@dotdotgod/cli 0.3.10 → 0.3.12

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 CHANGED
@@ -37,6 +37,7 @@ dotdotgod init .
37
37
  dotdotgod validate .
38
38
  dotdotgod query . "project documentation"
39
39
  dotdotgod graph impact . --changed <path> --compact
40
+ dotdotgod graph serve . --changed <path>
40
41
  ```
41
42
 
42
43
  ## What It Does
@@ -47,6 +48,7 @@ dotdotgod graph impact . --changed <path> --compact
47
48
  - `query` locally embeds shared Markdown with `Xenova/multilingual-e5-small`, incrementally stores vectors under `.dotdotgod/vectors/`, and returns the best-ranked chunk from each relevant Markdown file.
48
49
  - `resolve` and `expand` map explicit or high-signal prompt references to project files and can include related impact evidence.
49
50
  - `graph impact` ranks likely related specs, tests, docs, commands, and source files for one or more changed paths, with a combined ranking and per-file top five. Non-seed scores use fixed weighted PPR connection `80` plus memory policy `20`. A bounded request-local multilingual vector overlay participates in PPR when the query cache is available; vector preparation failures degrade to structural-only results.
51
+ - `graph serve` starts a local read-only explorer rooted at one or more changed files. It keeps only connected shared structural nodes, lays them out by minimum structural hop, and supports persisted instant, layer, or one-by-one reveal modes.
50
52
  - `traceability links` checks or repairs generated Markdown traceability-link sections.
51
53
 
52
54
  ## Commands
@@ -72,6 +74,8 @@ dotdotgod traceability links . --write
72
74
  dotdotgod graph impact . --changed <path>
73
75
  dotdotgod graph impact . --changed <path> --changed <another-path> --compact
74
76
  dotdotgod graph impact . --changed <path> --changed <another-path> --yml
77
+ dotdotgod graph serve . --changed <path>
78
+ dotdotgod graph serve . --changed <path> --changed <another-path> --port 4313
75
79
  dotdotgod graph communities .
76
80
  ```
77
81
 
@@ -86,6 +90,7 @@ dotdotgod graph communities .
86
90
  | Resolve `[[...]]` references from a prompt | `dotdotgod expand . "Update [[PLAN_MODE]]"` |
87
91
  | Repair generated traceability-link sections | `dotdotgod traceability links . --write` |
88
92
  | See what else to inspect after a change | `dotdotgod graph impact . --changed <path> --compact` |
93
+ | Explore the connected structural graph visually | `dotdotgod graph serve . --changed <path>` |
89
94
 
90
95
  ## Changed-File Impact
91
96
 
@@ -109,6 +114,24 @@ files:
109
114
 
110
115
  The indexed graph is built from maintained project files: Markdown links, README routes, headings, configured traceability relations, package metadata, scripts and packaged resources, dependencies, and memory-area membership. Impact analysis can add request-local vector edges for candidate discovery and PPR without mutating that indexed graph.
111
116
 
117
+ ## Local Impact Graph Explorer
118
+
119
+ ```bash
120
+ dotdotgod graph serve . --changed packages/cli/src/core.mjs
121
+ ```
122
+
123
+ `graph serve` prints a local URL and binds to `127.0.0.1` by default. Repeat `--changed` to use multiple roots. Set `--port 4313` for a fixed port or `--host <host>` when the default loopback binding is not suitable.
124
+
125
+ The explorer includes only shared indexed nodes structurally connected to a retained changed-file root. Connectivity treats stored structural edges as undirected so incoming evidence remains discoverable, while rendered arrows preserve each relation's original direction. Local-memory nodes, disconnected nodes, and request-local vector edges are excluded from the canvas and search.
126
+
127
+ Use **Node reveal** to choose:
128
+
129
+ - **Instant** — place the complete connected graph immediately.
130
+ - **By hop layer** — reveal the root first and then each minimum-hop layer.
131
+ - **One by one** — reveal nodes in deterministic breadth-first order with an automatically bounded total delay.
132
+
133
+ The browser remembers the selected mode. **Skip reveal** completes an active sequence immediately. Re-rooting and relation filters morph surviving nodes toward their new positions, while `prefers-reduced-motion` resolves every mode to immediate placement. Graph distance is layout-only and never represents impact score.
134
+
112
135
  ## Validation and Verification Boundaries
