@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 +23 -0
- package/package.json +5 -2
- package/src/cli/usage.mjs +7 -0
- package/src/commands/graph.mjs +20 -4
- package/src/core.mjs +2 -0
- package/src/graph/extract.mjs +8 -3
- package/src/graph/metadata.mjs +10 -1
- package/src/graph-view/assets/app.css +115 -0
- package/src/graph-view/assets/app.js +253 -0
- package/src/graph-view/assets/index.html +78 -0
- package/src/graph-view/payload.mjs +114 -0
- package/src/graph-view/server.mjs +79 -0
- package/src/impact/format.mjs +16 -0
- package/src/impact/references.mjs +78 -0
- package/src/impact/report.mjs +18 -9
- package/src/index/cache.mjs +1 -1
- 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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotdotgod/cli",
|
|
3
|
-
"version": "0.3.
|
|
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
|
-
"
|
|
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:
|
package/src/commands/graph.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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';
|
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 });
|
|
@@ -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
|
+
}
|
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,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,
|
|
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
|
|
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,
|
|
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
|
|
126
|
-
const related = report.related.
|
|
127
|
-
return { changed, related, omittedRelated: Math.max(0, report.related.length -
|
|
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 =
|
|
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
|
}
|
package/src/index/cache.mjs
CHANGED
|
@@ -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')));
|
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));
|