@dotdotgod/cli 0.3.11 → 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.11",
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",
@@ -5,7 +5,7 @@ import { buildCompactImpactReport, buildImpactReport } from '../impact/report.mj
5
5
  import { buildVectorImpactOverlay } from '../impact/vector-overlay.mjs';
6
6
  import { canonicalizeChangedPath } from '../impact/vector-profile.mjs';
7
7
  import { startImpactGraphServer } from '../graph-view/server.mjs';
8
- import { formatCompactImpactOutput, formatYmlGraphImpactError, formatYmlImpactOutput } from '../impact/format.mjs';
8
+ import { formatCompactImpactOutput, formatImpactWarnings, formatYmlGraphImpactError, formatYmlImpactOutput } from '../impact/format.mjs';
9
9
 
10
10
  const MAX_CHANGED_FILES = 20;
11
11
 
@@ -86,7 +86,7 @@ export async function runGraph(argv) {
86
86
  if (isImpact) options.changed = [...new Set(options.changed.map((path) => canonicalizeChangedPath(options.root, path)).filter(Boolean))];
87
87
  const { status, index, metadata } = readFreshIndex(options.root);
88
88
  const overlay = isImpact ? await buildVectorImpactOverlay(options.root, index, options.changed) : undefined;
89
- 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;
90
90
  const impact = isImpact && (options.compact || options.yml) ? buildCompactImpactReport(rawImpact) : rawImpact;
91
91
  const payload = isImpact
92
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 }
@@ -95,6 +95,6 @@ export async function runGraph(argv) {
95
95
  if (options.json) console.log(JSON.stringify(payload, null, 2));
96
96
  else if (isImpact && options.yml) console.log(formatYmlImpactOutput(payload, impact));
97
97
  else if (isImpact && options.compact) console.log(formatCompactImpactOutput(payload, impact));
98
- 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'));
99
99
  else console.log(`graph communities: ${payload.communities.communities.length}/${payload.communities.total} shown, ${payload.communities.omitted} omitted (${status.status}${refreshNote} index)`);
100
100
  }
@@ -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 });
@@ -109,3 +109,7 @@ h3 { font-size: 1rem; }
109
109
  .evidence { min-height: 70vh; }
110
110
  }
111
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; }
@@ -222,6 +222,15 @@
222
222
  const omitted = payload.graph.diagnostics?.disconnectedNodesOmitted ?? 0;
223
223
  const depth = payload.graph.diagnostics?.maximumDepth ?? 0;
224
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
+ }));
225
234
  renderFilters(payload.graph.edges); renderGraph(); renderList(); renderSearch();
226
235
  if (state.selected && payload.graph.nodes.some((node) => node.id === state.selected)) renderInspection(payload.graph.nodes.find((node) => node.id === state.selected));
227
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>';
@@ -58,6 +58,11 @@
58
58
  <ul id="node-search-results" class="node-search-results"></ul>
59
59
  </section>
60
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>
61
66
  <ol id="result-list" class="result-list"></ol>
62
67
  <section id="inspection" class="inspection" aria-live="polite">
63
68
  <h3>Select an impacted file</h3>
@@ -45,7 +45,7 @@ function rootReachability(nodes, edges, seedIds) {
45
45
  }
46
46
 
47
47
  export function buildImpactGraphPayload(index, impact) {
48
- const graph = index?.graph ?? { nodes: [], edges: [] };
48
+ const graph = impact.structuralGraph ?? index?.graph ?? { nodes: [], edges: [] };
49
49
  const config = index?.memoryConfig;
50
50
  const seedIds = new Set((impact.changedFiles ?? [impact.changed]).map((path) => `file:${path}`));
51
51
  const impactItems = new Map((impact.related ?? []).map((item) => [item.id, item]));
@@ -36,7 +36,7 @@ export async function buildServedImpact(root, changedPaths) {
36
36
  const normalized = [...new Set(changedPaths.map((path) => canonicalizeChangedPath(root, path)).filter(Boolean))];
37
37
  const { status, index, metadata } = readFreshIndex(root);
38
38
  const overlay = await buildVectorImpactOverlay(root, index, normalized);
39
- const impact = buildImpactReport(index, normalized, { overlay, verboseSemantic: false, related: 40 });
39
+ const impact = buildImpactReport(index, normalized, { root, overlay, verboseSemantic: false, related: 40 });
40
40
  const graph = buildImpactGraphPayload(index, impact);
41
41
  const graphIds = new Set(graph.nodes.map((node) => node.id));
42
42
  const explorerImpact = {
@@ -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,7 +59,7 @@ 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
65
  const maxRelated = Math.max(limits.related ?? 25, 0);
@@ -99,7 +100,8 @@ 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 rankedRelated = relatedAll.filter((item) => !seedSet.has(item.id));
103
+ const excludedIds = new Set(limits.excludeRelatedIds ?? seeds);
104
+ const rankedRelated = relatedAll.filter((item) => !excludedIds.has(item.id));
103
105
  const related = selectImpactItems(rankedRelated, maxRelated, []);
104
106
  for (const item of related) {
105
107
  if (item.type === 'file') {
@@ -113,17 +115,23 @@ function buildCombinedImpactReport(index, changedPaths, limits = {}) {
113
115
  else if (item.type === 'event') addImpactItem(groups.events, item, limits.events ?? 10);
114
116
  else if (item.type === 'package_resource') addImpactItem(groups.packageResources, item, limits.packageResources ?? 10);
115
117
  }
116
- 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) };
117
119
  }
118
120
 
119
121
  export function buildImpactReport(index, changedPaths, limits = {}) {
120
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)) } };
121
127
  const aggregate = buildCombinedImpactReport(index, normalized, limits);
128
+ aggregate.warnings = prepared.warnings;
129
+ Object.defineProperty(aggregate, 'structuralGraph', { value: index?.graph, enumerable: false });
122
130
  const perSeedLimit = limits.perSeed ?? 5;
123
131
  aggregate.perSeed = normalized.map((changed) => {
124
132
  const seedId = `file:${changed}`;
125
133
  const seedOverlay = limits.overlay ? { ...limits.overlay, edges: (limits.overlay.edges ?? []).filter((edge) => edge.source === seedId) } : undefined;
126
- const report = buildCombinedImpactReport(index, [changed], { ...limits, overlay: seedOverlay, related: Math.max(limits.related ?? 25, perSeedLimit) });
134
+ const report = buildCombinedImpactReport(index, [changed], { ...limits, excludeRelatedIds: normalized.map((path) => `file:${path}`), overlay: seedOverlay, related: Math.max(limits.related ?? 25, perSeedLimit) });
127
135
  const related = report.related.slice(0, perSeedLimit);
128
136
  return { changed, related, omittedRelated: Math.max(0, report.related.length - related.length) + report.omittedRelated };
129
137
  });
@@ -158,5 +166,5 @@ export function buildCompactImpactReport(impact, limits = {}) {
158
166
  const groups = Object.fromEntries(groupNames.map((name) => [name, compactImpactGroup(impact.groups?.[name], groupLimit)]));
159
167
  const perSeed = (impact.perSeed ?? []).map((entry) => ({ changed: entry.changed, related: (entry.related ?? []).slice(0, limits.perSeed ?? 5).map(compactImpactItem), omittedRelated: entry.omittedRelated ?? 0 }));
160
168
  const top10 = (impact.related ?? []).filter((item) => !seedIds.has(item.id)).slice(0, 10);
161
- 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 } };
162
170
  }
@@ -1,3 +1,3 @@
1
- export const CACHE_VERSION = 14;
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));