@dotdotgod/cli 0.3.10 → 0.3.11

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotdotgod/cli",
3
- "version": "0.3.10",
3
+ "version": "0.3.11",
4
4
  "description": "CLI for dotdotgod validation, local documentation search, and graph indexing.",
5
5
  "type": "module",
6
6
  "license": "Elastic-2.0",
@@ -29,7 +29,10 @@
29
29
  "LICENSE"
30
30
  ],
31
31
  "dependencies": {
32
- "leiden-ts": "^0.1.0"
32
+ "d3": "^7.9.0",
33
+ "graphology": "^0.26.0",
34
+ "leiden-ts": "^0.1.0",
35
+ "sigma": "^3.0.3"
33
36
  },
34
37
  "homepage": "https://github.com/dotdotgod/dotdotgod-kit/tree/main/packages/cli#readme",
35
38
  "repository": {
package/src/cli/usage.mjs CHANGED
@@ -66,12 +66,18 @@ Checks or writes generated Markdown traceability link sections from canonical fe
66
66
  case 'graph':
67
67
  return `Usage:
68
68
  dotdotgod graph impact <root> --changed <path> [--changed <path> ...] [--compact|--json|--yml|--yaml]
69
+ dotdotgod graph serve <root> --changed <path> [--changed <path> ...] [--host HOST] [--port PORT]
69
70
  dotdotgod graph communities <root> [--json]`;
70
71
  case 'graph impact':
71
72
  return `Usage:
72
73
  dotdotgod graph impact <root> --changed <path> [--changed <path> ...] [--compact|--json|--yml|--yaml]
73
74
 
74
75
  Ranks nodes related to one or more changed files. <root> is the project root; repeat --changed with project-relative file paths. Results include a combined ranking and the top five related nodes for each changed file. Use --compact for a short text summary or --yml/--yaml for structured agent-facing output.`;
76
+ case 'graph serve':
77
+ return `Usage:
78
+ dotdotgod graph serve <root> --changed <path> [--changed <path> ...] [--host HOST] [--port PORT]
79
+
80
+ Starts a local-only impact graph explorer. Changed files are rendered as root nodes and are excluded from related-result lists.`;
75
81
  case 'graph communities':
76
82
  return `Usage:
77
83
  dotdotgod graph communities <root> [--json]`;
@@ -95,6 +101,7 @@ Commands:
95
101
  status Show local index status.
96
102
  traceability links Check or write generated traceability links.
97
103
  graph impact Find files related to changed files.
104
+ graph serve Open a local impact graph explorer.
98
105
  graph communities Find groups of related project-memory nodes.
99
106
 
100
107
  Options:
@@ -4,6 +4,7 @@ import { graphSummary, readFreshIndex } from '../index/cache.mjs';
4
4
  import { buildCompactImpactReport, buildImpactReport } from '../impact/report.mjs';
5
5
  import { buildVectorImpactOverlay } from '../impact/vector-overlay.mjs';
6
6
  import { canonicalizeChangedPath } from '../impact/vector-profile.mjs';
7
+ import { startImpactGraphServer } from '../graph-view/server.mjs';
7
8
  import { formatCompactImpactOutput, formatYmlGraphImpactError, formatYmlImpactOutput } from '../impact/format.mjs';
8
9
 
9
10
  const MAX_CHANGED_FILES = 20;
@@ -13,6 +14,8 @@ export function parseGraphOptions(argv) {
13
14
  const changed = [];
14
15
  let compact = false;
15
16
  let yml = false;
17
+ let host = '127.0.0.1';
18
+ let port = 0;
16
19
  for (let i = 0; i < argv.length; i += 1) {
17
20
  if (argv[i] === '--changed') {
18
21
  const next = argv[i + 1];
@@ -22,20 +25,33 @@ export function parseGraphOptions(argv) {
22
25
  }
23
26
  } else if (argv[i] === '--compact') compact = true;
24
27
  else if (argv[i] === '--yml' || argv[i] === '--yaml') yml = true;
28
+ else if (argv[i] === '--host' && argv[i + 1] && !argv[i + 1].startsWith('-')) { host = argv[i + 1]; i += 1; }
29
+ else if (argv[i] === '--port' && argv[i + 1] && !argv[i + 1].startsWith('-')) { port = Number(argv[i + 1]); i += 1; }
25
30
  else filtered.push(argv[i]);
26
31
  }
27
32
  const options = parseCommon(filtered);
28
33
  options.changed = [...new Set(changed)];
29
34
  options.compact = compact;
30
35
  options.yml = yml;
36
+ options.host = host;
37
+ options.port = Number.isInteger(port) && port >= 0 && port <= 65535 ? port : NaN;
31
38
  return options;
32
39
  }
33
40
 
34
41
  export async function runGraph(argv) {
35
42
  const sub = argv[0];
36
43
  const isImpact = sub === 'impact';
37
- if (!['impact', 'communities'].includes(sub)) usage(sub ? `Unknown graph command: ${sub}` : 'Missing graph command.', 'graph');
44
+ const isServe = sub === 'serve';
45
+ if (!['impact', 'communities', 'serve'].includes(sub)) usage(sub ? `Unknown graph command: ${sub}` : 'Missing graph command.', 'graph');
38
46
  const options = parseGraphOptions(argv.slice(1));
47
+ if (isServe && options.changed.length === 0) usage('Missing required option: --changed <path>.', 'graph serve');
48
+ if (isServe && !Number.isInteger(options.port)) usage('Invalid --port. Use an integer from 0 through 65535.', 'graph serve');
49
+ if (isServe) {
50
+ options.changed = [...new Set(options.changed.map((path) => canonicalizeChangedPath(options.root, path)).filter(Boolean))];
51
+ const running = await startImpactGraphServer({ root: options.root, changed: options.changed, host: options.host, port: options.port });
52
+ console.log(`graph serve: ${running.url}`);
53
+ return;
54
+ }
39
55
  if (isImpact && [options.json, options.compact, options.yml].filter(Boolean).length > 1) {
40
56
  const message = 'Choose only one graph impact output mode: --compact, --json, or --yml/--yaml.';
41
57
  if (options.json) console.log(JSON.stringify({ ok: false, command: 'graph impact', compact: options.compact || undefined, yml: options.yml || undefined, root: options.root, error: { code: 'OUTPUT_MODE_CONFLICT', message }, usage: commandUsage('graph impact') }, null, 2));
package/src/core.mjs CHANGED
@@ -28,6 +28,8 @@ export { addDeterministicSemanticEdges } from './graph/semantic.mjs';
28
28
  export { buildIndex, writeIndex, readIndex, getStatus, runIndex, runStatus, readFreshIndex, graphSummary } from './index/cache.mjs';
29
29
  export { runValidate } from './validate/run.mjs';
30
30
  export { buildImpactReport, buildCompactImpactReport } from './impact/report.mjs';
31
+ export { buildImpactGraphPayload } from './graph-view/payload.mjs';
32
+ export { buildServedImpact, startImpactGraphServer } from './graph-view/server.mjs';
31
33
  export { buildChangedFileProfile, MAX_VECTOR_PROFILE_CHARS, MAX_VECTOR_PROFILE_FILE_BYTES, MAX_VECTOR_PROFILE_METADATA_ITEMS } from './impact/vector-profile.mjs';
32
34
  export { buildVectorImpactOverlay, VECTOR_RELATION_WEIGHT } from './impact/vector-overlay.mjs';
33
35
  export { formatCompactImpactOutput, formatYmlGraphImpactError, formatYmlImpactOutput } from './impact/format.mjs';
@@ -0,0 +1,111 @@
1
+ :root {
2
+ color-scheme: light;
3
+ --paper: #f4f6f3;
4
+ --panel: #fbfcf8;
5
+ --ink: #182028;
6
+ --muted: #59656f;
7
+ --rule: #c8d0cd;
8
+ --cobalt: #1857c8;
9
+ --amber: #c66a12;
10
+ --green: #16734b;
11
+ --violet: #6b47a8;
12
+ --focus: #073f9b;
13
+ font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
14
+ }
15
+ * { box-sizing: border-box; }
16
+ html, body { margin: 0; min-height: 100%; background: var(--paper); color: var(--ink); }
17
+ body { min-height: 100vh; overflow: hidden; }
18
+ button, input, select { font: inherit; }
19
+ button { border: 1px solid var(--ink); background: var(--ink); color: #fff; padding: .62rem .82rem; cursor: pointer; }
20
+ button:hover { background: var(--cobalt); border-color: var(--cobalt); }
21
+ button:focus-visible, input:focus-visible, select:focus-visible, .result-button:focus-visible { outline: 3px solid color-mix(in srgb, var(--focus), transparent 25%); outline-offset: 2px; }
22
+ ::selection { background: var(--cobalt); color: white; }
23
+ .command-rail { height: 64px; display: grid; grid-template-columns: 1fr minmax(420px, 620px) auto; align-items: center; gap: 1rem; padding: 0 1rem; border-bottom: 1px solid var(--ink); background: var(--panel); }
24
+ .brand { display: flex; align-items: baseline; gap: .58rem; white-space: nowrap; }
25
+ .brand strong { letter-spacing: -.025em; }
26
+ .brand span:last-child { color: var(--muted); }
27
+ .brand-mark { width: 12px; height: 12px; background: var(--cobalt); display: inline-block; }
28
+ .reroot-form { display: grid; grid-template-columns: auto minmax(180px, 1fr) auto; align-items: center; gap: .55rem; }
29
+ .reroot-form label { font-size: .76rem; font-weight: 750; text-transform: uppercase; letter-spacing: .07em; }
30
+ .reroot-form input { min-width: 0; padding: .58rem .7rem; border: 1px solid var(--rule); background: white; color: var(--ink); }
31
+ #status { margin: 0; color: var(--muted); font-size: .82rem; white-space: nowrap; }
32
+ .workspace { height: calc(100vh - 64px); display: grid; grid-template-columns: 225px minmax(320px, 1fr) minmax(330px, 420px); }
33
+ .filters, .evidence { background: var(--panel); overflow: auto; }
34
+ .filters { border-right: 1px solid var(--rule); padding: 1.2rem 1rem; display: flex; flex-direction: column; gap: 1.3rem; }
35
+ h1, h2, h3, p { margin-top: 0; }
36
+ h1 { font-size: 1.42rem; margin-bottom: .42rem; letter-spacing: -.025em; }
37
+ h2 { font-size: 1.12rem; margin-bottom: .2rem; }
38
+ h3 { font-size: 1rem; }
39
+ .filters p, .plot-heading p, .inspection p { color: var(--muted); font-size: .86rem; line-height: 1.5; }
40
+ #relation-filters { border: 0; padding: 0; margin: 0; display: grid; gap: .35rem; }
41
+ .filter-control { display: grid; grid-template-columns: 18px 1fr auto; align-items: center; gap: .48rem; padding: .42rem 0; border-bottom: 1px solid #e2e7e4; font-size: .82rem; }
42
+ .filter-control input { accent-color: var(--cobalt); }
43
+ .filter-control code { color: var(--muted); font-size: .7rem; }
44
+ .motion-control { margin-top: .35rem; font-size: .72rem; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; }
45
+ #motion-mode { width: 100%; border: 1px solid var(--ink); border-radius: 0; background: var(--panel); color: var(--ink); padding: .52rem; }
46
+ #skip-reveal { width: 100%; padding: .48rem .62rem; background: transparent; color: var(--ink); }
47
+ .legend { display: grid; gap: .62rem; font-size: .78rem; }
48
+ .legend span { display: flex; align-items: center; gap: .55rem; }
49
+ .node-key { display: inline-block; width: 10px; height: 10px; border: 1px solid var(--ink); }
50
+ .node-key.seed { background: var(--cobalt); }
51
+ .node-key.doc { background: var(--violet); }
52
+ .node-key.test { background: var(--green); }
53
+ .node-key.code { background: #42515e; }
54
+ .node-key.contract { background: var(--amber); }
55
+ .node-key.heading { width: 7px; height: 7px; margin-inline: 1.5px; border-color: #66736d; background: #8b9690; }
56
+ .reading-note { margin-top: auto; padding-top: 1rem; border-top: 1px solid var(--rule); }
57
+ .plot { min-width: 0; position: relative; background-color: #edf1ee; background-image: linear-gradient(#dce3df 1px, transparent 1px), linear-gradient(90deg, #dce3df 1px, transparent 1px); background-size: 32px 32px; }
58
+ .plot-heading { height: 58px; display: flex; justify-content: space-between; align-items: center; padding: .65rem .85rem; background: color-mix(in srgb, var(--panel), transparent 6%); border-bottom: 1px solid var(--rule); }
59
+ .plot-heading p { margin-bottom: 0; }
60
+ .plot-heading button { background: transparent; color: var(--ink); padding: .42rem .62rem; }
61
+ #graph { width: 100%; height: calc(100% - 58px); }
62
+ .graph-fallback { position: absolute; inset: 58px 0 0; place-items: center; padding: 2rem; text-align: center; background: var(--paper); color: var(--muted); }
63
+ .graph-fallback:not([hidden]) { display: grid; }
64
+ .evidence { border-left: 1px solid var(--rule); display: grid; grid-template-rows: auto auto minmax(180px, 1fr) auto; }
65
+ .node-search { padding: 1rem; border-bottom: 1px solid var(--rule); }
66
+ .node-search label { display: block; margin-bottom: .45rem; font-size: .76rem; font-weight: 750; text-transform: uppercase; letter-spacing: .06em; }
67
+ .node-search input { width: 100%; padding: .58rem .7rem; border: 1px solid var(--rule); background: white; color: var(--ink); }
68
+ .node-search-results { list-style: none; margin: .45rem 0 0; padding: 0; max-height: 11rem; overflow: auto; }
69
+ .node-search-results:empty { display: none; }
70
+ .node-search-results button { width: 100%; padding: .5rem; border: 0; border-bottom: 1px solid #e2e7e4; background: transparent; color: var(--ink); text-align: left; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: .72rem; overflow-wrap: anywhere; }
71
+ .node-search-results button:hover { background: #e5ecfa; color: var(--ink); }
72
+ .evidence-heading { display: flex; justify-content: space-between; align-items: baseline; padding: 1rem; border-bottom: 1px solid var(--rule); }
73
+ .evidence-heading h2 { margin: 0; }
74
+ #result-count { color: var(--muted); font-variant-numeric: tabular-nums; font-size: .78rem; }
75
+ .result-list { list-style: none; margin: 0; padding: 0; overflow: auto; }
76
+ .result-button { width: 100%; display: grid; grid-template-columns: 2.2rem 1fr auto; gap: .62rem; align-items: start; padding: .75rem 1rem; border: 0; border-bottom: 1px solid #dce2df; background: transparent; color: var(--ink); text-align: left; }
77
+ .result-button:hover, .result-button[aria-current="true"] { background: #e5ecfa; color: var(--ink); }
78
+ .rank { color: var(--muted); font-variant-numeric: tabular-nums; }
79
+ .result-copy { min-width: 0; }
80
+ .result-path { display: block; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: .76rem; overflow-wrap: anywhere; }
81
+ .result-reason { display: block; color: var(--muted); font-size: .72rem; margin-top: .26rem; }
82
+ .score { color: var(--cobalt); font-weight: 800; font-variant-numeric: tabular-nums; }
83
+ .inspection { border-top: 1px solid var(--ink); padding: 1rem; max-height: 38vh; overflow: auto; background: #f7f8f4; }
84
+ .inspection dl { display: grid; grid-template-columns: 6.8rem 1fr; gap: .45rem .75rem; margin: 0; font-size: .78rem; }
85
+ .inspection dt { color: var(--muted); }
86
+ .inspection dd { margin: 0; overflow-wrap: anywhere; }
87
+ .inspection .reroot-selected { margin-top: 1rem; width: 100%; }
88
+ .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; }
89
+ @media (max-width: 1050px) {
90
+ body { overflow: auto; }
91
+ .command-rail { height: auto; min-height: 64px; grid-template-columns: 1fr auto; padding-block: .65rem; }
92
+ .reroot-form { grid-column: 1 / -1; grid-row: 2; }
93
+ .workspace { height: auto; min-height: calc(100vh - 110px); grid-template-columns: 190px minmax(300px, 1fr); }
94
+ .evidence { grid-column: 1 / -1; border-left: 0; border-top: 1px solid var(--ink); grid-template-rows: auto auto minmax(220px, 42vh) auto; }
95
+ .plot { min-height: 620px; }
96
+ }
97
+ @media (max-width: 680px) {
98
+ .command-rail { display: flex; flex-wrap: wrap; }
99
+ .brand { flex: 1; }
100
+ #status { width: 100%; }
101
+ .reroot-form { width: 100%; grid-template-columns: 1fr auto; }
102
+ .reroot-form label { grid-column: 1 / -1; }
103
+ .workspace { display: flex; flex-direction: column; }
104
+ .filters { border-right: 0; border-bottom: 1px solid var(--rule); }
105
+ #relation-filters { grid-template-columns: repeat(2, minmax(0, 1fr)); }
106
+ .legend { grid-template-columns: repeat(2, minmax(0, 1fr)); }
107
+ .reading-note { margin-top: 0; }
108
+ .plot { min-height: 60vh; }
109
+ .evidence { min-height: 70vh; }
110
+ }
111
+ @media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; transition: none !important; animation: none !important; } }
@@ -0,0 +1,244 @@
1
+ (() => {
2
+ const COLORS = { seed: '#1857c8', doc: '#6b47a8', test: '#16734b', code: '#42515e', contract: '#c66a12', heading: '#8b9690', other: '#66727b' };
3
+ const MORPH_DURATION = 380;
4
+ const MOTION_MODES = new Set(['instant', 'layer', 'sequence']);
5
+ const savedMotionMode = localStorage.getItem('dotdotgod:graph-motion');
6
+ const state = { payload: null, renderer: null, graph: null, selected: null, enabledRelations: new Set(), previousPositions: new Map(), animationFrame: null, positioned: [], firstRender: true, motionMode: MOTION_MODES.has(savedMotionMode) ? savedMotionMode : 'layer', revealToken: 0 };
7
+ const elements = {
8
+ graph: document.querySelector('#graph'), fallback: document.querySelector('#graph-fallback'), filters: document.querySelector('#relation-filters'), motionMode: document.querySelector('#motion-mode'), skipReveal: document.querySelector('#skip-reveal'), list: document.querySelector('#result-list'), inspection: document.querySelector('#inspection'), status: document.querySelector('#status'), summary: document.querySelector('#plot-summary'), count: document.querySelector('#result-count'), form: document.querySelector('#reroot-form'), input: document.querySelector('#reroot-path'), reset: document.querySelector('#reset-camera'), search: document.querySelector('#node-search-input'), searchResults: document.querySelector('#node-search-results'),
9
+ };
10
+
11
+ function nodeKind(node) {
12
+ if (node.seed) return 'seed';
13
+ if (node.type === 'contract') return 'contract';
14
+ if (node.type === 'heading' || node.id.startsWith('heading:')) return 'heading';
15
+ if (node.path?.startsWith('docs/')) return 'doc';
16
+ if (/(^|\/)(test|tests)\//.test(node.path ?? '') || /\.(test|spec)\./.test(node.path ?? '')) return 'test';
17
+ if (node.type === 'file') return 'code';
18
+ return 'other';
19
+ }
20
+
21
+ function relationLabel(value) { return value.replace(/^incoming:/, '').replaceAll('_', ' '); }
22
+ function safe(value) { const span = document.createElement('span'); span.textContent = value ?? ''; return span.innerHTML; }
23
+
24
+ function layoutNodes(nodes, edges) {
25
+ const width = Math.max(elements.graph.clientWidth, 600);
26
+ const height = Math.max(elements.graph.clientHeight, 500);
27
+ const center = { x: width / 2, y: height / 2 };
28
+ const goldenAngle = Math.PI * (3 - Math.sqrt(5));
29
+ const layerCounts = new Map();
30
+ const simulationNodes = [...nodes].sort((left, right) => left.depth - right.depth || left.id.localeCompare(right.id)).map((node) => {
31
+ const layerIndex = layerCounts.get(node.depth) ?? 0;
32
+ layerCounts.set(node.depth, layerIndex + 1);
33
+ const radius = node.depth === 0 ? Math.min(34, nodes.filter((candidate) => candidate.depth === 0).length * 8) : 66 + node.depth * 76;
34
+ const angle = layerIndex * goldenAngle + node.depth * .35;
35
+ return { ...node, targetX: center.x + Math.cos(angle) * radius, targetY: center.y + Math.sin(angle) * radius, x: center.x + Math.cos(angle) * radius, y: center.y + Math.sin(angle) * radius };
36
+ });
37
+ const byId = new Map(simulationNodes.map((node) => [node.id, node]));
38
+ const simulationEdges = edges.map((edge) => ({ ...edge, source: byId.get(edge.source), target: byId.get(edge.target) })).filter((edge) => edge.source && edge.target);
39
+ const simulation = d3.forceSimulation(simulationNodes)
40
+ .force('charge', d3.forceManyBody().strength((node) => node.type === 'heading' ? -16 : node.seed ? -220 : -48))
41
+ .force('link', d3.forceLink(simulationEdges).id((node) => node.id).distance((edge) => edge.relation === 'contains_heading' ? 18 : edge.source.seed || edge.target.seed ? 72 : 38).strength((edge) => edge.relation === 'contains_heading' ? .72 : .34))
42
+ .force('x', d3.forceX((node) => node.targetX).strength(.16))
43
+ .force('y', d3.forceY((node) => node.targetY).strength(.16))
44
+ .force('center', d3.forceCenter(width / 2, height / 2).strength(.025))
45
+ .force('collide', d3.forceCollide().radius((node) => node.type === 'heading' ? 3.5 : 6 + Math.sqrt(node.impactScore || 0)))
46
+ .stop();
47
+ const ticks = nodes.length > 1400 ? 70 : nodes.length > 700 ? 100 : nodes.length > 300 ? 130 : 180;
48
+ for (let index = 0; index < ticks; index += 1) simulation.tick();
49
+ return simulationNodes;
50
+ }
51
+
52
+ function visibleModel() {
53
+ const sourceNodes = state.payload.graph.nodes;
54
+ const sourceIds = new Set(sourceNodes.map((node) => node.id));
55
+ return { nodes: sourceNodes, edges: state.payload.graph.edges.filter((edge) => state.enabledRelations.has(edge.relation) && sourceIds.has(edge.source) && sourceIds.has(edge.target)) };
56
+ }
57
+
58
+ function revealDelay(node, index, maximumDepth, totalNodes) {
59
+ if (!state.firstRender || state.motionMode === 'instant') return 0;
60
+ if (state.motionMode === 'sequence') return index * Math.min(32, 1200 / Math.max(totalNodes, 1));
61
+ return node.depth * Math.min(70, 650 / Math.max(maximumDepth, 1));
62
+ }
63
+
64
+ function finishReveal(positioned) {
65
+ cancelAnimationFrame(state.animationFrame);
66
+ state.revealToken += 1;
67
+ for (const node of positioned) {
68
+ if (!state.graph?.hasNode(node.id)) continue;
69
+ state.graph.setNodeAttribute(node.id, 'x', node.x);
70
+ state.graph.setNodeAttribute(node.id, 'y', node.y);
71
+ state.graph.setNodeAttribute(node.id, 'size', node.renderSize ?? 4);
72
+ }
73
+ state.renderer?.refresh();
74
+ state.previousPositions = new Map(positioned.map((node) => [node.id, { x: node.x, y: node.y }]));
75
+ state.firstRender = false;
76
+ elements.skipReveal.hidden = true;
77
+ }
78
+
79
+ function animatePositions(graph, positioned) {
80
+ cancelAnimationFrame(state.animationFrame);
81
+ const token = ++state.revealToken;
82
+ const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
83
+ const mode = reduced ? 'instant' : state.motionMode;
84
+ const maximumDepth = Math.max(0, ...positioned.map((node) => node.depth));
85
+ const ordered = [...positioned].sort((left, right) => left.depth - right.depth || left.id.localeCompare(right.id));
86
+ const orderById = new Map(ordered.map((node, index) => [node.id, index]));
87
+ const starts = new Map(positioned.map((node) => {
88
+ const prior = state.previousPositions.get(node.id);
89
+ const root = positioned.find((candidate) => candidate.id === node.nearestRoot);
90
+ return [node.id, prior ?? { x: root?.x ?? node.x, y: root?.y ?? node.y }];
91
+ }));
92
+ const lastDelay = mode === 'instant' ? 0 : Math.max(0, ...positioned.map((node) => revealDelay(node, orderById.get(node.id), maximumDepth, positioned.length)));
93
+ const duration = mode === 'instant' ? 0 : MORPH_DURATION + lastDelay;
94
+ const startedAt = performance.now();
95
+ elements.skipReveal.hidden = duration === 0;
96
+ const tick = (now) => {
97
+ if (token !== state.revealToken) return;
98
+ const elapsed = now - startedAt;
99
+ for (const node of positioned) {
100
+ const delay = mode === 'instant' ? 0 : revealDelay(node, orderById.get(node.id), maximumDepth, positioned.length);
101
+ const progress = duration === 0 ? 1 : Math.max(0, Math.min(1, (elapsed - delay) / MORPH_DURATION));
102
+ const eased = 1 - Math.pow(1 - progress, 4);
103
+ const start = starts.get(node.id);
104
+ graph.setNodeAttribute(node.id, 'x', start.x + (node.x - start.x) * eased);
105
+ graph.setNodeAttribute(node.id, 'y', start.y + (node.y - start.y) * eased);
106
+ graph.setNodeAttribute(node.id, 'size', (node.renderSize ?? 4) * (progress === 0 ? 0 : .18 + .82 * eased));
107
+ }
108
+ state.renderer?.refresh();
109
+ if (elapsed < duration) state.animationFrame = requestAnimationFrame(tick);
110
+ else finishReveal(positioned);
111
+ };
112
+ state.animationFrame = requestAnimationFrame(tick);
113
+ }
114
+
115
+ function renderFilters(edges) {
116
+ const relations = [...new Set(edges.map((edge) => edge.relation))].sort();
117
+ const previous = state.enabledRelations;
118
+ state.enabledRelations = new Set(relations.filter((relation) => previous.size === 0 || previous.has(relation)));
119
+ elements.filters.replaceChildren(...relations.map((relation) => {
120
+ const label = document.createElement('label'); label.className = 'filter-control';
121
+ const input = document.createElement('input'); input.type = 'checkbox'; input.checked = state.enabledRelations.has(relation); input.value = relation;
122
+ input.addEventListener('change', () => { input.checked ? state.enabledRelations.add(relation) : state.enabledRelations.delete(relation); renderGraph(); });
123
+ const text = document.createElement('span'); text.textContent = relationLabel(relation);
124
+ const count = document.createElement('code'); count.textContent = String(edges.filter((edge) => edge.relation === relation).length);
125
+ label.append(input, text, count); return label;
126
+ }));
127
+ }
128
+
129
+ function renderGraph() {
130
+ if (state.graph) state.previousPositions = new Map(state.graph.nodes().map((id) => [id, { x: state.graph.getNodeAttribute(id, 'x'), y: state.graph.getNodeAttribute(id, 'y') }]));
131
+ cancelAnimationFrame(state.animationFrame);
132
+ state.renderer?.kill();
133
+ const model = visibleModel();
134
+ const visibleNodes = model.nodes;
135
+ const positioned = layoutNodes(visibleNodes, model.edges);
136
+ state.positioned = positioned;
137
+ try {
138
+ const graph = new graphology.MultiDirectedGraph();
139
+ for (const node of positioned) {
140
+ node.renderSize = node.seed ? 14 : node.type === 'heading' ? 2 : 4 + Math.sqrt(Math.max(node.impactScore, 1)) * .55;
141
+ const start = state.previousPositions.get(node.id) ?? positioned.find((candidate) => candidate.id === node.nearestRoot) ?? node;
142
+ graph.addNode(node.id, { label: node.label, x: start.x, y: start.y, size: node.renderSize, color: COLORS[nodeKind(node)], node });
143
+ }
144
+ model.edges.forEach((edge, index) => graph.addDirectedEdgeWithKey(`edge-${index}`, edge.source, edge.target, { color: edge.relation === 'vector_similarity' ? '#c66a12' : '#9ca8a3', size: edge.weight ? Math.min(3, 1 + edge.weight / 7) : 1, type: 'arrow', relation: edge.relation }));
145
+ state.graph = graph;
146
+ state.renderer = new Sigma(graph, elements.graph, { renderEdgeLabels: false, labelDensity: visibleNodes.length > 500 ? .03 : .1, labelRenderedSizeThreshold: visibleNodes.length > 500 ? 11 : 7, defaultEdgeType: 'arrow', zIndex: true, nodeReducer: (id, data) => {
147
+ if (!state.selected || !state.graph.hasNode(state.selected)) return data;
148
+ const neighbors = new Set(state.graph.neighbors(state.selected));
149
+ if (id === state.selected || neighbors.has(id)) return { ...data, highlighted: true, zIndex: 2 };
150
+ return { ...data, color: '#cbd1ce', label: '', zIndex: 0 };
151
+ }, edgeReducer: (key, data) => {
152
+ if (!state.selected || !state.graph.hasNode(state.selected)) return data;
153
+ const [source, target] = state.graph.extremities(key);
154
+ return source === state.selected || target === state.selected ? { ...data, color: '#1857c8', size: 2.4, zIndex: 2 } : { ...data, color: '#d8dedb', zIndex: 0 };
155
+ } });
156
+ state.renderer.on('clickNode', ({ node }) => selectNode(node, true));
157
+ animatePositions(graph, positioned);
158
+ elements.fallback.hidden = true;
159
+ } catch (error) {
160
+ const message = error instanceof Error ? error.message : String(error);
161
+ console.error('Impact graph rendering failed:', error);
162
+ elements.fallback.textContent = `Graph rendering failed: ${message}`;
163
+ elements.fallback.hidden = false;
164
+ }
165
+ }
166
+
167
+ function renderList() {
168
+ const related = state.payload.impact.related;
169
+ elements.count.textContent = `${related.length} results`;
170
+ elements.list.replaceChildren(...related.map((item, index) => {
171
+ const entry = document.createElement('li');
172
+ const button = document.createElement('button'); button.type = 'button'; button.className = 'result-button'; button.dataset.nodeId = item.id; button.setAttribute('aria-current', item.id === state.selected ? 'true' : 'false');
173
+ button.innerHTML = `<span class="rank">${String(index + 1).padStart(2, '0')}</span><span class="result-copy"><span class="result-path">${safe(item.path ?? item.title ?? item.id)}</span><span class="result-reason">${safe((item.reasons ?? []).slice(0, 2).map(relationLabel).join(' · '))}</span></span><span class="score">${item.impactScore}</span>`;
174
+ button.addEventListener('click', () => selectNode(item.id, true)); entry.append(button); return entry;
175
+ }));
176
+ }
177
+
178
+ function renderSearch() {
179
+ const query = elements.search.value.trim().toLocaleLowerCase();
180
+ if (!query) { elements.searchResults.replaceChildren(); return; }
181
+ const matches = state.payload.graph.nodes.filter((node) => `${node.label} ${node.path ?? ''} ${node.id}`.toLocaleLowerCase().includes(query)).slice(0, 30);
182
+ elements.searchResults.replaceChildren(...matches.map((node) => {
183
+ const item = document.createElement('li'); const button = document.createElement('button'); button.type = 'button'; button.textContent = node.label ?? node.id;
184
+ button.addEventListener('click', () => { selectNode(node.id); elements.searchResults.replaceChildren(); });
185
+ item.append(button); return item;
186
+ }));
187
+ }
188
+
189
+ function renderInspection(node) {
190
+ if (!node) return;
191
+ const score = node.seed ? 100 : node.impactScore;
192
+ const reasons = (node.reasons ?? []).map(relationLabel).join(', ') || 'whole graph node';
193
+ const ppr = node.scoreBreakdown?.connection?.ppr;
194
+ const memory = node.scoreBreakdown?.memory;
195
+ elements.inspection.innerHTML = `<h3>${safe(node.label ?? node.path ?? node.id)}</h3><dl><dt>Type</dt><dd>${safe(node.type)}</dd><dt>Impact score</dt><dd>${safe(String(score))}</dd><dt>Evidence</dt><dd>${safe(reasons)}</dd><dt>Component</dt><dd>${safe(String((node.component ?? 0) + 1))}</dd>${ppr !== undefined ? `<dt>PPR contribution</dt><dd>${safe(String(ppr))}</dd>` : ''}${memory ? `<dt>Memory contribution</dt><dd>${safe(String((memory.priority ?? 0) + (memory.policyAdjustments ?? 0)))}</dd>` : ''}${node.vectorEvidence?.score ? `<dt>Similarity</dt><dd>${safe(String(node.vectorEvidence.score))}</dd>` : ''}<dt>Path</dt><dd>${safe(node.path ?? node.id)}</dd></dl>${node.path && !node.seed ? '<button type="button" class="reroot-selected">Re-root from this file</button>' : ''}`;
196
+ elements.inspection.querySelector('.reroot-selected')?.addEventListener('click', () => loadImpact([node.path]));
197
+ }
198
+
199
+ function selectNode(id, focusList = false) {
200
+ state.selected = id;
201
+ state.renderer?.refresh();
202
+ for (const button of elements.list.querySelectorAll('.result-button')) button.setAttribute('aria-current', button.dataset.nodeId === id ? 'true' : 'false');
203
+ if (focusList) elements.list.querySelector(`[data-node-id="${CSS.escape(id)}"]`)?.scrollIntoView({ block: 'nearest' });
204
+ renderInspection(state.payload.graph.nodes.find((node) => node.id === id));
205
+ history.replaceState(null, '', `#${encodeURIComponent(id)}`);
206
+ }
207
+
208
+ async function loadImpact(paths) {
209
+ elements.status.textContent = 'Calculating impact…';
210
+ const query = paths.map((path) => `changed=${encodeURIComponent(path)}`).join('&');
211
+ const response = await fetch(`/api/reroot?${query}`);
212
+ const payload = await response.json();
213
+ if (!response.ok || !payload.ok) { elements.status.textContent = payload.error?.message ?? 'Impact calculation failed.'; return; }
214
+ applyPayload(payload);
215
+ }
216
+
217
+ function applyPayload(payload) {
218
+ state.payload = payload;
219
+ state.selected = payload.graph.changedFiles.length === 1 ? `file:${payload.graph.changedFiles[0]}` : null;
220
+ elements.input.value = payload.graph.changedFiles[0] ?? '';
221
+ elements.status.textContent = payload.impact.semantic.status === 'available' ? 'Shared structural graph · semantic ranking available' : 'Shared structural graph · structural ranking';
222
+ const omitted = payload.graph.diagnostics?.disconnectedNodesOmitted ?? 0;
223
+ const depth = payload.graph.diagnostics?.maximumDepth ?? 0;
224
+ elements.summary.textContent = `${payload.graph.nodes.length} connected nodes · ${payload.graph.edges.length} relations · ${depth} hops · ${omitted} disconnected omitted`;
225
+ renderFilters(payload.graph.edges); renderGraph(); renderList(); renderSearch();
226
+ if (state.selected && payload.graph.nodes.some((node) => node.id === state.selected)) renderInspection(payload.graph.nodes.find((node) => node.id === state.selected));
227
+ else if (payload.graph.nodes.length === 0) elements.inspection.innerHTML = '<h3>No shared structural nodes</h3><p>Add indexable shared files or relations to populate this graph. Local memory and headings are intentionally hidden.</p>';
228
+ }
229
+
230
+ elements.motionMode.value = state.motionMode;
231
+ elements.motionMode.addEventListener('change', () => {
232
+ state.motionMode = MOTION_MODES.has(elements.motionMode.value) ? elements.motionMode.value : 'layer';
233
+ localStorage.setItem('dotdotgod:graph-motion', state.motionMode);
234
+ state.firstRender = true;
235
+ state.previousPositions.clear();
236
+ renderGraph();
237
+ });
238
+ elements.skipReveal.addEventListener('click', () => finishReveal(state.positioned));
239
+ elements.form.addEventListener('submit', (event) => { event.preventDefault(); const path = elements.input.value.trim(); if (path) loadImpact([path]); });
240
+ elements.reset.addEventListener('click', () => state.renderer?.getCamera().animatedReset({ duration: matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : 250 }));
241
+ elements.search.addEventListener('input', renderSearch);
242
+ addEventListener('resize', () => state.renderer?.refresh());
243
+ fetch('/api/impact').then((response) => response.json()).then(applyPayload).catch((error) => { console.error(error); elements.status.textContent = 'Unable to load impact data.'; });
244
+ })();
@@ -0,0 +1,73 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>dotdotgod structural graph</title>
7
+ <link rel="stylesheet" href="/app.css">
8
+ </head>
9
+ <body>
10
+ <header class="command-rail">
11
+ <div class="brand"><span class="brand-mark" aria-hidden="true"></span><strong>dotdotgod</strong><span>structural graph</span></div>
12
+ <form id="reroot-form" class="reroot-form">
13
+ <label for="reroot-path">Re-root graph</label>
14
+ <input id="reroot-path" name="changed" autocomplete="off" placeholder="packages/cli/src/core.mjs">
15
+ <button type="submit">Inspect file</button>
16
+ </form>
17
+ <p id="status" role="status">Loading impact data…</p>
18
+ </header>
19
+
20
+ <main class="workspace">
21
+ <aside class="filters" aria-labelledby="filters-title">
22
+ <div>
23
+ <h1 id="filters-title">Relation signals</h1>
24
+ <p>Filter what is drawn without changing the impact ranking.</p>
25
+ </div>
26
+ <fieldset id="relation-filters"><legend class="sr-only">Visible relation types</legend></fieldset>
27
+ <label class="motion-control" for="motion-mode">Node reveal</label>
28
+ <select id="motion-mode">
29
+ <option value="instant">Instant</option>
30
+ <option value="layer">By hop layer</option>
31
+ <option value="sequence">One by one</option>
32
+ </select>
33
+ <button id="skip-reveal" type="button" hidden>Skip reveal</button>
34
+ <div class="legend" aria-label="Node legend">
35
+ <span><i class="node-key seed"></i>Changed root</span>
36
+ <span><i class="node-key doc"></i>Documentation</span>
37
+ <span><i class="node-key test"></i>Verification</span>
38
+ <span><i class="node-key code"></i>Source</span>
39
+ <span><i class="node-key contract"></i>Contract</span>
40
+ <span><i class="node-key heading"></i>Heading</span>
41
+ </div>
42
+ <p class="reading-note">The graph grows outward from changed-file roots by structural hop. Position is layout only, never rank.</p>
43
+ </aside>
44
+
45
+ <section class="plot" aria-labelledby="plot-title">
46
+ <div class="plot-heading">
47
+ <div><h2 id="plot-title">Impact field</h2><p id="plot-summary"></p></div>
48
+ <button id="reset-camera" type="button">Reset view</button>
49
+ </div>
50
+ <div id="graph" aria-hidden="true"></div>
51
+ <div id="graph-fallback" class="graph-fallback" hidden>WebGL is unavailable. Use the synchronized ranked list.</div>
52
+ </section>
53
+
54
+ <aside class="evidence" aria-labelledby="evidence-title">
55
+ <section class="node-search" aria-labelledby="node-search-title">
56
+ <label id="node-search-title" for="node-search-input">Search shared graph nodes</label>
57
+ <input id="node-search-input" type="search" autocomplete="off" placeholder="Path, title, or node ID" aria-controls="node-search-results">
58
+ <ul id="node-search-results" class="node-search-results"></ul>
59
+ </section>
60
+ <div class="evidence-heading"><h2 id="evidence-title">Ranked evidence</h2><span id="result-count"></span></div>
61
+ <ol id="result-list" class="result-list"></ol>
62
+ <section id="inspection" class="inspection" aria-live="polite">
63
+ <h3>Select an impacted file</h3>
64
+ <p>The graph and ranked list share one selection. Details will appear here.</p>
65
+ </section>
66
+ </aside>
67
+ </main>
68
+ <script src="/vendor/graphology.js"></script>
69
+ <script src="/vendor/sigma.js"></script>
70
+ <script src="/vendor/d3.js"></script>
71
+ <script src="/app.js"></script>
72
+ </body>
73
+ </html>
@@ -0,0 +1,114 @@
1
+ import { resolveMemoryArea } from '../memory/config.mjs';
2
+
3
+ function edgeKey(edge) {
4
+ return `${edge.source}\0${edge.target}\0${edge.relation}`;
5
+ }
6
+
7
+ function labelForNode(node) {
8
+ if (node.type === 'contract') return node.title ?? node.contractId ?? node.id;
9
+ return node.path ?? node.command ?? node.name ?? node.target ?? node.id;
10
+ }
11
+
12
+ function adjacencyFor(nodes, edges) {
13
+ const adjacency = new Map(nodes.map((node) => [node.id, new Set()]));
14
+ for (const edge of edges) {
15
+ adjacency.get(edge.source)?.add(edge.target);
16
+ adjacency.get(edge.target)?.add(edge.source);
17
+ }
18
+ return adjacency;
19
+ }
20
+
21
+ function rootReachability(nodes, edges, seedIds) {
22
+ const adjacency = adjacencyFor(nodes, edges);
23
+ const reachable = new Set();
24
+ const depthByNode = new Map();
25
+ const nearestRootByNode = new Map();
26
+ const queue = [];
27
+ for (const node of nodes) {
28
+ if (!seedIds.has(node.id)) continue;
29
+ reachable.add(node.id);
30
+ depthByNode.set(node.id, 0);
31
+ nearestRootByNode.set(node.id, node.id);
32
+ queue.push(node.id);
33
+ }
34
+ for (let cursor = 0; cursor < queue.length; cursor += 1) {
35
+ const current = queue[cursor];
36
+ for (const neighbor of adjacency.get(current) ?? []) {
37
+ if (reachable.has(neighbor)) continue;
38
+ reachable.add(neighbor);
39
+ depthByNode.set(neighbor, (depthByNode.get(current) ?? 0) + 1);
40
+ nearestRootByNode.set(neighbor, nearestRootByNode.get(current));
41
+ queue.push(neighbor);
42
+ }
43
+ }
44
+ return { reachable, depthByNode, nearestRootByNode };
45
+ }
46
+
47
+ export function buildImpactGraphPayload(index, impact) {
48
+ const graph = index?.graph ?? { nodes: [], edges: [] };
49
+ const config = index?.memoryConfig;
50
+ const seedIds = new Set((impact.changedFiles ?? [impact.changed]).map((path) => `file:${path}`));
51
+ const impactItems = new Map((impact.related ?? []).map((item) => [item.id, item]));
52
+ const isAllowedNode = (node) => {
53
+ if (!node) return false;
54
+ if (node.path && resolveMemoryArea(node.path, config)?.scope === 'local') return false;
55
+ return true;
56
+ };
57
+ const sourceNodes = (graph.nodes ?? []).filter(isAllowedNode);
58
+ const indexedNodes = new Map(sourceNodes.map((node) => [node.id, node]));
59
+
60
+ const seenEdges = new Set();
61
+ const edges = [];
62
+ for (const edge of graph.edges ?? []) {
63
+ if (!indexedNodes.has(edge.source) || !indexedNodes.has(edge.target)) continue;
64
+ const key = edgeKey(edge);
65
+ if (seenEdges.has(key)) continue;
66
+ seenEdges.add(key);
67
+ edges.push({ source: edge.source, target: edge.target, relation: edge.relation, confidence: edge.confidence, weight: edge.weight ?? edge.relationWeight, score: edge.score, traceabilityKey: edge.traceabilityKey });
68
+ }
69
+ const { reachable, depthByNode, nearestRootByNode } = rootReachability(sourceNodes, edges, seedIds);
70
+ const retainedNodes = sourceNodes.filter((node) => reachable.has(node.id));
71
+ const retainedEdges = edges.filter((edge) => reachable.has(edge.source) && reachable.has(edge.target));
72
+ const nodes = retainedNodes.map((source) => {
73
+ const item = impactItems.get(source.id);
74
+ const seed = seedIds.has(source.id);
75
+ return {
76
+ id: source.id,
77
+ type: source.type ?? item?.type ?? 'file',
78
+ path: source.path ?? item?.path,
79
+ label: labelForNode({ ...source, ...item }),
80
+ seed,
81
+ ranked: Boolean(item),
82
+ depth: depthByNode.get(source.id) ?? 0,
83
+ nearestRoot: nearestRootByNode.get(source.id),
84
+ component: 0,
85
+ seededComponent: true,
86
+ impactScore: seed ? 100 : item?.impactScore ?? 0,
87
+ reasons: seed ? ['changed-file'] : item?.reasons ?? [],
88
+ scoreBreakdown: seed ? { seed: 100 } : item?.scoreBreakdown,
89
+ retrieval: item?.retrieval ?? source.retrieval,
90
+ contractId: item?.contractId ?? source.contractId,
91
+ title: item?.title ?? source.title,
92
+ sections: item?.sections ?? source.sections,
93
+ vectorEvidence: item?.vectorEvidence,
94
+ };
95
+ });
96
+
97
+ return {
98
+ changed: impact.changed,
99
+ changedFiles: impact.changedFiles ?? [impact.changed],
100
+ ranking: impact.ranking,
101
+ semantic: impact.semantic,
102
+ complete: true,
103
+ nodes,
104
+ edges: retainedEdges,
105
+ components: retainedNodes.length > 0 ? [{ id: 0, size: retainedNodes.length, seeded: true }] : [],
106
+ diagnostics: {
107
+ rootNodes: retainedNodes.filter((node) => seedIds.has(node.id)).length,
108
+ connectedNodes: retainedNodes.length,
109
+ disconnectedNodesOmitted: sourceNodes.length - retainedNodes.length,
110
+ maximumDepth: Math.max(0, ...depthByNode.values()),
111
+ },
112
+ omittedRelated: impact.omittedRelated ?? 0,
113
+ };
114
+ }
@@ -0,0 +1,79 @@
1
+ import { createServer } from 'node:http';
2
+ import { readFileSync } from 'node:fs';
3
+ import { dirname, extname, join } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { buildVectorImpactOverlay } from '../impact/vector-overlay.mjs';
6
+ import { buildImpactReport } from '../impact/report.mjs';
7
+ import { canonicalizeChangedPath } from '../impact/vector-profile.mjs';
8
+ import { readFreshIndex } from '../index/cache.mjs';
9
+ import { buildImpactGraphPayload } from './payload.mjs';
10
+
11
+ const ASSET_ROOT = join(dirname(fileURLToPath(import.meta.url)), 'assets');
12
+ const CONTENT_TYPES = { '.css': 'text/css; charset=utf-8', '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.map': 'application/json; charset=utf-8' };
13
+ const VENDOR_FILES = {
14
+ '/vendor/sigma.js': new URL('../../node_modules/sigma/dist/sigma.min.js', import.meta.url),
15
+ '/vendor/graphology.js': new URL('../../node_modules/graphology/dist/graphology.umd.min.js', import.meta.url),
16
+ '/vendor/d3.js': new URL('../../node_modules/d3/dist/d3.min.js', import.meta.url),
17
+ };
18
+
19
+ function json(response, status, value) {
20
+ response.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
21
+ response.end(JSON.stringify(value));
22
+ }
23
+
24
+ function asset(response, file) {
25
+ try {
26
+ const body = readFileSync(file);
27
+ response.writeHead(200, { 'content-type': CONTENT_TYPES[extname(file)] ?? 'application/octet-stream', 'cache-control': 'no-store' });
28
+ response.end(body);
29
+ } catch {
30
+ response.writeHead(404);
31
+ response.end('Not found');
32
+ }
33
+ }
34
+
35
+ export async function buildServedImpact(root, changedPaths) {
36
+ const normalized = [...new Set(changedPaths.map((path) => canonicalizeChangedPath(root, path)).filter(Boolean))];
37
+ const { status, index, metadata } = readFreshIndex(root);
38
+ const overlay = await buildVectorImpactOverlay(root, index, normalized);
39
+ const impact = buildImpactReport(index, normalized, { overlay, verboseSemantic: false, related: 40 });
40
+ const graph = buildImpactGraphPayload(index, impact);
41
+ const graphIds = new Set(graph.nodes.map((node) => node.id));
42
+ const explorerImpact = {
43
+ ...impact,
44
+ related: impact.related.filter((item) => graphIds.has(item.id)),
45
+ };
46
+ return { ok: status.ok, status, metadata, impact: explorerImpact, graph };
47
+ }
48
+
49
+ export async function startImpactGraphServer({ root, changed, host = '127.0.0.1', port = 0 }) {
50
+ let payload = await buildServedImpact(root, changed);
51
+ const server = createServer(async (request, response) => {
52
+ const url = new URL(request.url ?? '/', `http://${request.headers.host ?? host}`);
53
+ if (url.pathname === '/api/impact') return json(response, 200, payload);
54
+ if (url.pathname === '/api/reroot') {
55
+ const paths = url.searchParams.getAll('changed');
56
+ if (paths.length === 0) return json(response, 400, { ok: false, error: { code: 'MISSING_CHANGED', message: 'At least one changed path is required.' } });
57
+ try {
58
+ payload = await buildServedImpact(root, paths);
59
+ return json(response, 200, payload);
60
+ } catch (error) {
61
+ return json(response, 500, { ok: false, error: { code: 'GRAPH_SERVE_FAILED', message: error instanceof Error ? error.message : String(error) } });
62
+ }
63
+ }
64
+ if (VENDOR_FILES[url.pathname]) return asset(response, fileURLToPath(VENDOR_FILES[url.pathname]));
65
+ const relative = url.pathname === '/' ? 'index.html' : url.pathname.slice(1);
66
+ if (!['index.html', 'app.css', 'app.js'].includes(relative)) {
67
+ response.writeHead(404);
68
+ return response.end('Not found');
69
+ }
70
+ return asset(response, join(ASSET_ROOT, relative));
71
+ });
72
+ await new Promise((resolve, reject) => {
73
+ server.once('error', reject);
74
+ server.listen(port, host, resolve);
75
+ });
76
+ const address = server.address();
77
+ const actualPort = typeof address === 'object' && address ? address.port : port;
78
+ return { server, url: `http://${host}:${actualPort}`, payload };
79
+ }
@@ -61,7 +61,7 @@ function buildCombinedImpactReport(index, changedPaths, limits = {}) {
61
61
  const nodeById = new Map(graph.nodes.map((node) => [node.id, node]));
62
62
  const seeds = changedPaths.map((path) => `file:${path}`);
63
63
  const seedSet = new Set(seeds);
64
- const maxRelated = Math.max(limits.related ?? 25, seeds.length);
64
+ const maxRelated = Math.max(limits.related ?? 25, 0);
65
65
  const groups = { files: { items: [], omitted: 0 }, docs: { items: [], omitted: 0 }, contracts: { items: [], omitted: 0 }, tests: { items: [], omitted: 0 }, commands: { items: [], omitted: 0 }, events: { items: [], omitted: 0 }, packageResources: { items: [], omitted: 0 }, symbols: { items: [], omitted: 0 } };
66
66
  const relatedIds = new Set(seeds);
67
67
  const reasons = new Map(seeds.map((seed) => [seed, new Set(['changed-file'])]));
@@ -99,7 +99,8 @@ function buildCombinedImpactReport(index, changedPaths, limits = {}) {
99
99
  const scored = scoreImpactItem({ ...node, reasons: reasonList, retrieval }, seedSet, changedPaths, policy, pprScores, config);
100
100
  return { ...node, reasons: reasonList, hasCuratedEvidence, ...(vectorEvidence.has(id) ? { vectorEvidence: vectorEvidence.get(id) } : {}), retrieval: { ...retrieval, signals: [...new Set([...(retrieval.signals ?? []), ...reasonSignals])] }, ...scored };
101
101
  }).sort(compareImpactItems(seeds));
102
- const related = selectImpactItems(relatedAll, maxRelated, seeds);
102
+ const rankedRelated = relatedAll.filter((item) => !seedSet.has(item.id));
103
+ const related = selectImpactItems(rankedRelated, maxRelated, []);
103
104
  for (const item of related) {
104
105
  if (item.type === 'file') {
105
106
  const area = docsArea(item.path, config);
@@ -122,9 +123,9 @@ export function buildImpactReport(index, changedPaths, limits = {}) {
122
123
  aggregate.perSeed = normalized.map((changed) => {
123
124
  const seedId = `file:${changed}`;
124
125
  const seedOverlay = limits.overlay ? { ...limits.overlay, edges: (limits.overlay.edges ?? []).filter((edge) => edge.source === seedId) } : undefined;
125
- const report = buildCombinedImpactReport(index, [changed], { ...limits, overlay: seedOverlay, related: Math.max(limits.related ?? 25, perSeedLimit + 1) });
126
- const related = report.related.filter((item) => item.id !== `file:${changed}`).slice(0, perSeedLimit);
127
- return { changed, related, omittedRelated: Math.max(0, report.related.length - 1 - related.length) + report.omittedRelated };
126
+ const report = buildCombinedImpactReport(index, [changed], { ...limits, overlay: seedOverlay, related: Math.max(limits.related ?? 25, perSeedLimit) });
127
+ const related = report.related.slice(0, perSeedLimit);
128
+ return { changed, related, omittedRelated: Math.max(0, report.related.length - related.length) + report.omittedRelated };
128
129
  });
129
130
  return aggregate;
130
131
  }
@@ -148,7 +149,7 @@ function compactImpactGroup(group = { items: [], omitted: 0 }, limit = 5) {
148
149
  }
149
150
 
150
151
  export function buildCompactImpactReport(impact, limits = {}) {
151
- const relatedLimit = Math.max(limits.related ?? 10, impact.changedFiles?.length ?? 1);
152
+ const relatedLimit = limits.related ?? 10;
152
153
  const groupLimit = limits.groupItems ?? 5;
153
154
  const changedFiles = impact.changedFiles ?? [impact.changed];
154
155
  const seedIds = new Set(changedFiles.map((path) => `file:${path}`));
@@ -23,7 +23,7 @@ function mergeIncrementalGraph(previousGraph, changedGraph, changedPaths) {
23
23
  if (!previousGraph) return changedGraph;
24
24
  const changedSet = new Set(changedPaths);
25
25
  const changedNodeIds = new Set(previousGraph.nodes.filter((node) => nodeOwnedByPath(node, changedSet)).map((node) => node.id));
26
- for (const node of changedGraph.nodes) changedNodeIds.add(node.id);
26
+ for (const node of changedGraph.nodes) if (nodeOwnedByPath(node, changedSet)) changedNodeIds.add(node.id);
27
27
  const graph = { nodes: [], edges: [] };
28
28
  for (const node of previousGraph.nodes) if (!changedNodeIds.has(node.id) && !nodeOwnedByPath(node, changedSet)) addNode(graph, node.id, node.type, Object.fromEntries(Object.entries(node).filter(([key]) => key !== 'id' && key !== 'type')));
29
29
  for (const edge of previousGraph.edges) if (!changedNodeIds.has(edge.source) && !changedNodeIds.has(edge.target)) addEdge(graph, edge.source, edge.target, edge.relation, Object.fromEntries(Object.entries(edge).filter(([key]) => key !== 'source' && key !== 'target' && key !== 'relation')));
@@ -1,3 +1,3 @@
1
- export const CACHE_VERSION = 13;
1
+ export const CACHE_VERSION = 14;
2
2
  export const CACHE_DIR = '.dotdotgod';
3
3
  export const MANIFEST_FILE = 'manifest.json';