113
136
 
114
137
  | Command | Responsibility |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotdotgod/cli",
3
- "version": "0.3.10",
3
+ "version": "0.3.12",
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,7 +4,8 @@ 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 { formatCompactImpactOutput, formatYmlGraphImpactError, formatYmlImpactOutput } from '../impact/format.mjs';
7
+ import { startImpactGraphServer } from '../graph-view/server.mjs';
8
+ import { formatCompactImpactOutput, formatImpactWarnings, formatYmlGraphImpactError, formatYmlImpactOutput } from '../impact/format.mjs';
8
9
 
9
10
  const MAX_CHANGED_FILES = 20;
10
11
 
@@ -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));
@@ -70,7 +86,7 @@ export async function runGraph(argv) {
70
86
  if (isImpact) options.changed = [...new Set(options.changed.map((path) => canonicalizeChangedPath(options.root, path)).filter(Boolean))];
71
87
  const { status, index, metadata } = readFreshIndex(options.root);
72
88
  const overlay = isImpact ? await buildVectorImpactOverlay(options.root, index, options.changed) : undefined;
73
- const rawImpact = isImpact ? buildImpactReport(index, options.changed, { overlay, verboseSemantic: options.json }) : undefined;
89
+ const rawImpact = isImpact ? buildImpactReport(index, options.changed, { root: options.root, overlay, verboseSemantic: options.json }) : undefined;
74
90
  const impact = isImpact && (options.compact || options.yml) ? buildCompactImpactReport(rawImpact) : rawImpact;
75
91
  const payload = isImpact
76
92
  ? { ok: status.ok, command: 'graph impact', compact: options.compact || undefined, root: options.root, status, metadata, changed: options.changed[0], changedFiles: options.changed, related: impact.related, impact }
@@ -79,6 +95,6 @@ export async function runGraph(argv) {
79
95
  if (options.json) console.log(JSON.stringify(payload, null, 2));
80
96
  else if (isImpact && options.yml) console.log(formatYmlImpactOutput(payload, impact));
81
97
  else if (isImpact && options.compact) console.log(formatCompactImpactOutput(payload, impact));
82
- else if (isImpact) console.log(`graph impact: ${payload.related.length} related node(s), ${impact.omittedRelated ?? 0} omitted (${status.status}${refreshNote} index)`);
98
+ else if (isImpact) console.log([`graph impact: ${payload.related.length} related node(s), ${impact.omittedRelated ?? 0} omitted (${status.status}${refreshNote} index)`, ...formatImpactWarnings(impact.warnings)].join('\n'));
83
99
  else console.log(`graph communities: ${payload.communities.communities.length}/${payload.communities.total} shown, ${payload.communities.omitted} omitted (${status.status}${refreshNote} index)`);
84
100
  }
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';
@@ -4,7 +4,7 @@ import { rel } from '../common/paths.mjs';
4
4
  import { extractLinks } from '../docs/markdown.mjs';
5
5
  import { defaultMemoryConfig } from '../memory/config.mjs';
6
6
  import { addEdge, addNode } from './store.mjs';
7
- import { addMemoryAreaMembership, addPackageResource, addTraceabilityGraph, fileNodeMetadata, headingId, isReadmeIndexPath } from './metadata.mjs';
7
+ import { addMemoryAreaMembership, addPackageResource, addReferenceEvidence, addTraceabilityGraph, fileNodeMetadata, headingId, isReadmeIndexPath } from './metadata.mjs';
8
8
 
9
9
  function extractMarkdownGraph(root, file, graph, config = defaultMemoryConfig()) {
10
10
  const path = rel(root, file);
@@ -19,9 +19,14 @@ function extractMarkdownGraph(root, file, graph, config = defaultMemoryConfig())
19
19
  addEdge(graph, fileId, id, 'contains_heading', { confidence: 'EXTRACTED' });
20
20
  }
21
21
  for (const { href, line } of extractLinks(content)) {
22
- const pathPart = href.split('#')[0];
22
+ const hashIndex = href.indexOf('#');
23
+ const pathPart = hashIndex < 0 ? href : href.slice(0, hashIndex);
24
+ const anchor = hashIndex < 0 ? '' : href.slice(hashIndex + 1);
25
+ const targetPath = pathPart ? rel(root, resolve(dirname(file), pathPart)) : path;
26
+ const data = { line, confidence: 'EXTRACTED' };
27
+ addReferenceEvidence(graph, fileId, { targetPath, href, anchor, relation: 'links_to', data });
28
+ if (pathPart && isReadmeIndexPath(path)) addReferenceEvidence(graph, fileId, { targetPath, href, anchor, relation: 'routes_to', data: { line, confidence: 'CURATED_INDEX', sourceRole: 'readme-index' } });
23
29
  if (!pathPart) continue;
24
- const targetPath = rel(root, resolve(dirname(file), pathPart));
25
30
  const targetId = `file:${targetPath}`;
26
31
  addNode(graph, targetId, 'file', fileNodeMetadata(targetPath, null, config));
27
32
  addEdge(graph, fileId, targetId, 'links_to', { line, confidence: 'EXTRACTED' });
@@ -62,8 +62,17 @@ export function addPackageResource(graph, fileId, packagePath, name, target, kin
62
62
  addEdge(graph, fileId, id, 'includes_resource', { kind, confidence: 'EXTRACTED' });
63
63
  }
64
64
 
65
+ export function addReferenceEvidence(graph, sourceId, reference) {
66
+ const source = graph.nodes.find((node) => node.id === sourceId);
67
+ if (!source) return;
68
+ source.references ??= [];
69
+ source.references.push(reference);
70
+ }
71
+
65
72
  function addTraceabilityTarget(graph, sourceId, root, relation, targetPath, data = {}, config = defaultMemoryConfig()) {
66
- if (!isLocalRelativeTraceabilityPath(targetPath) || !existsSync(resolve(root, targetPath))) return;
73
+ if (!isLocalRelativeTraceabilityPath(targetPath)) return;
74
+ addReferenceEvidence(graph, sourceId, { targetPath, href: targetPath, relation, data: { confidence: 'CURATED_TRACEABILITY', ...data } });
75
+ if (!existsSync(resolve(root, targetPath))) return;
67
76
  const targetId = `file:${targetPath}`;
68
77
  addNode(graph, targetId, 'file', fileNodeMetadata(targetPath, null, config));
69
78
  addEdge(graph, sourceId, targetId, relation, { confidence: 'CURATED_TRACEABILITY', ...data });
@@ -0,0 +1,115 @@
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; } }
112
+
113
+ .reference-warnings { padding: 12px; border: 1px solid var(--border, #ddd); border-radius: 8px; }
114
+ .reference-warnings h3 { margin: 0; }
115
+ .reference-warnings ul { padding-left: 18px; overflow-wrap: anywhere; }
@@ -0,0 +1,253 @@
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
+ const warnings = payload.impact.warnings ?? { total: 0, omitted: 0, items: [] };
226
+ document.querySelector('#reference-warnings').hidden = warnings.total === 0;
227
+ document.querySelector('#reference-warning-summary').textContent = `${warnings.total} reference warning(s), ${warnings.omitted} omitted. Broken references are excluded; uncertain access is not proof of breakage.`;
228
+ const warningList = document.querySelector('#reference-warning-list');
229
+ warningList.replaceChildren(...warnings.items.map((warning) => {
230
+ const item = document.createElement('li');
231
+ item.textContent = `${warning.source}${warning.line ? `:${warning.line}` : ''} → ${warning.target} [${warning.reason}]`;
232
+ return item;
233
+ }));
234
+ renderFilters(payload.graph.edges); renderGraph(); renderList(); renderSearch();
235
+ if (state.selected && payload.graph.nodes.some((node) => node.id === state.selected)) renderInspection(payload.graph.nodes.find((node) => node.id === state.selected));
236
+ 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>';
237
+ }
238
+
239
+ elements.motionMode.value = state.motionMode;
240
+ elements.motionMode.addEventListener('change', () => {
241
+ state.motionMode = MOTION_MODES.has(elements.motionMode.value) ? elements.motionMode.value : 'layer';
242
+ localStorage.setItem('dotdotgod:graph-motion', state.motionMode);
243
+ state.firstRender = true;
244
+ state.previousPositions.clear();
245
+ renderGraph();
246
+ });
247
+ elements.skipReveal.addEventListener('click', () => finishReveal(state.positioned));
248
+ elements.form.addEventListener('submit', (event) => { event.preventDefault(); const path = elements.input.value.trim(); if (path) loadImpact([path]); });
249
+ elements.reset.addEventListener('click', () => state.renderer?.getCamera().animatedReset({ duration: matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : 250 }));
250
+ elements.search.addEventListener('input', renderSearch);
251
+ addEventListener('resize', () => state.renderer?.refresh());
252
+ fetch('/api/impact').then((response) => response.json()).then(applyPayload).catch((error) => { console.error(error); elements.status.textContent = 'Unable to load impact data.'; });
253
+ })();
@@ -0,0 +1,78 @@
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
+ <section id="reference-warnings" class="reference-warnings" aria-live="polite" hidden>
62
+ <h3>Reference warnings</h3>
63
+ <p id="reference-warning-summary"></p>
64
+ <ul id="reference-warning-list"></ul>
65
+ </section>
66
+ <ol id="result-list" class="result-list"></ol>
67
+ <section id="inspection" class="inspection" aria-live="polite">
68
+ <h3>Select an impacted file</h3>
69
+ <p>The graph and ranked list share one selection. Details will appear here.</p>
70
+ </section>
71
+ </aside>
72
+ </main>
73
+ <script src="/vendor/graphology.js"></script>
74
+ <script src="/vendor/sigma.js"></script>
75
+ <script src="/vendor/d3.js"></script>
76
+ <script src="/app.js"></script>
77
+ </body>
78
+ </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 = impact.structuralGraph ?? 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, { root, 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
+ }
@@ -1,5 +1,13 @@
1
1
  import { commandUsage } from '../cli/usage.mjs';
