@dotdotgod/cli 0.3.11 → 0.4.0
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 +23 -0
- package/package.json +1 -1
- package/src/commands/graph.mjs +3 -3
- package/src/graph/extract.mjs +8 -3
- package/src/graph/metadata.mjs +10 -1
- package/src/graph-view/assets/app.css +4 -0
- package/src/graph-view/assets/app.js +9 -0
- package/src/graph-view/assets/index.html +5 -0
- package/src/graph-view/payload.mjs +1 -1
- package/src/graph-view/server.mjs +1 -1
- package/src/impact/format.mjs +16 -0
- package/src/impact/references.mjs +78 -0
- package/src/impact/report.mjs +13 -5
- package/src/index/constants.mjs +1 -1
- package/src/reference/resolve.mjs +2 -2
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
package/src/commands/graph.mjs
CHANGED
|
@@ -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
|
}
|
package/src/graph/extract.mjs
CHANGED
|
@@ -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
|
|
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' });
|
package/src/graph/metadata.mjs
CHANGED
|
@@ -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)
|
|
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 = {
|
package/src/impact/format.mjs
CHANGED
|
@@ -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
|
+
}
|
package/src/impact/report.mjs
CHANGED
|
@@ -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
|
|
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,
|
|
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
|
}
|
package/src/index/constants.mjs
CHANGED
|
@@ -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));
|