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