2
2
 
3
+ export function formatImpactWarnings(warnings) {
4
+ if (!warnings?.total) return [];
5
+ return [
6
+ `Reference warnings: ${warnings.total} (${warnings.omitted} omitted); broken references excluded from impact.`,
7
+ ...warnings.items.map((item) => `- ${item.source}${item.line ? `:${item.line}` : ''} -> ${item.target} [${item.reason}]${item.reason === 'REFERENCE_UNAVAILABLE' ? ' (access uncertain; not excluded)' : ''}`),
8
+ ];
9
+ }
10
+
3
11
  function formatCompactImpactGroup(name, group) {
4
12
  const items = group?.items ?? [];
5
13
  if (items.length === 0) return [];
@@ -16,6 +24,7 @@ function formatCompactImpactGroup(name, group) {
16
24
  export function formatCompactImpactOutput(payload, impact) {
17
25
  const refreshNote = payload.metadata.cacheRefreshed ? ', refreshed' : '';
18
26
  const lines = [`graph impact compact: ${impact.related.length} related node(s), ${impact.omittedRelated ?? 0} omitted (${payload.status.status}${refreshNote} index)`, `changed files: ${(impact.changedFiles ?? [impact.changed]).join(', ')}`];
27
+ lines.push(...formatImpactWarnings(impact.warnings));
19
28
  for (const entry of impact.perSeed ?? []) lines.push(...formatCompactImpactGroup(`top for ${entry.changed}`, { items: entry.related }));
20
29
  for (const name of ['docs', 'contracts', 'tests', 'files', 'commands', 'events', 'packageResources', 'symbols']) lines.push(...formatCompactImpactGroup(name, impact.groups[name]));
21
30
  return lines.join('\n');
@@ -84,6 +93,13 @@ export function formatYmlImpactOutput(payload, impact) {
84
93
  }
85
94
  lines.push(' groups:');
86
95
  for (const name of ['docs', 'contracts', 'tests', 'files', 'commands', 'events', 'packageResources', 'symbols']) lines.push(...formatYmlImpactGroup(name, impact.groups[name]));
96
+ lines.push(' warnings:');
97
+ lines.push(` total: ${impact.warnings?.total ?? 0}`, ` omitted: ${impact.warnings?.omitted ?? 0}`, ' items:');
98
+ if (!impact.warnings?.items?.length) lines.push(' []');
99
+ else for (const item of impact.warnings.items) {
100
+ lines.push(` - source: ${ymlScalar(item.source)}`, ` target: ${ymlScalar(item.target)}`, ` target_path: ${ymlScalar(item.targetPath)}`, ` relation: ${ymlScalar(item.relation)}`, ` reason: ${ymlScalar(item.reason)}`);
101
+ if (item.line) lines.push(` line: ${item.line}`);
102
+ }
87
103
  lines.push(' recommended_actions:');
88
104
  lines.push(' - "review_related_docs"');
89
105
  lines.push(' - "run_related_tests"');
@@ -0,0 +1,78 @@
1
+ import { readFileSync, statSync } from 'node:fs';
2
+ import { extname, resolve } from 'node:path';
3
+ import { extractAnchors } from '../docs/markdown.mjs';
4
+ import { fileNodeMetadata } from '../graph/metadata.mjs';
5
+
6
+ const WARNING_LIMIT = 20;
7
+ const edgeKey = (source, target, relation) => `${source}\0${target}\0${relation}`;
8
+
9
+ // Request-local: persisted source evidence remains intact for target restoration.
10
+ export function prepareImpactReferences(index, root, changedPaths) {
11
+ const graph = index?.graph ?? { nodes: [], edges: [] };
12
+ if (!root) return { index, warnings: { items: [], total: 0, omitted: 0 } };
13
+ const seeds = new Set(changedPaths.map((path) => `file:${path}`));
14
+ const checks = new Map();
15
+ const inspect = (path) => {
16
+ if (checks.has(path)) return checks.get(path);
17
+ let result;
18
+ try {
19
+ const absolute = resolve(root, path);
20
+ const stat = statSync(absolute);
21
+ result = { status: 'exists' };
22
+ if (stat.isFile() && extname(absolute) === '.md') result.anchors = extractAnchors(readFileSync(absolute, 'utf8'));
23
+ } catch (error) {
24
+ result = { status: ['ENOENT', 'ENOTDIR'].includes(error.code) ? 'missing' : 'unavailable' };
25
+ }
26
+ checks.set(path, result);
27
+ return result;
28
+ };
29
+ const references = graph.nodes.flatMap((node) => (node.references ?? []).map((reference) => ({ ...reference, source: node.id, sourcePath: node.path })));
30
+ const managedEdges = new Set(references.map((ref) => edgeKey(ref.source, `file:${ref.targetPath}`, ref.relation)));
31
+ const adjacency = new Map();
32
+ const connect = (source, target) => {
33
+ if (!adjacency.has(source)) adjacency.set(source, new Set());
34
+ if (!adjacency.has(target)) adjacency.set(target, new Set());
35
+ adjacency.get(source).add(target);
36
+ adjacency.get(target).add(source);
37
+ };
38
+ for (const edge of graph.edges) connect(edge.source, edge.target);
39
+ for (const ref of references) connect(ref.source, `file:${ref.targetPath}`);
40
+ const reachable = new Set(seeds);
41
+ const queue = [...seeds];
42
+ for (let i = 0; i < queue.length; i += 1) for (const id of adjacency.get(queue[i]) ?? []) {
43
+ if (!reachable.has(id)) { reachable.add(id); queue.push(id); }
44
+ }
45
+ const missing = new Set(graph.nodes.filter((node) => node.path && inspect(node.path).status === 'missing' && !seeds.has(node.id)).map((node) => node.id));
46
+ const nodes = new Map(graph.nodes.filter((node) => !missing.has(node.id)).map((node) => [node.id, node]));
47
+ const edges = graph.edges.filter((edge) => !missing.has(edge.source) && !missing.has(edge.target) && !managedEdges.has(edgeKey(edge.source, edge.target, edge.relation)));
48
+ for (const path of changedPaths) {
49
+ const id = `file:${path}`;
50
+ if (!nodes.has(id)) nodes.set(id, { id, type: 'file', ...fileNodeMetadata(path, null, index?.memoryConfig) });
51
+ }
52
+ const warnings = new Map();
53
+ for (const ref of references) {
54
+ if (/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(ref.href)) continue;
55
+ const targetId = `file:${ref.targetPath}`;
56
+ const check = inspect(ref.targetPath);
57
+ let reason = check.status === 'missing' ? 'MISSING_FILE' : check.status === 'unavailable' ? 'REFERENCE_UNAVAILABLE' : undefined;
58
+ if (!reason && ref.anchor && check.anchors) {
59
+ try { if (!check.anchors.has(decodeURIComponent(ref.anchor))) reason = 'BROKEN_ANCHOR'; }
60
+ catch { reason = 'BROKEN_ANCHOR'; }
61
+ }
62
+ if (reason) {
63
+ if (reachable.has(ref.source) && !missing.has(ref.source)) {
64
+ // README routes repeat ordinary links; show the original reference once.
65
+ const key = `${ref.source}\0${ref.href}\0${ref.data?.line ?? ''}\0${reason}`;
66
+ if (!warnings.has(key)) warnings.set(key, { source: ref.sourcePath ?? ref.source, target: ref.href, targetPath: ref.targetPath, relation: ref.relation, reason, ...(ref.data?.line ? { line: ref.data.line } : {}) });
67
+ }
68
+ // Deleted seeds retain incoming evidence so their referring docs remain reviewable.
69
+ // Uncertain access does not prove that a relation is broken.
70
+ if (reason !== 'REFERENCE_UNAVAILABLE' && !(reason === 'MISSING_FILE' && seeds.has(targetId))) continue;
71
+ }
72
+ if (missing.has(ref.source)) continue;
73
+ if (!nodes.has(targetId)) nodes.set(targetId, { id: targetId, type: 'file', ...fileNodeMetadata(ref.targetPath, null, index?.memoryConfig) });
74
+ edges.push({ source: ref.source, target: targetId, relation: ref.relation, ...ref.data });
75
+ }
76
+ const items = [...warnings.values()].sort((a, b) => a.source.localeCompare(b.source) || (a.line ?? 0) - (b.line ?? 0) || a.target.localeCompare(b.target) || a.reason.localeCompare(b.reason));
77
+ return { index: { ...index, graph: { nodes: [...nodes.values()], edges: edges.filter((edge) => nodes.has(edge.source) && nodes.has(edge.target)) } }, warnings: { items: items.slice(0, WARNING_LIMIT), total: items.length, omitted: Math.max(0, items.length - WARNING_LIMIT) } };
78
+ }
@@ -1,6 +1,7 @@
1
1
  import { DEFAULT_IMPACT_RANKING_POLICY, SEMANTIC_RELATIONS, cloneImpactRankingPolicy, defaultMemoryConfig, traceabilityRelationWeights } from '../memory/config.mjs';
2
2
  import { retrievalMetadataForPath } from '../graph/metadata.mjs';
3
3
  import { buildPersonalizedPageRank, compareImpactItems, docsArea, hasCuratedImpactReason, isLowActionabilityImpactItem, isSemanticOnlyImpactItem, isTestPath, scoreImpactItem } from './scoring.mjs';
4
+ import { prepareImpactReferences } from './references.mjs';
4
5
  import { normalizeChangedPath } from './vector-profile.mjs';
5
6
  import { MAX_VECTOR_EVIDENCE_CHUNK_ID_CHARS, MAX_VECTOR_EVIDENCE_HEADING_CHARS } from './vector-overlay.mjs';
6
7
 
@@ -58,10 +59,10 @@ function buildCombinedImpactReport(index, changedPaths, limits = {}) {
58
59
  const traceabilityWeights = traceabilityRelationWeights(config.traceability);
59
60
  policy.relationWeights = { ...policy.relationWeights, ...traceabilityWeights };
60
61
  const curatedRelations = new Set(Object.keys(traceabilityWeights));
61
- const nodeById = new Map(graph.nodes.map((node) => [node.id, node]));
62
+ const nodeById = new Map(graph.nodes.map(({ references, ...node }) => [node.id, node]));
62
63
  const seeds = changedPaths.map((path) => `file:${path}`);
63
64
  const seedSet = new Set(seeds);
64
- const maxRelated = Math.max(limits.related ?? 25, seeds.length);
65
+ const maxRelated = Math.max(limits.related ?? 25, 0);
65
66
  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
67
  const relatedIds = new Set(seeds);
67
68
  const reasons = new Map(seeds.map((seed) => [seed, new Set(['changed-file'])]));
@@ -99,7 +100,9 @@ function buildCombinedImpactReport(index, changedPaths, limits = {}) {
99
100
  const scored = scoreImpactItem({ ...node, reasons: reasonList, retrieval }, seedSet, changedPaths, policy, pprScores, config);
100
101
  return { ...node, reasons: reasonList, hasCuratedEvidence, ...(vectorEvidence.has(id) ? { vectorEvidence: vectorEvidence.get(id) } : {}), retrieval: { ...retrieval, signals: [...new Set([...(retrieval.signals ?? []), ...reasonSignals])] }, ...scored };
101
102
  }).sort(compareImpactItems(seeds));
102
- const related = selectImpactItems(relatedAll, maxRelated, seeds);
103
+ const excludedIds = new Set(limits.excludeRelatedIds ?? seeds);
104
+ const rankedRelated = relatedAll.filter((item) => !excludedIds.has(item.id));
105
+ const related = selectImpactItems(rankedRelated, maxRelated, []);
103
106
  for (const item of related) {
104
107
  if (item.type === 'file') {
105
108
  const area = docsArea(item.path, config);
@@ -112,19 +115,25 @@ function buildCombinedImpactReport(index, changedPaths, limits = {}) {
112
115
  else if (item.type === 'event') addImpactItem(groups.events, item, limits.events ?? 10);
113
116
  else if (item.type === 'package_resource') addImpactItem(groups.packageResources, item, limits.packageResources ?? 10);
114
117
  }
115
- return { changed: changedPaths[0], changedFiles: changedPaths, semantic: { status: overlay.status ?? 'disabled', ...(limits.verboseSemantic ? { diagnostics: overlay.diagnostics } : {}) }, ranking: { method: 'weighted-personalized-pagerank+memory', configSource: index?.memoryConfig?.source ?? 'default', connectionCap: policy.connectionCap, memoryCap: policy.memoryCap, pprReference: policy.ppr.reference }, related, groups, omittedRelated: Math.max(0, relatedAll.length - related.length) };
118
+ return { changed: changedPaths[0], changedFiles: changedPaths, semantic: { status: overlay.status ?? 'disabled', ...(limits.verboseSemantic ? { diagnostics: overlay.diagnostics } : {}) }, ranking: { method: 'weighted-personalized-pagerank+memory', configSource: index?.memoryConfig?.source ?? 'default', connectionCap: policy.connectionCap, memoryCap: policy.memoryCap, pprReference: policy.ppr.reference }, related, groups, omittedRelated: Math.max(0, rankedRelated.length - related.length) };
116
119
  }
117
120
 
118
121
  export function buildImpactReport(index, changedPaths, limits = {}) {
119
122
  const normalized = normalizeChangedPaths(changedPaths);
123
+ const prepared = prepareImpactReferences(index, limits.root, normalized);
124
+ index = prepared.index;
125
+ const validIds = new Set(index?.graph?.nodes.map((node) => node.id) ?? []);
126
+ if (limits.overlay) limits = { ...limits, overlay: { ...limits.overlay, edges: (limits.overlay.edges ?? []).filter((edge) => validIds.has(edge.target)) } };
120
127
  const aggregate = buildCombinedImpactReport(index, normalized, limits);
128
+ aggregate.warnings = prepared.warnings;
129
+ Object.defineProperty(aggregate, 'structuralGraph', { value: index?.graph, enumerable: false });
121
130
  const perSeedLimit = limits.perSeed ?? 5;
122
131
  aggregate.perSeed = normalized.map((changed) => {
123
132
  const seedId = `file:${changed}`;
124
133
  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 };
134
+ const report = buildCombinedImpactReport(index, [changed], { ...limits, excludeRelatedIds: normalized.map((path) => `file:${path}`), overlay: seedOverlay, related: Math.max(limits.related ?? 25, perSeedLimit) });
135
+ const related = report.related.slice(0, perSeedLimit);
136
+ return { changed, related, omittedRelated: Math.max(0, report.related.length - related.length) + report.omittedRelated };
128
137
  });
129
138
  return aggregate;
130
139
  }
@@ -148,7 +157,7 @@ function compactImpactGroup(group = { items: [], omitted: 0 }, limit = 5) {
148
157
  }
149
158
 
150
159
  export function buildCompactImpactReport(impact, limits = {}) {
151
- const relatedLimit = Math.max(limits.related ?? 10, impact.changedFiles?.length ?? 1);
160
+ const relatedLimit = limits.related ?? 10;
152
161
  const groupLimit = limits.groupItems ?? 5;
153
162
  const changedFiles = impact.changedFiles ?? [impact.changed];
154
163
  const seedIds = new Set(changedFiles.map((path) => `file:${path}`));
@@ -157,5 +166,5 @@ export function buildCompactImpactReport(impact, limits = {}) {
157
166
  const groups = Object.fromEntries(groupNames.map((name) => [name, compactImpactGroup(impact.groups?.[name], groupLimit)]));
158
167
  const perSeed = (impact.perSeed ?? []).map((entry) => ({ changed: entry.changed, related: (entry.related ?? []).slice(0, limits.perSeed ?? 5).map(compactImpactItem), omittedRelated: entry.omittedRelated ?? 0 }));
159
168
  const top10 = (impact.related ?? []).filter((item) => !seedIds.has(item.id)).slice(0, 10);
160
- return { changed: impact.changed, changedFiles, perSeed, semantic: impact.semantic, compact: true, ranking: { method: impact.ranking?.method, configSource: impact.ranking?.configSource, connectionCap: impact.ranking?.connectionCap, memoryCap: impact.ranking?.memoryCap, pprReference: impact.ranking?.pprReference }, related, groups, omittedRelated: (impact.omittedRelated ?? 0) + Math.max(0, (impact.related?.length ?? 0) - related.length), quality: { rawRelated: impact.related?.length ?? 0, compactRelated: related.length, semanticOnlyTop10: top10.filter((item) => isSemanticOnlyImpactItem(item)).length, curatedTop10: top10.filter((item) => hasCuratedImpactReason(item)).length, lowActionabilityTop10: top10.filter((item) => isLowActionabilityImpactItem(item)).length } };
169
+ return { changed: impact.changed, changedFiles, perSeed, warnings: impact.warnings, semantic: impact.semantic, compact: true, ranking: { method: impact.ranking?.method, configSource: impact.ranking?.configSource, connectionCap: impact.ranking?.connectionCap, memoryCap: impact.ranking?.memoryCap, pprReference: impact.ranking?.pprReference }, related, groups, omittedRelated: (impact.omittedRelated ?? 0) + Math.max(0, (impact.related?.length ?? 0) - related.length), quality: { rawRelated: impact.related?.length ?? 0, compactRelated: related.length, semanticOnlyTop10: top10.filter((item) => isSemanticOnlyImpactItem(item)).length, curatedTop10: top10.filter((item) => hasCuratedImpactReason(item)).length, lowActionabilityTop10: top10.filter((item) => isLowActionabilityImpactItem(item)).length } };
161
170
  }
@@ -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 = 15;
2
2
  export const CACHE_DIR = '.dotdotgod';
3
3
  export const MANIFEST_FILE = 'manifest.json';
@@ -117,8 +117,8 @@ export function runExpand(argv) {
117
117
  if (options.withImpact) refs = refs.map((item) => {
118
118
  const topPath = item.top?.path;
119
119
  if (!topPath) return item;
120
- const impact = buildCompactImpactReport(buildImpactReport(index, topPath));
121
- return { ...item, impact: { changed: topPath, related: impact.related, groups: impact.groups, omittedRelated: impact.omittedRelated, quality: impact.quality } };
120
+ const impact = buildCompactImpactReport(buildImpactReport(index, topPath, { root: options.root }));
121
+ return { ...item, impact: { changed: topPath, warnings: impact.warnings, related: impact.related, groups: impact.groups, omittedRelated: impact.omittedRelated, quality: impact.quality } };
122
122
  });
123
123
  const payload = { ok: status.ok, command: 'expand', root: options.root, prompt, status, metadata, refs, omitted: refs.reduce((sum, item) => sum + item.omitted, 0) };
124
124
  if (options.json) console.log(JSON.stringify(payload, null, 2));