@unerr-ai/unerr 0.2.0 → 0.2.2
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 +6 -0
- package/dist/cli.js +37236 -35793
- package/package.json +6 -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,1217 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Layer 7: Intelligence API — graph stats, health, entity detail, ledger-derived insight.
|
|
3
|
-
*
|
|
4
|
-
* All GET handlers. Data is read from in-process CozoDB and tracking modules.
|
|
5
|
-
*/
|
|
6
|
-
import { Hono } from "hono";
|
|
7
|
-
import { isTestFile } from "../../intelligence/indexer/test-detector.js";
|
|
8
|
-
import { computeOverallDurability, computePromptDurabilityProfiles, getMostFragile, } from "../../tracking/prompt-durability.js";
|
|
9
|
-
const ENTITY_BODY_MAX = 24_000;
|
|
10
|
-
function slimCallerCallee(e) {
|
|
11
|
-
return {
|
|
12
|
-
key: e.key,
|
|
13
|
-
name: e.name,
|
|
14
|
-
kind: e.kind,
|
|
15
|
-
file_path: e.file_path,
|
|
16
|
-
fan_in: e.fan_in,
|
|
17
|
-
fan_out: e.fan_out,
|
|
18
|
-
risk_level: e.risk_level,
|
|
19
|
-
};
|
|
20
|
-
}
|
|
21
|
-
function compactEntityBody(e) {
|
|
22
|
-
if (e.body.length <= ENTITY_BODY_MAX) {
|
|
23
|
-
return { ...e, body_truncated: false };
|
|
24
|
-
}
|
|
25
|
-
return {
|
|
26
|
-
...e,
|
|
27
|
-
body: `${e.body.slice(0, ENTITY_BODY_MAX)}…`,
|
|
28
|
-
body_truncated: true,
|
|
29
|
-
};
|
|
30
|
-
}
|
|
31
|
-
function parseLimit(raw, fallback, max) {
|
|
32
|
-
const n = Number.parseInt(raw ?? "", 10);
|
|
33
|
-
if (Number.isNaN(n) || n < 1)
|
|
34
|
-
return fallback;
|
|
35
|
-
return Math.min(n, max);
|
|
36
|
-
}
|
|
37
|
-
export function createIntelligenceRoutes(deps) {
|
|
38
|
-
const app = new Hono();
|
|
39
|
-
app.get("/search", async (c) => {
|
|
40
|
-
const start = performance.now();
|
|
41
|
-
const q = (c.req.query("q") ?? "").trim();
|
|
42
|
-
const limit = parseLimit(c.req.query("limit"), 20, 50);
|
|
43
|
-
if (!deps.localGraph) {
|
|
44
|
-
return c.json({
|
|
45
|
-
data: [],
|
|
46
|
-
_meta: {
|
|
47
|
-
source: "local",
|
|
48
|
-
graph: "unavailable",
|
|
49
|
-
latency_ms: Math.round((performance.now() - start) * 100) / 100,
|
|
50
|
-
},
|
|
51
|
-
}, 503);
|
|
52
|
-
}
|
|
53
|
-
if (q.length === 0) {
|
|
54
|
-
return c.json({
|
|
55
|
-
data: [],
|
|
56
|
-
_meta: {
|
|
57
|
-
source: "local",
|
|
58
|
-
empty_query: true,
|
|
59
|
-
latency_ms: Math.round((performance.now() - start) * 100) / 100,
|
|
60
|
-
},
|
|
61
|
-
});
|
|
62
|
-
}
|
|
63
|
-
const results = await deps.localGraph.searchEntities(q, limit);
|
|
64
|
-
return c.json({
|
|
65
|
-
data: results,
|
|
66
|
-
_meta: {
|
|
67
|
-
source: "local",
|
|
68
|
-
latency_ms: Math.round((performance.now() - start) * 100) / 100,
|
|
69
|
-
},
|
|
70
|
-
});
|
|
71
|
-
});
|
|
72
|
-
app.get("/graph-stats", async (c) => {
|
|
73
|
-
const start = performance.now();
|
|
74
|
-
if (!deps.localGraph) {
|
|
75
|
-
return c.json({
|
|
76
|
-
data: null,
|
|
77
|
-
_meta: {
|
|
78
|
-
source: "local",
|
|
79
|
-
graph: "unavailable",
|
|
80
|
-
latency_ms: Math.round((performance.now() - start) * 100) / 100,
|
|
81
|
-
},
|
|
82
|
-
}, 503);
|
|
83
|
-
}
|
|
84
|
-
const stats = await deps.localGraph.getLocalProjectStats();
|
|
85
|
-
return c.json({
|
|
86
|
-
data: stats,
|
|
87
|
-
_meta: {
|
|
88
|
-
source: "local",
|
|
89
|
-
latency_ms: Math.round((performance.now() - start) * 100) / 100,
|
|
90
|
-
},
|
|
91
|
-
});
|
|
92
|
-
});
|
|
93
|
-
app.get("/health", async (c) => {
|
|
94
|
-
const start = performance.now();
|
|
95
|
-
const grade = await deps.getHealthGrade();
|
|
96
|
-
if (!grade) {
|
|
97
|
-
return c.json({
|
|
98
|
-
data: null,
|
|
99
|
-
_meta: {
|
|
100
|
-
source: "local",
|
|
101
|
-
graph: "unavailable",
|
|
102
|
-
latency_ms: Math.round((performance.now() - start) * 100) / 100,
|
|
103
|
-
},
|
|
104
|
-
});
|
|
105
|
-
}
|
|
106
|
-
return c.json({
|
|
107
|
-
data: grade,
|
|
108
|
-
_meta: {
|
|
109
|
-
source: "local",
|
|
110
|
-
latency_ms: Math.round((performance.now() - start) * 100) / 100,
|
|
111
|
-
},
|
|
112
|
-
});
|
|
113
|
-
});
|
|
114
|
-
app.get("/top-entities", async (c) => {
|
|
115
|
-
const start = performance.now();
|
|
116
|
-
const limit = parseLimit(c.req.query("limit"), 10, 50);
|
|
117
|
-
const communityRaw = c.req.query("community");
|
|
118
|
-
const communityId = communityRaw !== undefined && communityRaw !== ""
|
|
119
|
-
? Number.parseInt(communityRaw, 10)
|
|
120
|
-
: undefined;
|
|
121
|
-
if (!deps.localGraph) {
|
|
122
|
-
return c.json({
|
|
123
|
-
data: [],
|
|
124
|
-
_meta: {
|
|
125
|
-
source: "local",
|
|
126
|
-
graph: "unavailable",
|
|
127
|
-
latency_ms: Math.round((performance.now() - start) * 100) / 100,
|
|
128
|
-
},
|
|
129
|
-
}, 503);
|
|
130
|
-
}
|
|
131
|
-
const nodes = communityId !== undefined && !Number.isNaN(communityId)
|
|
132
|
-
? await deps.localGraph.getCriticalNodes(limit, communityId)
|
|
133
|
-
: await deps.localGraph.getCriticalNodes(limit);
|
|
134
|
-
return c.json({
|
|
135
|
-
data: nodes,
|
|
136
|
-
_meta: {
|
|
137
|
-
source: "local",
|
|
138
|
-
latency_ms: Math.round((performance.now() - start) * 100) / 100,
|
|
139
|
-
},
|
|
140
|
-
});
|
|
141
|
-
});
|
|
142
|
-
// R2: Reading Tour — top backbone files for an unfamiliar repo.
|
|
143
|
-
// Picks the top-N most-depended-on entities, dedupes by file, generates a "why" line.
|
|
144
|
-
app.get("/reading-tour", async (c) => {
|
|
145
|
-
const start = performance.now();
|
|
146
|
-
const limit = parseLimit(c.req.query("limit"), 5, 10);
|
|
147
|
-
if (!deps.localGraph) {
|
|
148
|
-
return c.json({
|
|
149
|
-
data: { stops: [], estimatedReadingTimeMin: 0 },
|
|
150
|
-
_meta: {
|
|
151
|
-
source: "local",
|
|
152
|
-
graph: "unavailable",
|
|
153
|
-
latency_ms: Math.round((performance.now() - start) * 100) / 100,
|
|
154
|
-
},
|
|
155
|
-
}, 503);
|
|
156
|
-
}
|
|
157
|
-
// Pull a generous candidate pool so we can dedupe by file and still hit limit.
|
|
158
|
-
const candidates = await deps.localGraph.getCriticalNodes(limit * 6);
|
|
159
|
-
const seenFiles = new Set();
|
|
160
|
-
const stops = [];
|
|
161
|
-
for (const node of candidates) {
|
|
162
|
-
if (stops.length >= limit)
|
|
163
|
-
break;
|
|
164
|
-
if (seenFiles.has(node.file_path))
|
|
165
|
-
continue;
|
|
166
|
-
seenFiles.add(node.file_path);
|
|
167
|
-
const why = node.fan_in >= 30
|
|
168
|
-
? `Called from ${node.fan_in} places — load-bearing.`
|
|
169
|
-
: node.fan_in >= 15
|
|
170
|
-
? `${node.fan_in} dependents — touched by most of the codebase.`
|
|
171
|
-
: `${node.fan_in} dependents — a structural anchor.`;
|
|
172
|
-
stops.push({
|
|
173
|
-
rank: stops.length + 1,
|
|
174
|
-
key: node.key,
|
|
175
|
-
name: node.name,
|
|
176
|
-
file_path: node.file_path,
|
|
177
|
-
kind: node.kind,
|
|
178
|
-
fan_in: node.fan_in,
|
|
179
|
-
community_label: node.community_label,
|
|
180
|
-
why,
|
|
181
|
-
});
|
|
182
|
-
}
|
|
183
|
-
return c.json({
|
|
184
|
-
data: {
|
|
185
|
-
stops,
|
|
186
|
-
estimatedReadingTimeMin: Math.max(10, stops.length * 5),
|
|
187
|
-
},
|
|
188
|
-
_meta: {
|
|
189
|
-
source: "local",
|
|
190
|
-
latency_ms: Math.round((performance.now() - start) * 100) / 100,
|
|
191
|
-
},
|
|
192
|
-
});
|
|
193
|
-
});
|
|
194
|
-
app.get("/entity/:key", async (c) => {
|
|
195
|
-
const start = performance.now();
|
|
196
|
-
const key = decodeURIComponent(c.req.param("key"));
|
|
197
|
-
if (!deps.localGraph) {
|
|
198
|
-
return c.json({
|
|
199
|
-
data: null,
|
|
200
|
-
_meta: {
|
|
201
|
-
source: "local",
|
|
202
|
-
graph: "unavailable",
|
|
203
|
-
latency_ms: Math.round((performance.now() - start) * 100) / 100,
|
|
204
|
-
},
|
|
205
|
-
}, 503);
|
|
206
|
-
}
|
|
207
|
-
const entity = await deps.localGraph.getEntity(key);
|
|
208
|
-
if (!entity) {
|
|
209
|
-
return c.json({
|
|
210
|
-
data: null,
|
|
211
|
-
_meta: {
|
|
212
|
-
source: "local",
|
|
213
|
-
not_found: key,
|
|
214
|
-
latency_ms: Math.round((performance.now() - start) * 100) / 100,
|
|
215
|
-
},
|
|
216
|
-
}, 404);
|
|
217
|
-
}
|
|
218
|
-
const [callers, callees] = await Promise.all([
|
|
219
|
-
deps.localGraph.getCallersOf(key),
|
|
220
|
-
deps.localGraph.getCalleesOf(key),
|
|
221
|
-
]);
|
|
222
|
-
const slimmed = callers.map(slimCallerCallee);
|
|
223
|
-
const productionCallers = slimmed.filter((c) => !isTestFile(c.file_path));
|
|
224
|
-
const testCallers = slimmed.filter((c) => isTestFile(c.file_path));
|
|
225
|
-
return c.json({
|
|
226
|
-
data: {
|
|
227
|
-
entity: compactEntityBody(entity),
|
|
228
|
-
callers: slimmed,
|
|
229
|
-
production_callers: productionCallers,
|
|
230
|
-
test_callers: testCallers,
|
|
231
|
-
callees: callees.map(slimCallerCallee),
|
|
232
|
-
},
|
|
233
|
-
_meta: {
|
|
234
|
-
source: "local",
|
|
235
|
-
latency_ms: Math.round((performance.now() - start) * 100) / 100,
|
|
236
|
-
},
|
|
237
|
-
});
|
|
238
|
-
});
|
|
239
|
-
app.get("/test-coverage/:key", async (c) => {
|
|
240
|
-
const start = performance.now();
|
|
241
|
-
const key = decodeURIComponent(c.req.param("key"));
|
|
242
|
-
const includeTransitive = c.req.query("transitive") !== "false";
|
|
243
|
-
if (!deps.localGraph) {
|
|
244
|
-
return c.json({
|
|
245
|
-
data: [],
|
|
246
|
-
_meta: {
|
|
247
|
-
source: "local",
|
|
248
|
-
graph: "unavailable",
|
|
249
|
-
latency_ms: Math.round((performance.now() - start) * 100) / 100,
|
|
250
|
-
},
|
|
251
|
-
}, 503);
|
|
252
|
-
}
|
|
253
|
-
const coverage = await deps.localGraph.getTestCoverage(key, includeTransitive);
|
|
254
|
-
return c.json({
|
|
255
|
-
data: coverage,
|
|
256
|
-
_meta: {
|
|
257
|
-
source: "local",
|
|
258
|
-
entity_key: key,
|
|
259
|
-
include_transitive: includeTransitive,
|
|
260
|
-
latency_ms: Math.round((performance.now() - start) * 100) / 100,
|
|
261
|
-
},
|
|
262
|
-
});
|
|
263
|
-
});
|
|
264
|
-
app.get("/conventions", async (c) => {
|
|
265
|
-
const start = performance.now();
|
|
266
|
-
const { learnConventions } = await import("../../intelligence/convention-learner.js");
|
|
267
|
-
const entries = deps.getRecentLedgerEntries(500);
|
|
268
|
-
const conventions = learnConventions(entries);
|
|
269
|
-
return c.json({
|
|
270
|
-
data: conventions,
|
|
271
|
-
_meta: {
|
|
272
|
-
source: "local",
|
|
273
|
-
ledger_entries_sampled: entries.length,
|
|
274
|
-
latency_ms: Math.round((performance.now() - start) * 100) / 100,
|
|
275
|
-
},
|
|
276
|
-
});
|
|
277
|
-
});
|
|
278
|
-
app.get("/causal/:key", async (c) => {
|
|
279
|
-
const start = performance.now();
|
|
280
|
-
const key = decodeURIComponent(c.req.param("key"));
|
|
281
|
-
const { CausalBridge } = await import("../../tracking/causal-bridge.js");
|
|
282
|
-
const bridge = new CausalBridge(deps.unerrDir, deps.cwd);
|
|
283
|
-
const chain = await bridge.buildCausalChain(key);
|
|
284
|
-
return c.json({
|
|
285
|
-
data: chain,
|
|
286
|
-
_meta: {
|
|
287
|
-
source: "local",
|
|
288
|
-
latency_ms: Math.round((performance.now() - start) * 100) / 100,
|
|
289
|
-
},
|
|
290
|
-
});
|
|
291
|
-
});
|
|
292
|
-
app.get("/graph-visual", async (c) => {
|
|
293
|
-
const start = performance.now();
|
|
294
|
-
if (!deps.localGraph) {
|
|
295
|
-
return c.json({
|
|
296
|
-
data: { nodes: [], edges: [], communities: [] },
|
|
297
|
-
_meta: {
|
|
298
|
-
source: "local",
|
|
299
|
-
graph: "unavailable",
|
|
300
|
-
view_mode: "flat",
|
|
301
|
-
node_count: 0,
|
|
302
|
-
edge_count: 0,
|
|
303
|
-
latency_ms: Math.round((performance.now() - start) * 100) / 100,
|
|
304
|
-
},
|
|
305
|
-
}, 503);
|
|
306
|
-
}
|
|
307
|
-
// Fetch ALL entities and edges — no community filter (file-as-L0 approach)
|
|
308
|
-
const [entityResult, edgeResult] = await Promise.all([
|
|
309
|
-
deps.localGraph.db.run(`?[key, name, fp, fi, fo, community, rl, kind] :=
|
|
310
|
-
*entities{key, name, file_path: fp, fan_in: fi, fan_out: fo, community, risk_level: rl, kind},
|
|
311
|
-
kind != "file", kind != "module"`),
|
|
312
|
-
deps.localGraph.db.run("?[from_key, to_key, type] := *edges{from_key, to_key, type}"),
|
|
313
|
-
]);
|
|
314
|
-
// Use single source of truth for test detection (8-language support)
|
|
315
|
-
// Separate "contains" edges (parent→child) from relationship edges
|
|
316
|
-
const containsChildren = new Map(); // parent → Set<child>
|
|
317
|
-
const childToParent = new Map(); // child → parent
|
|
318
|
-
const relationshipEdges = [];
|
|
319
|
-
for (const row of edgeResult.rows) {
|
|
320
|
-
const from = row[0];
|
|
321
|
-
const to = row[1];
|
|
322
|
-
const type = row[2];
|
|
323
|
-
if (type === "contains") {
|
|
324
|
-
if (!containsChildren.has(from))
|
|
325
|
-
containsChildren.set(from, new Set());
|
|
326
|
-
containsChildren.get(from)?.add(to);
|
|
327
|
-
childToParent.set(to, from);
|
|
328
|
-
}
|
|
329
|
-
else {
|
|
330
|
-
relationshipEdges.push({ from, to, type });
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
// Build entity map for quick lookup
|
|
334
|
-
const entityMap = new Map();
|
|
335
|
-
for (const row of entityResult.rows) {
|
|
336
|
-
const [key, name, fp, fi, fo, community, rl, kind] = row;
|
|
337
|
-
entityMap.set(key, { key, name, fp, fi, fo, community, rl, kind });
|
|
338
|
-
}
|
|
339
|
-
// Kinds that should be collapsed into their parent node
|
|
340
|
-
const COLLAPSIBLE_KINDS = new Set([
|
|
341
|
-
"method",
|
|
342
|
-
"constructor",
|
|
343
|
-
"type",
|
|
344
|
-
"interface",
|
|
345
|
-
"enum",
|
|
346
|
-
"property",
|
|
347
|
-
]);
|
|
348
|
-
// Determine which entities are "child" entities that should collapse into parent
|
|
349
|
-
const collapsedInto = new Map(); // child → visual parent
|
|
350
|
-
const memberCounts = new Map(); // parent → member count
|
|
351
|
-
for (const [child, parent] of childToParent) {
|
|
352
|
-
const childEntity = entityMap.get(child);
|
|
353
|
-
if (!childEntity)
|
|
354
|
-
continue;
|
|
355
|
-
if (entityMap.has(parent) && COLLAPSIBLE_KINDS.has(childEntity.kind)) {
|
|
356
|
-
collapsedInto.set(child, parent);
|
|
357
|
-
memberCounts.set(parent, (memberCounts.get(parent) ?? 0) + 1);
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
// Redirect edges that point to/from collapsed children to their parent
|
|
361
|
-
const redirectedEdges = [];
|
|
362
|
-
const edgeDedup = new Set();
|
|
363
|
-
for (const e of relationshipEdges) {
|
|
364
|
-
const from = collapsedInto.get(e.from) ?? e.from;
|
|
365
|
-
const to = collapsedInto.get(e.to) ?? e.to;
|
|
366
|
-
if (from === to)
|
|
367
|
-
continue;
|
|
368
|
-
const dedupKey = `${from}→${to}→${e.type}`;
|
|
369
|
-
if (edgeDedup.has(dedupKey))
|
|
370
|
-
continue;
|
|
371
|
-
edgeDedup.add(dedupKey);
|
|
372
|
-
redirectedEdges.push({ from, to, type: e.type });
|
|
373
|
-
}
|
|
374
|
-
// Build final node list (excluding collapsed children)
|
|
375
|
-
const nodeKeys = new Set();
|
|
376
|
-
let collapsedCount = 0;
|
|
377
|
-
const nodes = [];
|
|
378
|
-
for (const [key, ent] of entityMap) {
|
|
379
|
-
if (collapsedInto.has(key)) {
|
|
380
|
-
collapsedCount++;
|
|
381
|
-
continue;
|
|
382
|
-
}
|
|
383
|
-
nodeKeys.add(key);
|
|
384
|
-
nodes.push({
|
|
385
|
-
id: key,
|
|
386
|
-
label: ent.name,
|
|
387
|
-
kind: ent.kind,
|
|
388
|
-
file: ent.fp,
|
|
389
|
-
fileGroup: ent.fp,
|
|
390
|
-
fanIn: ent.fi,
|
|
391
|
-
fanOut: ent.fo,
|
|
392
|
-
community: ent.community,
|
|
393
|
-
risk: ent.rl,
|
|
394
|
-
isTest: isTestFile(ent.fp),
|
|
395
|
-
members: memberCounts.get(key) ?? 0,
|
|
396
|
-
externalOut: 0,
|
|
397
|
-
externalIn: 0,
|
|
398
|
-
});
|
|
399
|
-
}
|
|
400
|
-
// Final edge filter: only edges where both endpoints are visible
|
|
401
|
-
const edges = redirectedEdges.filter((e) => nodeKeys.has(e.from) && nodeKeys.has(e.to));
|
|
402
|
-
// Compute external (cross-community) edge counts per node
|
|
403
|
-
const nodeById = new Map(nodes.map((n) => [n.id, n]));
|
|
404
|
-
for (const e of edges) {
|
|
405
|
-
const fromNode = nodeById.get(e.from);
|
|
406
|
-
const toNode = nodeById.get(e.to);
|
|
407
|
-
if (fromNode && toNode && fromNode.community !== toNode.community) {
|
|
408
|
-
fromNode.externalOut++;
|
|
409
|
-
toNode.externalIn++;
|
|
410
|
-
}
|
|
411
|
-
}
|
|
412
|
-
// ─── File-as-L0: Read materialized file-level data ─────────────────────
|
|
413
|
-
// Group entities by file_path
|
|
414
|
-
const fileEntityMap = new Map(); // filePath → entities in that file
|
|
415
|
-
for (const n of nodes) {
|
|
416
|
-
const list = fileEntityMap.get(n.file) ?? [];
|
|
417
|
-
list.push(n);
|
|
418
|
-
fileEntityMap.set(n.file, list);
|
|
419
|
-
}
|
|
420
|
-
// Read materialized file edges from CozoDB (computed at index time)
|
|
421
|
-
const fileEdges = [];
|
|
422
|
-
const fileEdgeWeights = new Map(); // "from→to" → weight for position computation
|
|
423
|
-
try {
|
|
424
|
-
const feResult = await deps.localGraph.db.run("?[from_file, to_file, weight] := *file_edges{from_file, to_file, weight}");
|
|
425
|
-
for (const row of feResult.rows) {
|
|
426
|
-
const from = row[0];
|
|
427
|
-
const to = row[1];
|
|
428
|
-
const weight = row[2];
|
|
429
|
-
fileEdges.push({ from, to, weight });
|
|
430
|
-
const key = `${from}→${to}`;
|
|
431
|
-
fileEdgeWeights.set(key, (fileEdgeWeights.get(key) ?? 0) + weight);
|
|
432
|
-
}
|
|
433
|
-
}
|
|
434
|
-
catch {
|
|
435
|
-
// File edges not yet materialized — empty
|
|
436
|
-
}
|
|
437
|
-
// Read materialized file communities from CozoDB
|
|
438
|
-
const fileCommunityAssignment = new Map();
|
|
439
|
-
const fileCommunityLabels = new Map();
|
|
440
|
-
const fileCommunityCohesion = new Map();
|
|
441
|
-
try {
|
|
442
|
-
const fcResult = await deps.localGraph.db.run("?[file_path, community, label, cohesion] := *file_communities{file_path, community, label, cohesion}");
|
|
443
|
-
for (const row of fcResult.rows) {
|
|
444
|
-
const fp = row[0];
|
|
445
|
-
const cid = row[1];
|
|
446
|
-
fileCommunityAssignment.set(fp, cid);
|
|
447
|
-
fileCommunityLabels.set(cid, row[2]);
|
|
448
|
-
fileCommunityCohesion.set(cid, row[3]);
|
|
449
|
-
}
|
|
450
|
-
}
|
|
451
|
-
catch {
|
|
452
|
-
// File communities not yet materialized — fallback: all in community 0
|
|
453
|
-
}
|
|
454
|
-
// Fallback: assign files without a community
|
|
455
|
-
for (const fp of fileEntityMap.keys()) {
|
|
456
|
-
if (!fileCommunityAssignment.has(fp)) {
|
|
457
|
-
fileCommunityAssignment.set(fp, 0);
|
|
458
|
-
}
|
|
459
|
-
}
|
|
460
|
-
// Build file nodes with aggregated metrics
|
|
461
|
-
const fileNodes = [];
|
|
462
|
-
const RISK_ORDER = { high: 3, medium: 2, low: 1 };
|
|
463
|
-
for (const [fp, ents] of fileEntityMap) {
|
|
464
|
-
const kinds = {};
|
|
465
|
-
let totalFanIn = 0;
|
|
466
|
-
let totalFanOut = 0;
|
|
467
|
-
let maxRiskLevel = "low";
|
|
468
|
-
for (const ent of ents) {
|
|
469
|
-
kinds[ent.kind] = (kinds[ent.kind] ?? 0) + 1;
|
|
470
|
-
totalFanIn += ent.fanIn;
|
|
471
|
-
totalFanOut += ent.fanOut;
|
|
472
|
-
if ((RISK_ORDER[ent.risk] ?? 0) > (RISK_ORDER[maxRiskLevel] ?? 0)) {
|
|
473
|
-
maxRiskLevel = ent.risk;
|
|
474
|
-
}
|
|
475
|
-
}
|
|
476
|
-
const myComm = fileCommunityAssignment.get(fp) ?? 0;
|
|
477
|
-
let extOut = 0;
|
|
478
|
-
let extIn = 0;
|
|
479
|
-
for (const fe of fileEdges) {
|
|
480
|
-
if (fe.from === fp) {
|
|
481
|
-
const targetComm = fileCommunityAssignment.get(fe.to) ?? 0;
|
|
482
|
-
if (targetComm !== myComm)
|
|
483
|
-
extOut += fe.weight;
|
|
484
|
-
}
|
|
485
|
-
if (fe.to === fp) {
|
|
486
|
-
const sourceComm = fileCommunityAssignment.get(fe.from) ?? 0;
|
|
487
|
-
if (sourceComm !== myComm)
|
|
488
|
-
extIn += fe.weight;
|
|
489
|
-
}
|
|
490
|
-
}
|
|
491
|
-
const filename = fp.split("/").pop() ?? fp;
|
|
492
|
-
fileNodes.push({
|
|
493
|
-
id: fp,
|
|
494
|
-
label: filename,
|
|
495
|
-
filePath: fp,
|
|
496
|
-
entityCount: ents.length,
|
|
497
|
-
totalFanIn,
|
|
498
|
-
totalFanOut,
|
|
499
|
-
fileCommunity: myComm,
|
|
500
|
-
maxRisk: maxRiskLevel,
|
|
501
|
-
isTest: isTestFile(fp),
|
|
502
|
-
kinds,
|
|
503
|
-
externalOut: extOut,
|
|
504
|
-
externalIn: extIn,
|
|
505
|
-
});
|
|
506
|
-
}
|
|
507
|
-
// Build file communities from materialized data
|
|
508
|
-
const fileCommunityGroups = new Map();
|
|
509
|
-
for (const [fp, comm] of fileCommunityAssignment) {
|
|
510
|
-
const list = fileCommunityGroups.get(comm) ?? [];
|
|
511
|
-
list.push(fp);
|
|
512
|
-
fileCommunityGroups.set(comm, list);
|
|
513
|
-
}
|
|
514
|
-
const fileCommunities = [];
|
|
515
|
-
for (const [commId, files] of fileCommunityGroups) {
|
|
516
|
-
const label = fileCommunityLabels.get(commId) ?? `cluster-${commId}`;
|
|
517
|
-
const cohesion = fileCommunityCohesion.get(commId) ?? 1.0;
|
|
518
|
-
let entityCount = 0;
|
|
519
|
-
for (const fp of files) {
|
|
520
|
-
entityCount += fileEntityMap.get(fp)?.length ?? 0;
|
|
521
|
-
}
|
|
522
|
-
// Inter-community edges from materialized file edges
|
|
523
|
-
const interEdgeMap = new Map();
|
|
524
|
-
for (const fe of fileEdges) {
|
|
525
|
-
const fromComm = fileCommunityAssignment.get(fe.from) ?? -1;
|
|
526
|
-
const toComm = fileCommunityAssignment.get(fe.to) ?? -1;
|
|
527
|
-
if (fromComm === commId && toComm !== commId && toComm >= 0) {
|
|
528
|
-
interEdgeMap.set(toComm, (interEdgeMap.get(toComm) ?? 0) + fe.weight);
|
|
529
|
-
}
|
|
530
|
-
}
|
|
531
|
-
fileCommunities.push({
|
|
532
|
-
id: commId,
|
|
533
|
-
label,
|
|
534
|
-
fileCount: files.length,
|
|
535
|
-
entityCount,
|
|
536
|
-
cohesion: Math.round(cohesion * 100) / 100,
|
|
537
|
-
files,
|
|
538
|
-
inter_edges: Array.from(interEdgeMap.entries()).map(([target, weight]) => ({ target, weight })),
|
|
539
|
-
});
|
|
540
|
-
}
|
|
541
|
-
// Compute cross-community external counts on entity nodes
|
|
542
|
-
for (const n of nodes) {
|
|
543
|
-
const myFileComm = fileCommunityAssignment.get(n.file) ?? 0;
|
|
544
|
-
n.externalOut = 0;
|
|
545
|
-
n.externalIn = 0;
|
|
546
|
-
for (const e of edges) {
|
|
547
|
-
if (e.from === n.id) {
|
|
548
|
-
const targetNode = nodeById.get(e.to);
|
|
549
|
-
if (targetNode &&
|
|
550
|
-
(fileCommunityAssignment.get(targetNode.file) ?? 0) !== myFileComm) {
|
|
551
|
-
n.externalOut++;
|
|
552
|
-
}
|
|
553
|
-
}
|
|
554
|
-
if (e.to === n.id) {
|
|
555
|
-
const sourceNode = nodeById.get(e.from);
|
|
556
|
-
if (sourceNode &&
|
|
557
|
-
(fileCommunityAssignment.get(sourceNode.file) ?? 0) !== myFileComm) {
|
|
558
|
-
n.externalIn++;
|
|
559
|
-
}
|
|
560
|
-
}
|
|
561
|
-
}
|
|
562
|
-
}
|
|
563
|
-
// Read class edges for L1 class-level visualization
|
|
564
|
-
let classEdges = [];
|
|
565
|
-
try {
|
|
566
|
-
const ceResult = await deps.localGraph.db.run("?[from_class, to_class, edge_type, weight] := *class_edges{from_class, to_class, edge_type, weight}");
|
|
567
|
-
classEdges = ceResult.rows.map((row) => ({
|
|
568
|
-
from: row[0],
|
|
569
|
-
to: row[1],
|
|
570
|
-
type: row[2],
|
|
571
|
-
weight: row[3],
|
|
572
|
-
}));
|
|
573
|
-
}
|
|
574
|
-
catch {
|
|
575
|
-
// Class edges not materialized — empty
|
|
576
|
-
}
|
|
577
|
-
// Determine view_mode based on file count and file community count
|
|
578
|
-
const totalFileCount = fileNodes.length;
|
|
579
|
-
const totalFileCommunities = fileCommunities.length;
|
|
580
|
-
let viewMode;
|
|
581
|
-
if (totalFileCommunities <= 2 && totalFileCount < 15) {
|
|
582
|
-
viewMode = "flat";
|
|
583
|
-
}
|
|
584
|
-
else if (totalFileCommunities <= 3) {
|
|
585
|
-
viewMode = "file-clusters";
|
|
586
|
-
}
|
|
587
|
-
else {
|
|
588
|
-
viewMode = "hierarchical";
|
|
589
|
-
}
|
|
590
|
-
// ─── Server-side position pre-computation ──────────────────────────────
|
|
591
|
-
const positions = { communities: {}, files: {}, entities: {} };
|
|
592
|
-
try {
|
|
593
|
-
const graphologyMod = (await import("graphology"));
|
|
594
|
-
const fa2Mod = (await import("graphology-layout-forceatlas2"));
|
|
595
|
-
const Graph = graphologyMod.default ?? graphologyMod;
|
|
596
|
-
const forceAtlas2 = fa2Mod.default ?? fa2Mod;
|
|
597
|
-
// Community-level positions
|
|
598
|
-
if (fileCommunities.length > 1) {
|
|
599
|
-
const commGraph = new Graph({ type: "undirected" });
|
|
600
|
-
for (const comm of fileCommunities) {
|
|
601
|
-
commGraph.addNode(String(comm.id), {
|
|
602
|
-
x: Math.random() * 100 - 50,
|
|
603
|
-
y: Math.random() * 100 - 50,
|
|
604
|
-
size: comm.entityCount,
|
|
605
|
-
});
|
|
606
|
-
}
|
|
607
|
-
for (const comm of fileCommunities) {
|
|
608
|
-
for (const ie of comm.inter_edges) {
|
|
609
|
-
if (commGraph.hasNode(String(ie.target)) &&
|
|
610
|
-
!commGraph.hasEdge(String(comm.id), String(ie.target))) {
|
|
611
|
-
commGraph.addEdge(String(comm.id), String(ie.target), {
|
|
612
|
-
weight: ie.weight,
|
|
613
|
-
});
|
|
614
|
-
}
|
|
615
|
-
}
|
|
616
|
-
}
|
|
617
|
-
forceAtlas2.assign(commGraph, {
|
|
618
|
-
iterations: 100,
|
|
619
|
-
settings: {
|
|
620
|
-
gravity: 1,
|
|
621
|
-
scalingRatio: 10,
|
|
622
|
-
strongGravityMode: true,
|
|
623
|
-
barnesHutOptimize: true,
|
|
624
|
-
},
|
|
625
|
-
});
|
|
626
|
-
commGraph.forEachNode((node, attrs) => {
|
|
627
|
-
positions.communities[Number(node)] = {
|
|
628
|
-
x: Math.round(attrs.x),
|
|
629
|
-
y: Math.round(attrs.y),
|
|
630
|
-
};
|
|
631
|
-
});
|
|
632
|
-
}
|
|
633
|
-
// File-level positions (one layout per community)
|
|
634
|
-
for (const comm of fileCommunities) {
|
|
635
|
-
const filesInComm = comm.files;
|
|
636
|
-
if (filesInComm.length === 0)
|
|
637
|
-
continue;
|
|
638
|
-
const fileGraph = new Graph({ type: "undirected" });
|
|
639
|
-
for (const fp of filesInComm) {
|
|
640
|
-
fileGraph.addNode(fp, {
|
|
641
|
-
x: Math.random() * 100 - 50,
|
|
642
|
-
y: Math.random() * 100 - 50,
|
|
643
|
-
size: fileEntityMap.get(fp)?.length ?? 1,
|
|
644
|
-
});
|
|
645
|
-
}
|
|
646
|
-
for (const [key, weight] of fileEdgeWeights) {
|
|
647
|
-
const parts = key.split("→");
|
|
648
|
-
const from = parts[0];
|
|
649
|
-
const to = parts[1];
|
|
650
|
-
if (fileGraph.hasNode(from) &&
|
|
651
|
-
fileGraph.hasNode(to) &&
|
|
652
|
-
!fileGraph.hasEdge(from, to)) {
|
|
653
|
-
fileGraph.addEdge(from, to, { weight });
|
|
654
|
-
}
|
|
655
|
-
}
|
|
656
|
-
if (fileGraph.order > 1) {
|
|
657
|
-
forceAtlas2.assign(fileGraph, {
|
|
658
|
-
iterations: 80,
|
|
659
|
-
settings: {
|
|
660
|
-
gravity: 2,
|
|
661
|
-
scalingRatio: 5,
|
|
662
|
-
barnesHutOptimize: fileGraph.order > 30,
|
|
663
|
-
},
|
|
664
|
-
});
|
|
665
|
-
}
|
|
666
|
-
fileGraph.forEachNode((node, attrs) => {
|
|
667
|
-
positions.files[node] = {
|
|
668
|
-
x: Math.round(attrs.x),
|
|
669
|
-
y: Math.round(attrs.y),
|
|
670
|
-
};
|
|
671
|
-
});
|
|
672
|
-
}
|
|
673
|
-
// Entity-level positions (vertical code-order layout per file)
|
|
674
|
-
for (const [, ents] of fileEntityMap) {
|
|
675
|
-
if (ents.length <= 1) {
|
|
676
|
-
if (ents.length === 1) {
|
|
677
|
-
const ent0 = ents[0];
|
|
678
|
-
if (ent0)
|
|
679
|
-
positions.entities[ent0.id] = { x: 0, y: 0 };
|
|
680
|
-
}
|
|
681
|
-
continue;
|
|
682
|
-
}
|
|
683
|
-
const sorted = [...ents].sort((a, b) => {
|
|
684
|
-
const kindOrder = {
|
|
685
|
-
class: 0,
|
|
686
|
-
function: 1,
|
|
687
|
-
variable: 2,
|
|
688
|
-
};
|
|
689
|
-
const ka = kindOrder[a.kind] ?? 3;
|
|
690
|
-
const kb = kindOrder[b.kind] ?? 3;
|
|
691
|
-
if (ka !== kb)
|
|
692
|
-
return ka - kb;
|
|
693
|
-
return a.label.localeCompare(b.label);
|
|
694
|
-
});
|
|
695
|
-
sorted.forEach((ent, i) => {
|
|
696
|
-
positions.entities[ent.id] = { x: 0, y: i * 60 };
|
|
697
|
-
});
|
|
698
|
-
}
|
|
699
|
-
}
|
|
700
|
-
catch {
|
|
701
|
-
// Position computation is non-critical — frontend falls back to force-directed
|
|
702
|
-
}
|
|
703
|
-
return c.json({
|
|
704
|
-
data: {
|
|
705
|
-
nodes,
|
|
706
|
-
edges,
|
|
707
|
-
fileNodes,
|
|
708
|
-
fileEdges,
|
|
709
|
-
fileCommunities,
|
|
710
|
-
classEdges,
|
|
711
|
-
positions,
|
|
712
|
-
},
|
|
713
|
-
_meta: {
|
|
714
|
-
source: "local",
|
|
715
|
-
view_mode: viewMode,
|
|
716
|
-
node_count: nodes.length,
|
|
717
|
-
edge_count: edges.length,
|
|
718
|
-
file_count: totalFileCount,
|
|
719
|
-
file_edge_count: fileEdges.length,
|
|
720
|
-
class_edge_count: classEdges.length,
|
|
721
|
-
collapsed_count: collapsedCount,
|
|
722
|
-
total_entities: entityResult.rows.length,
|
|
723
|
-
latency_ms: Math.round((performance.now() - start) * 100) / 100,
|
|
724
|
-
},
|
|
725
|
-
});
|
|
726
|
-
});
|
|
727
|
-
app.get("/risk-hotspots", async (c) => {
|
|
728
|
-
const start = performance.now();
|
|
729
|
-
const limit = parseLimit(c.req.query("limit"), 20, 50);
|
|
730
|
-
if (!deps.localGraph) {
|
|
731
|
-
return c.json({
|
|
732
|
-
data: [],
|
|
733
|
-
_meta: {
|
|
734
|
-
source: "local",
|
|
735
|
-
graph: "unavailable",
|
|
736
|
-
latency_ms: Math.round((performance.now() - start) * 100) / 100,
|
|
737
|
-
},
|
|
738
|
-
}, 503);
|
|
739
|
-
}
|
|
740
|
-
// Get top entities by degree
|
|
741
|
-
const topNodes = await deps.localGraph.getCriticalNodes(limit);
|
|
742
|
-
// Batch test coverage check: for each entity, count direct + transitive test edges
|
|
743
|
-
// Single Datalog query to get test counts for all keys at once
|
|
744
|
-
const keys = topNodes.map((n) => n.key);
|
|
745
|
-
const testCountMap = new Map();
|
|
746
|
-
if (keys.length > 0) {
|
|
747
|
-
try {
|
|
748
|
-
// Direct tests
|
|
749
|
-
const directResult = await deps.localGraph.db.run(`?[target, count(tk)] := *edges{from_key: tk, to_key: target, type: "tests"},
|
|
750
|
-
target in $keys`, { keys });
|
|
751
|
-
for (const row of directResult.rows) {
|
|
752
|
-
testCountMap.set(row[0], row[1]);
|
|
753
|
-
}
|
|
754
|
-
// Transitive tests (depth 2: test → caller → target)
|
|
755
|
-
const transitiveResult = await deps.localGraph.db.run(`?[target, count(tk)] := *edges{from_key: mid, to_key: target, type: "calls"},
|
|
756
|
-
*edges{from_key: tk, to_key: mid, type: "tests"},
|
|
757
|
-
target in $keys`, { keys });
|
|
758
|
-
for (const row of transitiveResult.rows) {
|
|
759
|
-
const key = row[0];
|
|
760
|
-
const existing = testCountMap.get(key) ?? 0;
|
|
761
|
-
testCountMap.set(key, existing + row[1]);
|
|
762
|
-
}
|
|
763
|
-
}
|
|
764
|
-
catch {
|
|
765
|
-
// Test coverage query failed — leave counts at 0
|
|
766
|
-
}
|
|
767
|
-
}
|
|
768
|
-
// Also get caller counts for blast radius context
|
|
769
|
-
const callerCountMap = new Map();
|
|
770
|
-
if (keys.length > 0) {
|
|
771
|
-
try {
|
|
772
|
-
const callerResult = await deps.localGraph.db.run(`?[target, count(caller)] := *edges{from_key: caller, to_key: target, type: "calls"},
|
|
773
|
-
target in $keys`, { keys });
|
|
774
|
-
for (const row of callerResult.rows) {
|
|
775
|
-
callerCountMap.set(row[0], row[1]);
|
|
776
|
-
}
|
|
777
|
-
}
|
|
778
|
-
catch {
|
|
779
|
-
// Caller count query failed — leave counts at 0
|
|
780
|
-
}
|
|
781
|
-
}
|
|
782
|
-
const hotspots = topNodes.map((n) => ({
|
|
783
|
-
key: n.key,
|
|
784
|
-
name: n.name,
|
|
785
|
-
file_path: n.file_path,
|
|
786
|
-
kind: n.kind,
|
|
787
|
-
fan_in: n.fan_in,
|
|
788
|
-
fan_out: n.fan_out,
|
|
789
|
-
degree: n.degree,
|
|
790
|
-
risk_level: n.risk_level,
|
|
791
|
-
community_label: n.community_label,
|
|
792
|
-
test_count: testCountMap.get(n.key) ?? 0,
|
|
793
|
-
caller_count: callerCountMap.get(n.key) ?? 0,
|
|
794
|
-
}));
|
|
795
|
-
return c.json({
|
|
796
|
-
data: hotspots,
|
|
797
|
-
_meta: {
|
|
798
|
-
source: "local",
|
|
799
|
-
latency_ms: Math.round((performance.now() - start) * 100) / 100,
|
|
800
|
-
},
|
|
801
|
-
});
|
|
802
|
-
});
|
|
803
|
-
app.get("/insights", async (c) => {
|
|
804
|
-
const start = performance.now();
|
|
805
|
-
if (!deps.localGraph) {
|
|
806
|
-
return c.json({
|
|
807
|
-
data: null,
|
|
808
|
-
_meta: {
|
|
809
|
-
source: "local",
|
|
810
|
-
graph: "unavailable",
|
|
811
|
-
latency_ms: Math.round((performance.now() - start) * 100) / 100,
|
|
812
|
-
},
|
|
813
|
-
}, 503);
|
|
814
|
-
}
|
|
815
|
-
// ── Gather raw data with parallel queries ──────────────────────────
|
|
816
|
-
const topN = 50; // Analyze more than we show for accurate metrics
|
|
817
|
-
const topNodes = await deps.localGraph.getCriticalNodes(topN);
|
|
818
|
-
const keys = topNodes.map((n) => n.key);
|
|
819
|
-
// Batch test coverage for all top entities
|
|
820
|
-
const testCountMap = new Map();
|
|
821
|
-
if (keys.length > 0) {
|
|
822
|
-
try {
|
|
823
|
-
const directResult = await deps.localGraph.db.run(`?[target, count(tk)] := *edges{from_key: tk, to_key: target, type: "tests"},
|
|
824
|
-
target in $keys`, { keys });
|
|
825
|
-
for (const row of directResult.rows) {
|
|
826
|
-
testCountMap.set(row[0], row[1]);
|
|
827
|
-
}
|
|
828
|
-
const transitiveResult = await deps.localGraph.db.run(`?[target, count(tk)] := *edges{from_key: mid, to_key: target, type: "calls"},
|
|
829
|
-
*edges{from_key: tk, to_key: mid, type: "tests"},
|
|
830
|
-
target in $keys`, { keys });
|
|
831
|
-
for (const row of transitiveResult.rows) {
|
|
832
|
-
const k = row[0];
|
|
833
|
-
testCountMap.set(k, (testCountMap.get(k) ?? 0) + row[1]);
|
|
834
|
-
}
|
|
835
|
-
}
|
|
836
|
-
catch {
|
|
837
|
-
// Test coverage unavailable
|
|
838
|
-
}
|
|
839
|
-
}
|
|
840
|
-
// ── Compute blast-radius-weighted coverage ────────────────────────
|
|
841
|
-
// Weight each entity by its fan_in (how many things depend on it)
|
|
842
|
-
let totalBlastRadius = 0;
|
|
843
|
-
let testedBlastRadius = 0;
|
|
844
|
-
let untestedBlastRadius = 0;
|
|
845
|
-
for (const n of topNodes) {
|
|
846
|
-
const weight = Math.max(n.fan_in, 1); // min 1 so isolated entities still count
|
|
847
|
-
totalBlastRadius += weight;
|
|
848
|
-
if ((testCountMap.get(n.key) ?? 0) > 0) {
|
|
849
|
-
testedBlastRadius += weight;
|
|
850
|
-
}
|
|
851
|
-
else {
|
|
852
|
-
untestedBlastRadius += weight;
|
|
853
|
-
}
|
|
854
|
-
}
|
|
855
|
-
const blastRadiusCoverage = totalBlastRadius > 0
|
|
856
|
-
? Math.round((testedBlastRadius / totalBlastRadius) * 100)
|
|
857
|
-
: 0;
|
|
858
|
-
// ── Identify bottlenecks ──────────────────────────────────────────
|
|
859
|
-
// Bottleneck = high fan_in AND high fan_out AND zero tests
|
|
860
|
-
// These are structural chokepoints — everything flows through them
|
|
861
|
-
const bottlenecks = topNodes
|
|
862
|
-
.filter((n) => n.fan_in >= 3 &&
|
|
863
|
-
n.fan_out >= 2 &&
|
|
864
|
-
(testCountMap.get(n.key) ?? 0) === 0)
|
|
865
|
-
.map((n) => ({
|
|
866
|
-
key: n.key,
|
|
867
|
-
name: n.name,
|
|
868
|
-
file_path: n.file_path,
|
|
869
|
-
kind: n.kind,
|
|
870
|
-
fan_in: n.fan_in,
|
|
871
|
-
fan_out: n.fan_out,
|
|
872
|
-
degree: n.degree,
|
|
873
|
-
risk_level: n.risk_level,
|
|
874
|
-
}));
|
|
875
|
-
// ── Risk distribution ─────────────────────────────────────────────
|
|
876
|
-
const riskDistribution = { high: 0, medium: 0, low: 0 };
|
|
877
|
-
for (const n of topNodes) {
|
|
878
|
-
const level = n.risk_level;
|
|
879
|
-
if (level in riskDistribution)
|
|
880
|
-
riskDistribution[level]++;
|
|
881
|
-
}
|
|
882
|
-
// ── Risk concentration ────────────────────────────────────────────
|
|
883
|
-
// What % of total degree sits in the top 5 entities?
|
|
884
|
-
const sortedByDegree = [...topNodes].sort((a, b) => b.degree - a.degree);
|
|
885
|
-
const totalDegree = topNodes.reduce((s, n) => s + n.degree, 0);
|
|
886
|
-
const top5Degree = sortedByDegree
|
|
887
|
-
.slice(0, 5)
|
|
888
|
-
.reduce((s, n) => s + n.degree, 0);
|
|
889
|
-
const riskConcentration = totalDegree > 0 ? Math.round((top5Degree / totalDegree) * 100) : 0;
|
|
890
|
-
// ── Community health ──────────────────────────────────────────────
|
|
891
|
-
const communityMap = new Map();
|
|
892
|
-
for (const n of topNodes) {
|
|
893
|
-
const label = n.community_label || `cluster-${n.community}`;
|
|
894
|
-
let comm = communityMap.get(label);
|
|
895
|
-
if (!comm) {
|
|
896
|
-
comm = {
|
|
897
|
-
label,
|
|
898
|
-
entities: 0,
|
|
899
|
-
totalDegree: 0,
|
|
900
|
-
riskHigh: 0,
|
|
901
|
-
riskMedium: 0,
|
|
902
|
-
riskLow: 0,
|
|
903
|
-
untested: 0,
|
|
904
|
-
tested: 0,
|
|
905
|
-
totalFanIn: 0,
|
|
906
|
-
untestedFanIn: 0,
|
|
907
|
-
};
|
|
908
|
-
communityMap.set(label, comm);
|
|
909
|
-
}
|
|
910
|
-
comm.entities++;
|
|
911
|
-
comm.totalDegree += n.degree;
|
|
912
|
-
comm.totalFanIn += n.fan_in;
|
|
913
|
-
const hasCoverage = (testCountMap.get(n.key) ?? 0) > 0;
|
|
914
|
-
if (hasCoverage)
|
|
915
|
-
comm.tested++;
|
|
916
|
-
else {
|
|
917
|
-
comm.untested++;
|
|
918
|
-
comm.untestedFanIn += n.fan_in;
|
|
919
|
-
}
|
|
920
|
-
if (n.risk_level === "high")
|
|
921
|
-
comm.riskHigh++;
|
|
922
|
-
else if (n.risk_level === "medium")
|
|
923
|
-
comm.riskMedium++;
|
|
924
|
-
else
|
|
925
|
-
comm.riskLow++;
|
|
926
|
-
}
|
|
927
|
-
// Read cohesion data from file communities if available
|
|
928
|
-
const communityHealth = [];
|
|
929
|
-
for (const [, comm] of communityMap) {
|
|
930
|
-
const total = comm.tested + comm.untested;
|
|
931
|
-
communityHealth.push({
|
|
932
|
-
label: comm.label,
|
|
933
|
-
entities: comm.entities,
|
|
934
|
-
tested: comm.tested,
|
|
935
|
-
untested: comm.untested,
|
|
936
|
-
coveragePct: total > 0 ? Math.round((comm.tested / total) * 100) : 0,
|
|
937
|
-
blastRadiusCoveragePct: comm.totalFanIn > 0
|
|
938
|
-
? Math.round(((comm.totalFanIn - comm.untestedFanIn) / comm.totalFanIn) * 100)
|
|
939
|
-
: 0,
|
|
940
|
-
riskHigh: comm.riskHigh,
|
|
941
|
-
riskMedium: comm.riskMedium,
|
|
942
|
-
riskLow: comm.riskLow,
|
|
943
|
-
totalDegree: comm.totalDegree,
|
|
944
|
-
});
|
|
945
|
-
}
|
|
946
|
-
// Sort communities: worst blast-radius coverage first
|
|
947
|
-
communityHealth.sort((a, b) => a.blastRadiusCoveragePct - b.blastRadiusCoveragePct);
|
|
948
|
-
// ── Most coupled community pair ───────────────────────────────────
|
|
949
|
-
let mostCoupledPair = null;
|
|
950
|
-
try {
|
|
951
|
-
const fcResult = await deps.localGraph.db.run(`?[from_label, to_label, w] :=
|
|
952
|
-
*file_edges{from_file, to_file, weight: w},
|
|
953
|
-
*file_communities{file_path: from_file, label: from_label},
|
|
954
|
-
*file_communities{file_path: to_file, label: to_label},
|
|
955
|
-
from_label != to_label
|
|
956
|
-
:order -w
|
|
957
|
-
:limit 1`);
|
|
958
|
-
if (fcResult.rows.length > 0) {
|
|
959
|
-
const [from, to, weight] = fcResult.rows[0];
|
|
960
|
-
mostCoupledPair = { from, to, weight };
|
|
961
|
-
}
|
|
962
|
-
}
|
|
963
|
-
catch {
|
|
964
|
-
// File communities not available
|
|
965
|
-
}
|
|
966
|
-
// ── Generate narrative insights ───────────────────────────────────
|
|
967
|
-
const insights = [];
|
|
968
|
-
// Insight 1: Blast-radius-weighted coverage gap
|
|
969
|
-
if (blastRadiusCoverage < 50) {
|
|
970
|
-
insights.push({
|
|
971
|
-
id: "blast-radius-coverage",
|
|
972
|
-
severity: "critical",
|
|
973
|
-
title: `Blast-radius coverage is only ${blastRadiusCoverage}%`,
|
|
974
|
-
description: `${untestedBlastRadius} dependency-weighted risk sits in untested code. Your entity-count coverage looks better but hides that your most-depended-on code is unprotected.`,
|
|
975
|
-
metric: blastRadiusCoverage,
|
|
976
|
-
metricLabel: "blast-radius coverage",
|
|
977
|
-
});
|
|
978
|
-
}
|
|
979
|
-
else if (blastRadiusCoverage < 75) {
|
|
980
|
-
insights.push({
|
|
981
|
-
id: "blast-radius-coverage",
|
|
982
|
-
severity: "warning",
|
|
983
|
-
title: `Blast-radius coverage at ${blastRadiusCoverage}%`,
|
|
984
|
-
description: `Your most critical code paths are partially covered, but ${untestedBlastRadius} dependency-weight remains untested.`,
|
|
985
|
-
metric: blastRadiusCoverage,
|
|
986
|
-
metricLabel: "blast-radius coverage",
|
|
987
|
-
});
|
|
988
|
-
}
|
|
989
|
-
else {
|
|
990
|
-
insights.push({
|
|
991
|
-
id: "blast-radius-coverage",
|
|
992
|
-
severity: "positive",
|
|
993
|
-
title: `Strong blast-radius coverage: ${blastRadiusCoverage}%`,
|
|
994
|
-
description: "Your highest-impact code paths are well tested. Regressions are unlikely to cascade silently.",
|
|
995
|
-
metric: blastRadiusCoverage,
|
|
996
|
-
metricLabel: "blast-radius coverage",
|
|
997
|
-
});
|
|
998
|
-
}
|
|
999
|
-
// Insight 2: Bottleneck alert
|
|
1000
|
-
if (bottlenecks.length > 0) {
|
|
1001
|
-
const totalBottleneckFanIn = bottlenecks.reduce((s, b) => s + b.fan_in, 0);
|
|
1002
|
-
insights.push({
|
|
1003
|
-
id: "bottlenecks",
|
|
1004
|
-
severity: bottlenecks.length >= 5 ? "critical" : "warning",
|
|
1005
|
-
title: `${bottlenecks.length} structural bottleneck${bottlenecks.length !== 1 ? "s" : ""} with zero tests`,
|
|
1006
|
-
description: `These functions have high fan-in AND fan-out — all dependency traffic flows through them. Combined, ${totalBottleneckFanIn} dependents are exposed. A failure in any one cascades in both directions.`,
|
|
1007
|
-
metric: bottlenecks.length,
|
|
1008
|
-
metricLabel: "bottlenecks",
|
|
1009
|
-
});
|
|
1010
|
-
}
|
|
1011
|
-
// Insight 3: Risk concentration
|
|
1012
|
-
if (riskConcentration > 40) {
|
|
1013
|
-
insights.push({
|
|
1014
|
-
id: "risk-concentration",
|
|
1015
|
-
severity: riskConcentration > 60 ? "warning" : "info",
|
|
1016
|
-
title: `${riskConcentration}% of structural risk concentrated in top 5 entities`,
|
|
1017
|
-
description: `Your risk isn't spread evenly — a small number of entities carry most of the dependency weight. Focus testing and review here for maximum impact.`,
|
|
1018
|
-
metric: riskConcentration,
|
|
1019
|
-
metricLabel: "risk in top 5",
|
|
1020
|
-
});
|
|
1021
|
-
}
|
|
1022
|
-
// Insight 4: Most coupled pair
|
|
1023
|
-
if (mostCoupledPair && mostCoupledPair.weight >= 3) {
|
|
1024
|
-
insights.push({
|
|
1025
|
-
id: "coupling-hotspot",
|
|
1026
|
-
severity: mostCoupledPair.weight >= 10 ? "warning" : "info",
|
|
1027
|
-
title: `"${mostCoupledPair.from}" and "${mostCoupledPair.to}" are tightly coupled`,
|
|
1028
|
-
description: `${mostCoupledPair.weight} cross-boundary calls between these modules. Changes in one are likely to require changes in the other.`,
|
|
1029
|
-
metric: mostCoupledPair.weight,
|
|
1030
|
-
metricLabel: "cross-boundary calls",
|
|
1031
|
-
});
|
|
1032
|
-
}
|
|
1033
|
-
// Insight 5: Positive — low risk distribution
|
|
1034
|
-
if (riskDistribution.high === 0 && topNodes.length > 0) {
|
|
1035
|
-
insights.push({
|
|
1036
|
-
id: "no-high-risk",
|
|
1037
|
-
severity: "positive",
|
|
1038
|
-
title: "No high-risk entities detected",
|
|
1039
|
-
description: "Your top entities are well-balanced with moderate dependency counts. The codebase structure is healthy.",
|
|
1040
|
-
});
|
|
1041
|
-
}
|
|
1042
|
-
// ── Health score (0-100) ──────────────────────────────────────────
|
|
1043
|
-
// Weighted: 40% blast-radius coverage, 25% bottleneck penalty, 20% risk spread, 15% high-risk penalty
|
|
1044
|
-
const bottleneckPenalty = Math.min(bottlenecks.length * 5, 25);
|
|
1045
|
-
const highRiskPenalty = Math.min(riskDistribution.high * 3, 15);
|
|
1046
|
-
const concentrationPenalty = riskConcentration > 50 ? (riskConcentration - 50) * 0.4 : 0;
|
|
1047
|
-
const healthScore = Math.max(0, Math.min(100, Math.round(blastRadiusCoverage * 0.4 +
|
|
1048
|
-
(100 - bottleneckPenalty) * 0.25 +
|
|
1049
|
-
(100 - concentrationPenalty) * 0.2 +
|
|
1050
|
-
(100 - highRiskPenalty) * 0.15)));
|
|
1051
|
-
const healthGrade = healthScore >= 90
|
|
1052
|
-
? "A"
|
|
1053
|
-
: healthScore >= 80
|
|
1054
|
-
? "B"
|
|
1055
|
-
: healthScore >= 65
|
|
1056
|
-
? "C"
|
|
1057
|
-
: healthScore >= 50
|
|
1058
|
-
? "D"
|
|
1059
|
-
: "F";
|
|
1060
|
-
return c.json({
|
|
1061
|
-
data: {
|
|
1062
|
-
healthScore,
|
|
1063
|
-
healthGrade,
|
|
1064
|
-
blastRadiusCoverage,
|
|
1065
|
-
untestedBlastRadius,
|
|
1066
|
-
testedBlastRadius,
|
|
1067
|
-
totalBlastRadius,
|
|
1068
|
-
bottlenecks,
|
|
1069
|
-
riskDistribution,
|
|
1070
|
-
riskConcentration,
|
|
1071
|
-
communityHealth,
|
|
1072
|
-
mostCoupledPair,
|
|
1073
|
-
insights,
|
|
1074
|
-
},
|
|
1075
|
-
_meta: {
|
|
1076
|
-
source: "local",
|
|
1077
|
-
entities_analyzed: topNodes.length,
|
|
1078
|
-
latency_ms: Math.round((performance.now() - start) * 100) / 100,
|
|
1079
|
-
},
|
|
1080
|
-
});
|
|
1081
|
-
});
|
|
1082
|
-
app.get("/durability", async (c) => {
|
|
1083
|
-
const start = performance.now();
|
|
1084
|
-
const limit = parseLimit(c.req.query("ledger_limit"), 800, 5000);
|
|
1085
|
-
const raw = deps.getRecentLedgerEntries(limit);
|
|
1086
|
-
const ledgerLike = raw.map((entry) => ({
|
|
1087
|
-
prompt: entry.plan_summary ?? "",
|
|
1088
|
-
files: extractFilesHint(entry),
|
|
1089
|
-
survived: undefined,
|
|
1090
|
-
riskLevel: undefined,
|
|
1091
|
-
}));
|
|
1092
|
-
const profiles = computePromptDurabilityProfiles(ledgerLike);
|
|
1093
|
-
return c.json({
|
|
1094
|
-
data: {
|
|
1095
|
-
profiles,
|
|
1096
|
-
overall: computeOverallDurability(profiles),
|
|
1097
|
-
most_fragile: getMostFragile(profiles, 5),
|
|
1098
|
-
},
|
|
1099
|
-
_meta: {
|
|
1100
|
-
source: "local",
|
|
1101
|
-
ledger_entries_sampled: raw.length,
|
|
1102
|
-
latency_ms: Math.round((performance.now() - start) * 100) / 100,
|
|
1103
|
-
},
|
|
1104
|
-
});
|
|
1105
|
-
});
|
|
1106
|
-
// ── Sprint 8: Health Map ──
|
|
1107
|
-
app.get("/health-map", async (c) => {
|
|
1108
|
-
if (!deps.localGraph) {
|
|
1109
|
-
return c.json({ error: "Graph not available" }, 503);
|
|
1110
|
-
}
|
|
1111
|
-
const start = performance.now();
|
|
1112
|
-
const root = c.req.query("root") || undefined;
|
|
1113
|
-
try {
|
|
1114
|
-
const { HealthMapData } = await import("../../intelligence/health-map-data.js");
|
|
1115
|
-
const healthMap = new HealthMapData(deps.localGraph, deps.factStore ?? null);
|
|
1116
|
-
const tree = await healthMap.buildTree(root);
|
|
1117
|
-
return c.json({
|
|
1118
|
-
data: tree,
|
|
1119
|
-
_meta: {
|
|
1120
|
-
source: "local",
|
|
1121
|
-
latency_ms: Math.round((performance.now() - start) * 100) / 100,
|
|
1122
|
-
},
|
|
1123
|
-
});
|
|
1124
|
-
}
|
|
1125
|
-
catch (err) {
|
|
1126
|
-
return c.json({ error: err instanceof Error ? err.message : "Health map failed" }, 500);
|
|
1127
|
-
}
|
|
1128
|
-
});
|
|
1129
|
-
app.get("/health-map/file", async (c) => {
|
|
1130
|
-
if (!deps.localGraph) {
|
|
1131
|
-
return c.json({ error: "Graph not available" }, 503);
|
|
1132
|
-
}
|
|
1133
|
-
const start = performance.now();
|
|
1134
|
-
const filePath = c.req.query("path");
|
|
1135
|
-
if (!filePath) {
|
|
1136
|
-
return c.json({ error: "Missing ?path= query parameter" }, 400);
|
|
1137
|
-
}
|
|
1138
|
-
try {
|
|
1139
|
-
const { HealthMapData } = await import("../../intelligence/health-map-data.js");
|
|
1140
|
-
const healthMap = new HealthMapData(deps.localGraph, deps.factStore ?? null);
|
|
1141
|
-
const node = await healthMap.getFileHealth(filePath);
|
|
1142
|
-
if (!node) {
|
|
1143
|
-
return c.json({ error: "No entities found for file" }, 404);
|
|
1144
|
-
}
|
|
1145
|
-
// R4: Recent activity — count ledger entries that touched this file
|
|
1146
|
-
// within the last 30 days. Conservative signal of "this file is hot".
|
|
1147
|
-
const days = 30;
|
|
1148
|
-
const sinceMs = Date.now() - days * 24 * 60 * 60 * 1000;
|
|
1149
|
-
const entries = deps.getRecentLedgerEntries(500);
|
|
1150
|
-
const matchEnds = filePath.toLowerCase();
|
|
1151
|
-
let editCount = 0;
|
|
1152
|
-
let mostRecentTs;
|
|
1153
|
-
const sessions = new Set();
|
|
1154
|
-
for (const entry of entries) {
|
|
1155
|
-
const ts = Date.parse(entry.ts);
|
|
1156
|
-
if (Number.isNaN(ts) || ts < sinceMs)
|
|
1157
|
-
continue;
|
|
1158
|
-
const files = extractFilesHint(entry);
|
|
1159
|
-
const hit = files.some((f) => f.toLowerCase().endsWith(matchEnds));
|
|
1160
|
-
if (!hit)
|
|
1161
|
-
continue;
|
|
1162
|
-
editCount += 1;
|
|
1163
|
-
sessions.add(entry.session_id);
|
|
1164
|
-
if (!mostRecentTs || entry.ts > mostRecentTs)
|
|
1165
|
-
mostRecentTs = entry.ts;
|
|
1166
|
-
}
|
|
1167
|
-
return c.json({
|
|
1168
|
-
data: {
|
|
1169
|
-
...node,
|
|
1170
|
-
recent_activity: {
|
|
1171
|
-
edit_count: editCount,
|
|
1172
|
-
session_count: sessions.size,
|
|
1173
|
-
most_recent_ts: mostRecentTs ?? null,
|
|
1174
|
-
window_days: days,
|
|
1175
|
-
},
|
|
1176
|
-
},
|
|
1177
|
-
_meta: {
|
|
1178
|
-
source: "local",
|
|
1179
|
-
latency_ms: Math.round((performance.now() - start) * 100) / 100,
|
|
1180
|
-
},
|
|
1181
|
-
});
|
|
1182
|
-
}
|
|
1183
|
-
catch (err) {
|
|
1184
|
-
return c.json({ error: err instanceof Error ? err.message : "File health failed" }, 500);
|
|
1185
|
-
}
|
|
1186
|
-
});
|
|
1187
|
-
// Sprint 9.3: Signal delivery stats endpoint
|
|
1188
|
-
app.get("/signal-stats", (c) => {
|
|
1189
|
-
if (!deps.getSignalStats) {
|
|
1190
|
-
return c.json({
|
|
1191
|
-
data: { total_delivered: 0, by_type: {}, coverage_pct: 0 },
|
|
1192
|
-
});
|
|
1193
|
-
}
|
|
1194
|
-
return c.json({ data: deps.getSignalStats() });
|
|
1195
|
-
});
|
|
1196
|
-
return app;
|
|
1197
|
-
}
|
|
1198
|
-
function extractFilesHint(entry) {
|
|
1199
|
-
const files = [];
|
|
1200
|
-
const args = entry.args_summary;
|
|
1201
|
-
if (args && typeof args === "object") {
|
|
1202
|
-
for (const v of Object.values(args)) {
|
|
1203
|
-
if (typeof v === "string" && (v.includes("/") || v.includes("\\"))) {
|
|
1204
|
-
files.push(v);
|
|
1205
|
-
}
|
|
1206
|
-
if (Array.isArray(v)) {
|
|
1207
|
-
for (const item of v) {
|
|
1208
|
-
if (typeof item === "string" &&
|
|
1209
|
-
(item.includes("/") || item.includes("\\"))) {
|
|
1210
|
-
files.push(item);
|
|
1211
|
-
}
|
|
1212
|
-
}
|
|
1213
|
-
}
|
|
1214
|
-
}
|
|
1215
|
-
}
|
|
1216
|
-
return files;
|
|
1217
|
-
}
|