@sleetdrop/dsh-plugin-topology 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/NEXT-STEPS.md +32 -0
- package/README.md +120 -0
- package/cordis.patch.yml +17 -0
- package/lib/client/TopologyGraphView.d.ts +27 -0
- package/lib/client/TopologyGraphView.js +246 -0
- package/lib/client/TopologyView.d.ts +26 -0
- package/lib/client/TopologyView.js +96 -0
- package/lib/client/icons.d.ts +34 -0
- package/lib/client/icons.js +37 -0
- package/lib/client/index.d.ts +29 -0
- package/lib/client/index.js +76 -0
- package/lib/client/locales.d.ts +4 -0
- package/lib/client/locales.js +55 -0
- package/lib/client/panel.d.ts +19 -0
- package/lib/client/panel.js +41 -0
- package/lib/client/remote-client.d.ts +12 -0
- package/lib/client/remote-client.js +148 -0
- package/lib/client/stores.d.ts +25 -0
- package/lib/client/stores.js +15 -0
- package/lib/client/useDismissOnOutsidePointer.d.ts +9 -0
- package/lib/client/useDismissOnOutsidePointer.js +23 -0
- package/lib/client.js +5566 -0
- package/lib/client.js.map +1 -0
- package/lib/index.d.ts +54 -0
- package/lib/index.js +198 -0
- package/lib/invariant.d.ts +15 -0
- package/lib/invariant.js +17 -0
- package/lib/metrics.d.ts +26 -0
- package/lib/metrics.js +331 -0
- package/lib/render.d.ts +72 -0
- package/lib/render.js +262 -0
- package/lib/tool.d.ts +14 -0
- package/lib/tool.js +57 -0
- package/lib/typert.d.ts +139 -0
- package/lib/typert.js +142 -0
- package/lib/types.d.ts +126 -0
- package/lib/types.js +5 -0
- package/overlay.example.yml +19 -0
- package/package.json +114 -0
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only snapshot of the runtime plugin service dependency graph.
|
|
3
|
+
*
|
|
4
|
+
* `snapshot()` reads the live Cordis registry and reflect store and projects
|
|
5
|
+
* them into a JSON-safe bipartite graph without mutating the runtime. The
|
|
6
|
+
* graph carries the fiber tree (plugin parent pointers) alongside the
|
|
7
|
+
* inject/provide service edges, so the assembly hierarchy and the dependency
|
|
8
|
+
* topology are both visible in one capture. Isolation scopes are preserved as
|
|
9
|
+
* per-service scope discriminators, and a declared inject with no active
|
|
10
|
+
* provider is reported as unresolved instead of a dangling edge.
|
|
11
|
+
*
|
|
12
|
+
* The deep reads here reach into Cordis internals that are not part of its
|
|
13
|
+
* stable public API (`root.registry`, `root.reflect.store`, fiber fields).
|
|
14
|
+
* They are gated on a pinned `@deepseek-ai/cordis` peer range (see README
|
|
15
|
+
* "Compatibility"); a Cordis that reshapes these surfaces breaks this
|
|
16
|
+
* projection instead of degrading silently.
|
|
17
|
+
*
|
|
18
|
+
* @module @sleetdrop/dsh-plugin-topology
|
|
19
|
+
*/
|
|
20
|
+
import { Context } from '@deepseek-ai/cordis';
|
|
21
|
+
import { TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
|
|
22
|
+
import type { Rankdir, RenderFormat, TopologyAnalysis, TopologyGraph } from './types.ts';
|
|
23
|
+
export type * from './types.ts';
|
|
24
|
+
export { analyzeGraph, collapse } from './metrics.ts';
|
|
25
|
+
export { composeGraphvizSvgs, isolatedNodes, nodeLink, renderCompleteDot, renderDot, renderIsolatedDot, renderJson } from './render.ts';
|
|
26
|
+
export type { NodeLinkEdge, NodeLinkGraph, NodeLinkNode } from './render.ts';
|
|
27
|
+
declare module '@deepseek-ai/cordis' {
|
|
28
|
+
interface Context {
|
|
29
|
+
pluginTopology: PluginTopologyService;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** Read-only projection of the runtime service dependency graph. */
|
|
33
|
+
export declare class PluginTopologyService extends TypertRemoteService {
|
|
34
|
+
private graphvizPromise;
|
|
35
|
+
constructor(ctx: Context);
|
|
36
|
+
/**
|
|
37
|
+
* Capture the current runtime topology without mutating it.
|
|
38
|
+
* @returns a JSON-safe bipartite graph of plugins and services.
|
|
39
|
+
*/
|
|
40
|
+
snapshot(): TopologyGraph;
|
|
41
|
+
/**
|
|
42
|
+
* Capture the runtime topology and derive its graph-theory analysis.
|
|
43
|
+
* @returns the bipartite snapshot, its collapsed projection, and metrics.
|
|
44
|
+
*/
|
|
45
|
+
analyze(): TopologyAnalysis;
|
|
46
|
+
/**
|
|
47
|
+
* Export the current topology in one serialized format.
|
|
48
|
+
* @param format - `json` (node-link document), `dot` (Graphviz source), or `svg` (Graphviz-rendered SVG).
|
|
49
|
+
* @param rankdir - Graphviz layout direction for `dot`/`svg`; ignored by `json`.
|
|
50
|
+
* @returns the serialized document for the format.
|
|
51
|
+
*/
|
|
52
|
+
render(format: RenderFormat, rankdir: Rankdir): Promise<string>;
|
|
53
|
+
}
|
|
54
|
+
export default PluginTopologyService;
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only snapshot of the runtime plugin service dependency graph.
|
|
3
|
+
*
|
|
4
|
+
* `snapshot()` reads the live Cordis registry and reflect store and projects
|
|
5
|
+
* them into a JSON-safe bipartite graph without mutating the runtime. The
|
|
6
|
+
* graph carries the fiber tree (plugin parent pointers) alongside the
|
|
7
|
+
* inject/provide service edges, so the assembly hierarchy and the dependency
|
|
8
|
+
* topology are both visible in one capture. Isolation scopes are preserved as
|
|
9
|
+
* per-service scope discriminators, and a declared inject with no active
|
|
10
|
+
* provider is reported as unresolved instead of a dangling edge.
|
|
11
|
+
*
|
|
12
|
+
* The deep reads here reach into Cordis internals that are not part of its
|
|
13
|
+
* stable public API (`root.registry`, `root.reflect.store`, fiber fields).
|
|
14
|
+
* They are gated on a pinned `@deepseek-ai/cordis` peer range (see README
|
|
15
|
+
* "Compatibility"); a Cordis that reshapes these surfaces breaks this
|
|
16
|
+
* projection instead of degrading silently.
|
|
17
|
+
*
|
|
18
|
+
* @module @sleetdrop/dsh-plugin-topology
|
|
19
|
+
*/
|
|
20
|
+
var __runInitializers = (this && this.__runInitializers) || function (thisArg, initializers, value) {
|
|
21
|
+
var useValue = arguments.length > 2;
|
|
22
|
+
for (var i = 0; i < initializers.length; i++) {
|
|
23
|
+
value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg);
|
|
24
|
+
}
|
|
25
|
+
return useValue ? value : void 0;
|
|
26
|
+
};
|
|
27
|
+
var __esDecorate = (this && this.__esDecorate) || function (ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) {
|
|
28
|
+
function accept(f) { if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected"); return f; }
|
|
29
|
+
var kind = contextIn.kind, key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value";
|
|
30
|
+
var target = !descriptorIn && ctor ? contextIn["static"] ? ctor : ctor.prototype : null;
|
|
31
|
+
var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {});
|
|
32
|
+
var _, done = false;
|
|
33
|
+
for (var i = decorators.length - 1; i >= 0; i--) {
|
|
34
|
+
var context = {};
|
|
35
|
+
for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p];
|
|
36
|
+
for (var p in contextIn.access) context.access[p] = contextIn.access[p];
|
|
37
|
+
context.addInitializer = function (f) { if (done) throw new TypeError("Cannot add initializers after decoration has completed"); extraInitializers.push(accept(f || null)); };
|
|
38
|
+
var result = (0, decorators[i])(kind === "accessor" ? { get: descriptor.get, set: descriptor.set } : descriptor[key], context);
|
|
39
|
+
if (kind === "accessor") {
|
|
40
|
+
if (result === void 0) continue;
|
|
41
|
+
if (result === null || typeof result !== "object") throw new TypeError("Object expected");
|
|
42
|
+
if (_ = accept(result.get)) descriptor.get = _;
|
|
43
|
+
if (_ = accept(result.set)) descriptor.set = _;
|
|
44
|
+
if (_ = accept(result.init)) initializers.unshift(_);
|
|
45
|
+
}
|
|
46
|
+
else if (_ = accept(result)) {
|
|
47
|
+
if (kind === "field") initializers.unshift(_);
|
|
48
|
+
else descriptor[key] = _;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (target) Object.defineProperty(target, contextIn.name, descriptor);
|
|
52
|
+
done = true;
|
|
53
|
+
};
|
|
54
|
+
import { Context } from '@deepseek-ai/cordis';
|
|
55
|
+
import { Graphviz } from '@hpcc-js/wasm-graphviz';
|
|
56
|
+
import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
|
|
57
|
+
import { analyzeGraph } from "./metrics.js";
|
|
58
|
+
import { composeGraphvizSvgs, isolatedNodes, renderCompleteDot, renderDot, renderIsolatedDot, renderJson } from "./render.js";
|
|
59
|
+
export { analyzeGraph, collapse } from "./metrics.js";
|
|
60
|
+
export { composeGraphvizSvgs, isolatedNodes, nodeLink, renderCompleteDot, renderDot, renderIsolatedDot, renderJson } from "./render.js";
|
|
61
|
+
/** Map a Cordis fiber state number to its stable name. */
|
|
62
|
+
function fiberStateName(state) {
|
|
63
|
+
switch (state) {
|
|
64
|
+
case 0: return 'PENDING';
|
|
65
|
+
case 1: return 'LOADING';
|
|
66
|
+
case 2: return 'ACTIVE';
|
|
67
|
+
case 3: return 'FAILED';
|
|
68
|
+
case 4: return 'DISPOSED';
|
|
69
|
+
default: return 'UNLOADING';
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/** Read-only projection of the runtime service dependency graph. */
|
|
73
|
+
let PluginTopologyService = (() => {
|
|
74
|
+
let _classSuper = TypertRemoteService;
|
|
75
|
+
let _instanceExtraInitializers = [];
|
|
76
|
+
let _analyze_decorators;
|
|
77
|
+
let _render_decorators;
|
|
78
|
+
return class PluginTopologyService extends _classSuper {
|
|
79
|
+
static {
|
|
80
|
+
const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
|
|
81
|
+
_analyze_decorators = [Remote('analyze')];
|
|
82
|
+
_render_decorators = [Remote('render')];
|
|
83
|
+
__esDecorate(this, null, _analyze_decorators, { kind: "method", name: "analyze", static: false, private: false, access: { has: obj => "analyze" in obj, get: obj => obj.analyze }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
84
|
+
__esDecorate(this, null, _render_decorators, { kind: "method", name: "render", static: false, private: false, access: { has: obj => "render" in obj, get: obj => obj.render }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
85
|
+
if (_metadata) Object.defineProperty(this, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata });
|
|
86
|
+
}
|
|
87
|
+
graphvizPromise = __runInitializers(this, _instanceExtraInitializers);
|
|
88
|
+
constructor(ctx) {
|
|
89
|
+
super(ctx, 'pluginTopology');
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Capture the current runtime topology without mutating it.
|
|
93
|
+
* @returns a JSON-safe bipartite graph of plugins and services.
|
|
94
|
+
*/
|
|
95
|
+
snapshot() {
|
|
96
|
+
const root = this.ctx.root;
|
|
97
|
+
const nodes = [];
|
|
98
|
+
const edges = [];
|
|
99
|
+
const unresolved = [];
|
|
100
|
+
const fiberIds = new Map();
|
|
101
|
+
// Plugin nodes. The root fiber is not registered, so synthesize it to root
|
|
102
|
+
// the fiber tree; its uid is 0 by construction.
|
|
103
|
+
nodes.push({ id: 'p:0', kind: 'plugin', label: 'root', state: 'ACTIVE', parent: null, inject: [] });
|
|
104
|
+
for (const runtime of root.registry.values()) {
|
|
105
|
+
for (const fiber of runtime.fibers) {
|
|
106
|
+
const uid = fiber.uid;
|
|
107
|
+
if (uid === null)
|
|
108
|
+
continue;
|
|
109
|
+
const id = `p:${uid}`;
|
|
110
|
+
fiberIds.set(fiber, id);
|
|
111
|
+
const parentUid = fiber.parent.fiber.uid;
|
|
112
|
+
// Loader-mounted fibers carry the module specifier they were imported
|
|
113
|
+
// from (the npm/package path); programmatic mounts do not.
|
|
114
|
+
const source = fiber.entry?.options?.name;
|
|
115
|
+
nodes.push({
|
|
116
|
+
id,
|
|
117
|
+
kind: 'plugin',
|
|
118
|
+
label: fiber.name,
|
|
119
|
+
state: fiberStateName(fiber.state),
|
|
120
|
+
parent: parentUid === null ? null : `p:${parentUid}`,
|
|
121
|
+
inject: Object.keys(fiber.inject),
|
|
122
|
+
...(source === undefined ? {} : { source }),
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
// Service nodes and provides edges. Store keys are isolation labels.
|
|
127
|
+
const implIds = new Map();
|
|
128
|
+
const scopeIds = new Map();
|
|
129
|
+
let isolateCounter = 0;
|
|
130
|
+
for (const key of Object.getOwnPropertySymbols(root.reflect.store)) {
|
|
131
|
+
const impl = root.reflect.store[key];
|
|
132
|
+
if (impl === undefined)
|
|
133
|
+
continue;
|
|
134
|
+
let scope = scopeIds.get(key);
|
|
135
|
+
if (scope === undefined) {
|
|
136
|
+
scope = key === root[Context.isolate][impl.name] ? 'root' : `isolate-${isolateCounter++}`;
|
|
137
|
+
scopeIds.set(key, scope);
|
|
138
|
+
}
|
|
139
|
+
const serviceId = `s:${scope}:${impl.name}`;
|
|
140
|
+
implIds.set(impl, serviceId);
|
|
141
|
+
nodes.push({ id: serviceId, kind: 'service', label: impl.name, scope });
|
|
142
|
+
const providerId = fiberIds.get(impl.fiber);
|
|
143
|
+
if (providerId !== undefined) {
|
|
144
|
+
edges.push({ source: providerId, target: serviceId, kind: 'provides', service: impl.name });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
// Inject edges, or unresolved declarations for dependencies with no active provider.
|
|
148
|
+
for (const [fiber, id] of fiberIds) {
|
|
149
|
+
for (const name of Object.keys(fiber.inject)) {
|
|
150
|
+
const impl = fiber.store?.[name];
|
|
151
|
+
if (impl === undefined) {
|
|
152
|
+
unresolved.push({ plugin: id, service: name, state: fiberStateName(fiber.state) });
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
const serviceId = implIds.get(impl);
|
|
156
|
+
if (serviceId !== undefined) {
|
|
157
|
+
edges.push({ source: serviceId, target: id, kind: 'injects', service: name });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
kind: 'bipartite',
|
|
163
|
+
generatedAt: new Date().toISOString(),
|
|
164
|
+
nodes,
|
|
165
|
+
edges,
|
|
166
|
+
unresolved,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Capture the runtime topology and derive its graph-theory analysis.
|
|
171
|
+
* @returns the bipartite snapshot, its collapsed projection, and metrics.
|
|
172
|
+
*/
|
|
173
|
+
analyze() {
|
|
174
|
+
return analyzeGraph(this.snapshot());
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Export the current topology in one serialized format.
|
|
178
|
+
* @param format - `json` (node-link document), `dot` (Graphviz source), or `svg` (Graphviz-rendered SVG).
|
|
179
|
+
* @param rankdir - Graphviz layout direction for `dot`/`svg`; ignored by `json`.
|
|
180
|
+
* @returns the serialized document for the format.
|
|
181
|
+
*/
|
|
182
|
+
async render(format, rankdir) {
|
|
183
|
+
const analysis = this.analyze();
|
|
184
|
+
if (format === 'json')
|
|
185
|
+
return renderJson(analysis);
|
|
186
|
+
if (format === 'dot')
|
|
187
|
+
return renderCompleteDot(analysis, rankdir);
|
|
188
|
+
const graphviz = await (this.graphvizPromise ??= Graphviz.load());
|
|
189
|
+
const mainSvg = graphviz.dot(renderDot(analysis, rankdir), 'svg');
|
|
190
|
+
if (isolatedNodes(analysis).length === 0)
|
|
191
|
+
return mainSvg;
|
|
192
|
+
const isolatedSvg = graphviz.dot(renderIsolatedDot(analysis, rankdir), 'svg');
|
|
193
|
+
return composeGraphvizSvgs(mainSvg, isolatedSvg, rankdir);
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
})();
|
|
197
|
+
export { PluginTopologyService };
|
|
198
|
+
export default PluginTopologyService;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package-owned invariant companion.
|
|
3
|
+
* @module @sleetdrop/dsh-plugin-topology/invariant
|
|
4
|
+
*/
|
|
5
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
6
|
+
/** Cordis companion plugin name. */
|
|
7
|
+
export declare const name = "plugin-topology-invariant";
|
|
8
|
+
/** Service required before the companion can reserve package ownership. */
|
|
9
|
+
export declare const inject: string[];
|
|
10
|
+
/**
|
|
11
|
+
* Register this package's invariant companion.
|
|
12
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
13
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
14
|
+
*/
|
|
15
|
+
export declare const apply: (ctx: Context) => Promise<() => void>;
|
package/lib/invariant.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package-owned invariant companion.
|
|
3
|
+
* @module @sleetdrop/dsh-plugin-topology/invariant
|
|
4
|
+
*/
|
|
5
|
+
const PACKAGE_NAME = '@sleetdrop/dsh-plugin-topology';
|
|
6
|
+
/** Cordis companion plugin name. */
|
|
7
|
+
export const name = 'plugin-topology-invariant';
|
|
8
|
+
/** Service required before the companion can reserve package ownership. */
|
|
9
|
+
export const inject = ['invariants'];
|
|
10
|
+
/** No runtime invariant: this package owns a read-only diagnostic surface. */
|
|
11
|
+
const install = () => { };
|
|
12
|
+
/**
|
|
13
|
+
* Register this package's invariant companion.
|
|
14
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
15
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
16
|
+
*/
|
|
17
|
+
export const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
|
package/lib/metrics.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Graph-theory analysis of the plugin topology snapshot.
|
|
3
|
+
*
|
|
4
|
+
* `collapse` projects the bipartite service graph onto plugin-to-plugin
|
|
5
|
+
* `depends-on` edges (a plugin depends on the plugin that provides each
|
|
6
|
+
* service it injects). `computeMetrics` derives degree, centrality, and
|
|
7
|
+
* connectivity metrics from that projection; `analyzeGraph` composes both.
|
|
8
|
+
*
|
|
9
|
+
* Metrics run on the collapsed plugin graph with service-mediated repeats
|
|
10
|
+
* deduplicated, so `edgeCount` counts unique dependency pairs rather than
|
|
11
|
+
* service edges. Eigenvector centrality is 0 on an acyclic graph by
|
|
12
|
+
* definition; PageRank, betweenness, degree, and connectivity stay meaningful.
|
|
13
|
+
*
|
|
14
|
+
* @module @sleetdrop/dsh-plugin-topology/metrics
|
|
15
|
+
*/
|
|
16
|
+
import type { CollapsedGraph, GraphMetrics, NodeMetrics, TopologyAnalysis, TopologyGraph } from './types.ts';
|
|
17
|
+
export type * from './types.ts';
|
|
18
|
+
/** Project the bipartite graph onto plugin-to-plugin `depends-on` edges. */
|
|
19
|
+
export declare function collapse(graph: TopologyGraph): CollapsedGraph;
|
|
20
|
+
/** Derive per-node and aggregate metrics from a collapsed graph. */
|
|
21
|
+
export declare function computeMetrics(collapsed: CollapsedGraph): {
|
|
22
|
+
nodeMetrics: Record<string, NodeMetrics>;
|
|
23
|
+
graphMetrics: GraphMetrics;
|
|
24
|
+
};
|
|
25
|
+
/** Compose the collapsed projection and its metrics into one analysis. */
|
|
26
|
+
export declare function analyzeGraph(graph: TopologyGraph): TopologyAnalysis;
|
package/lib/metrics.js
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Graph-theory analysis of the plugin topology snapshot.
|
|
3
|
+
*
|
|
4
|
+
* `collapse` projects the bipartite service graph onto plugin-to-plugin
|
|
5
|
+
* `depends-on` edges (a plugin depends on the plugin that provides each
|
|
6
|
+
* service it injects). `computeMetrics` derives degree, centrality, and
|
|
7
|
+
* connectivity metrics from that projection; `analyzeGraph` composes both.
|
|
8
|
+
*
|
|
9
|
+
* Metrics run on the collapsed plugin graph with service-mediated repeats
|
|
10
|
+
* deduplicated, so `edgeCount` counts unique dependency pairs rather than
|
|
11
|
+
* service edges. Eigenvector centrality is 0 on an acyclic graph by
|
|
12
|
+
* definition; PageRank, betweenness, degree, and connectivity stay meaningful.
|
|
13
|
+
*
|
|
14
|
+
* @module @sleetdrop/dsh-plugin-topology/metrics
|
|
15
|
+
*/
|
|
16
|
+
/** Index a parallel array; the index is in bounds by construction. */
|
|
17
|
+
function at(array, index) {
|
|
18
|
+
const value = array[index];
|
|
19
|
+
if (value === undefined) {
|
|
20
|
+
throw new Error(`index ${index} is out of bounds for an array of length ${array.length}`);
|
|
21
|
+
}
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
/** Pop from a non-empty stack. */
|
|
25
|
+
function pop(stack) {
|
|
26
|
+
const value = stack.pop();
|
|
27
|
+
if (value === undefined) {
|
|
28
|
+
throw new Error('pop from an empty stack');
|
|
29
|
+
}
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
/** Project the bipartite graph onto plugin-to-plugin `depends-on` edges. */
|
|
33
|
+
export function collapse(graph) {
|
|
34
|
+
const pluginNodes = graph.nodes.filter(node => node.kind === 'plugin');
|
|
35
|
+
const providers = new Map();
|
|
36
|
+
const consumers = new Map();
|
|
37
|
+
for (const edge of graph.edges) {
|
|
38
|
+
if (edge.kind === 'provides') {
|
|
39
|
+
providers.set(edge.target, { plugin: edge.source, service: edge.service });
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
let set = consumers.get(edge.source);
|
|
43
|
+
if (set === undefined) {
|
|
44
|
+
set = new Set();
|
|
45
|
+
consumers.set(edge.source, set);
|
|
46
|
+
}
|
|
47
|
+
set.add(edge.target);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const edges = [];
|
|
51
|
+
for (const [serviceId, { plugin, service }] of providers) {
|
|
52
|
+
const consumerSet = consumers.get(serviceId);
|
|
53
|
+
if (consumerSet === undefined)
|
|
54
|
+
continue;
|
|
55
|
+
for (const consumer of consumerSet) {
|
|
56
|
+
edges.push({ source: consumer, target: plugin, service });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return { kind: 'collapsed', generatedAt: graph.generatedAt, nodes: pluginNodes, edges };
|
|
60
|
+
}
|
|
61
|
+
/** Derive per-node and aggregate metrics from a collapsed graph. */
|
|
62
|
+
export function computeMetrics(collapsed) {
|
|
63
|
+
const nodeIds = collapsed.nodes.map(node => node.id).sort();
|
|
64
|
+
const n = nodeIds.length;
|
|
65
|
+
const index = new Map();
|
|
66
|
+
nodeIds.forEach((id, i) => index.set(id, i));
|
|
67
|
+
const out = Array.from({ length: n }, () => new Set());
|
|
68
|
+
const inc = Array.from({ length: n }, () => new Set());
|
|
69
|
+
for (const edge of collapsed.edges) {
|
|
70
|
+
const source = index.get(edge.source);
|
|
71
|
+
const target = index.get(edge.target);
|
|
72
|
+
if (source === undefined || target === undefined)
|
|
73
|
+
continue;
|
|
74
|
+
at(out, source).add(target);
|
|
75
|
+
at(inc, target).add(source);
|
|
76
|
+
}
|
|
77
|
+
const outDegree = out.map(set => set.size);
|
|
78
|
+
const inDegree = inc.map(set => set.size);
|
|
79
|
+
const components = tarjan(out);
|
|
80
|
+
const componentSize = new Map();
|
|
81
|
+
for (const component of components) {
|
|
82
|
+
componentSize.set(component, (componentSize.get(component) ?? 0) + 1);
|
|
83
|
+
}
|
|
84
|
+
const stronglyConnectedComponents = componentSize.size;
|
|
85
|
+
let largestSccSize = 0;
|
|
86
|
+
for (const size of componentSize.values()) {
|
|
87
|
+
if (size > largestSccSize)
|
|
88
|
+
largestSccSize = size;
|
|
89
|
+
}
|
|
90
|
+
const { diameter, averagePathLength } = shortestPaths(out);
|
|
91
|
+
const weaklyConnectedComponents = countWeakComponents(out);
|
|
92
|
+
const betweenness = brandes(out);
|
|
93
|
+
const eigenvector = eigenvectorCentrality(inc);
|
|
94
|
+
const rank = pagerank(inc, outDegree);
|
|
95
|
+
const uniqueEdges = out.reduce((sum, set) => sum + set.size, 0);
|
|
96
|
+
const density = n <= 1 ? 0 : uniqueEdges / (n * (n - 1));
|
|
97
|
+
const nodeMetrics = {};
|
|
98
|
+
for (let i = 0; i < n; i++) {
|
|
99
|
+
nodeMetrics[at(nodeIds, i)] = {
|
|
100
|
+
inDegree: at(inDegree, i),
|
|
101
|
+
outDegree: at(outDegree, i),
|
|
102
|
+
degreeCentrality: n <= 1 ? 0 : (at(inDegree, i) + at(outDegree, i)) / (2 * (n - 1)),
|
|
103
|
+
betweennessCentrality: at(betweenness, i),
|
|
104
|
+
eigenvectorCentrality: at(eigenvector, i),
|
|
105
|
+
pagerank: at(rank, i),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
nodeMetrics,
|
|
110
|
+
graphMetrics: {
|
|
111
|
+
nodeCount: n,
|
|
112
|
+
edgeCount: uniqueEdges,
|
|
113
|
+
density,
|
|
114
|
+
stronglyConnectedComponents,
|
|
115
|
+
largestSccSize,
|
|
116
|
+
isDag: stronglyConnectedComponents === n,
|
|
117
|
+
weaklyConnectedComponents,
|
|
118
|
+
diameter,
|
|
119
|
+
averagePathLength,
|
|
120
|
+
sourceCount: outDegree.filter(degree => degree === 0).length,
|
|
121
|
+
sinkCount: inDegree.filter(degree => degree === 0).length,
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
/** Compose the collapsed projection and its metrics into one analysis. */
|
|
126
|
+
export function analyzeGraph(graph) {
|
|
127
|
+
const collapsed = collapse(graph);
|
|
128
|
+
const { nodeMetrics, graphMetrics } = computeMetrics(collapsed);
|
|
129
|
+
return { graph, collapsed, nodeMetrics, graphMetrics };
|
|
130
|
+
}
|
|
131
|
+
/** Tarjan strongly connected components over the `depends-on` adjacency. */
|
|
132
|
+
function tarjan(out) {
|
|
133
|
+
const n = out.length;
|
|
134
|
+
const index = new Array(n).fill(-1);
|
|
135
|
+
const low = new Array(n).fill(-1);
|
|
136
|
+
const onStack = new Array(n).fill(false);
|
|
137
|
+
const stack = [];
|
|
138
|
+
const component = new Array(n).fill(-1);
|
|
139
|
+
let counter = 0;
|
|
140
|
+
let componentCount = 0;
|
|
141
|
+
const visit = (v) => {
|
|
142
|
+
index[v] = low[v] = counter++;
|
|
143
|
+
stack.push(v);
|
|
144
|
+
onStack[v] = true;
|
|
145
|
+
for (const w of at(out, v)) {
|
|
146
|
+
if (at(index, w) === -1) {
|
|
147
|
+
visit(w);
|
|
148
|
+
low[v] = Math.min(at(low, v), at(low, w));
|
|
149
|
+
}
|
|
150
|
+
else if (at(onStack, w)) {
|
|
151
|
+
low[v] = Math.min(at(low, v), at(index, w));
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (at(low, v) === at(index, v)) {
|
|
155
|
+
let w;
|
|
156
|
+
do {
|
|
157
|
+
w = pop(stack);
|
|
158
|
+
onStack[w] = false;
|
|
159
|
+
component[w] = componentCount;
|
|
160
|
+
} while (w !== v);
|
|
161
|
+
componentCount++;
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
for (let v = 0; v < n; v++) {
|
|
165
|
+
if (at(index, v) === -1)
|
|
166
|
+
visit(v);
|
|
167
|
+
}
|
|
168
|
+
return component;
|
|
169
|
+
}
|
|
170
|
+
/** Directed all-pairs shortest paths; unreachable pairs are excluded. */
|
|
171
|
+
function shortestPaths(out) {
|
|
172
|
+
const n = out.length;
|
|
173
|
+
let diameter = 0;
|
|
174
|
+
let total = 0;
|
|
175
|
+
let count = 0;
|
|
176
|
+
for (let source = 0; source < n; source++) {
|
|
177
|
+
const distance = new Array(n).fill(-1);
|
|
178
|
+
distance[source] = 0;
|
|
179
|
+
const queue = [source];
|
|
180
|
+
let head = 0;
|
|
181
|
+
while (head < queue.length) {
|
|
182
|
+
const v = at(queue, head);
|
|
183
|
+
head++;
|
|
184
|
+
for (const w of at(out, v)) {
|
|
185
|
+
if (at(distance, w) === -1) {
|
|
186
|
+
distance[w] = at(distance, v) + 1;
|
|
187
|
+
queue.push(w);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
for (let target = 0; target < n; target++) {
|
|
192
|
+
const d = at(distance, target);
|
|
193
|
+
if (target === source || d < 0)
|
|
194
|
+
continue;
|
|
195
|
+
total += d;
|
|
196
|
+
count++;
|
|
197
|
+
if (d > diameter)
|
|
198
|
+
diameter = d;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return { diameter, averagePathLength: count === 0 ? 0 : total / count };
|
|
202
|
+
}
|
|
203
|
+
/** Number of weakly connected components on the undirected projection. */
|
|
204
|
+
function countWeakComponents(out) {
|
|
205
|
+
const n = out.length;
|
|
206
|
+
const undirected = Array.from({ length: n }, () => new Set());
|
|
207
|
+
for (let v = 0; v < n; v++) {
|
|
208
|
+
for (const w of at(out, v)) {
|
|
209
|
+
at(undirected, v).add(w);
|
|
210
|
+
at(undirected, w).add(v);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
const visited = new Array(n).fill(false);
|
|
214
|
+
let components = 0;
|
|
215
|
+
for (let start = 0; start < n; start++) {
|
|
216
|
+
if (at(visited, start))
|
|
217
|
+
continue;
|
|
218
|
+
components++;
|
|
219
|
+
const queue = [start];
|
|
220
|
+
visited[start] = true;
|
|
221
|
+
let head = 0;
|
|
222
|
+
while (head < queue.length) {
|
|
223
|
+
const v = at(queue, head);
|
|
224
|
+
head++;
|
|
225
|
+
for (const w of at(undirected, v)) {
|
|
226
|
+
if (at(visited, w))
|
|
227
|
+
continue;
|
|
228
|
+
visited[w] = true;
|
|
229
|
+
queue.push(w);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return components;
|
|
234
|
+
}
|
|
235
|
+
/** Brandes unweighted directed betweenness centrality. */
|
|
236
|
+
function brandes(out) {
|
|
237
|
+
const n = out.length;
|
|
238
|
+
const centrality = new Array(n).fill(0);
|
|
239
|
+
for (let source = 0; source < n; source++) {
|
|
240
|
+
const stack = [];
|
|
241
|
+
const predecessors = Array.from({ length: n }, () => []);
|
|
242
|
+
const sigma = new Array(n).fill(0);
|
|
243
|
+
const distance = new Array(n).fill(-1);
|
|
244
|
+
sigma[source] = 1;
|
|
245
|
+
distance[source] = 0;
|
|
246
|
+
const queue = [source];
|
|
247
|
+
let head = 0;
|
|
248
|
+
while (head < queue.length) {
|
|
249
|
+
const v = at(queue, head);
|
|
250
|
+
head++;
|
|
251
|
+
stack.push(v);
|
|
252
|
+
for (const w of at(out, v)) {
|
|
253
|
+
if (at(distance, w) === -1) {
|
|
254
|
+
distance[w] = at(distance, v) + 1;
|
|
255
|
+
queue.push(w);
|
|
256
|
+
}
|
|
257
|
+
if (at(distance, w) === at(distance, v) + 1) {
|
|
258
|
+
sigma[w] = at(sigma, w) + at(sigma, v);
|
|
259
|
+
at(predecessors, w).push(v);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
const dependency = new Array(n).fill(0);
|
|
264
|
+
while (stack.length > 0) {
|
|
265
|
+
const w = pop(stack);
|
|
266
|
+
for (const v of at(predecessors, w)) {
|
|
267
|
+
dependency[v] = at(dependency, v) + (at(sigma, v) / at(sigma, w)) * (1 + at(dependency, w));
|
|
268
|
+
}
|
|
269
|
+
if (w !== source)
|
|
270
|
+
centrality[w] = at(centrality, w) + at(dependency, w);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return centrality;
|
|
274
|
+
}
|
|
275
|
+
/** Power-iteration eigenvector centrality from in-neighbors. */
|
|
276
|
+
function eigenvectorCentrality(inc) {
|
|
277
|
+
const n = inc.length;
|
|
278
|
+
if (n === 0)
|
|
279
|
+
return [];
|
|
280
|
+
let x = new Array(n).fill(1 / Math.sqrt(n));
|
|
281
|
+
for (let iteration = 0; iteration < 100; iteration++) {
|
|
282
|
+
const next = new Array(n).fill(0);
|
|
283
|
+
for (let i = 0; i < n; i++) {
|
|
284
|
+
for (const j of at(inc, i)) {
|
|
285
|
+
next[i] = at(next, i) + at(x, j);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
const norm = Math.sqrt(next.reduce((sum, value) => sum + value * value, 0));
|
|
289
|
+
if (norm === 0)
|
|
290
|
+
return new Array(n).fill(0);
|
|
291
|
+
let delta = 0;
|
|
292
|
+
for (let i = 0; i < n; i++) {
|
|
293
|
+
next[i] = at(next, i) / norm;
|
|
294
|
+
delta += Math.abs(at(next, i) - at(x, i));
|
|
295
|
+
}
|
|
296
|
+
x = next;
|
|
297
|
+
if (delta < 1e-9)
|
|
298
|
+
break;
|
|
299
|
+
}
|
|
300
|
+
return x;
|
|
301
|
+
}
|
|
302
|
+
/** PageRank from in-neighbors with damping 0.85. */
|
|
303
|
+
function pagerank(inc, outDegree) {
|
|
304
|
+
const n = inc.length;
|
|
305
|
+
if (n === 0)
|
|
306
|
+
return [];
|
|
307
|
+
const damping = 0.85;
|
|
308
|
+
let x = new Array(n).fill(1 / n);
|
|
309
|
+
for (let iteration = 0; iteration < 100; iteration++) {
|
|
310
|
+
const next = new Array(n).fill((1 - damping) / n);
|
|
311
|
+
let dangling = 0;
|
|
312
|
+
for (let j = 0; j < n; j++) {
|
|
313
|
+
if (at(outDegree, j) === 0)
|
|
314
|
+
dangling += at(x, j);
|
|
315
|
+
}
|
|
316
|
+
for (let i = 0; i < n; i++) {
|
|
317
|
+
for (const j of at(inc, i)) {
|
|
318
|
+
next[i] = at(next, i) + (damping * at(x, j)) / at(outDegree, j);
|
|
319
|
+
}
|
|
320
|
+
next[i] = at(next, i) + (damping * dangling) / n;
|
|
321
|
+
}
|
|
322
|
+
let delta = 0;
|
|
323
|
+
for (let i = 0; i < n; i++) {
|
|
324
|
+
delta += Math.abs(at(next, i) - at(x, i));
|
|
325
|
+
}
|
|
326
|
+
x = next;
|
|
327
|
+
if (delta < 1e-9)
|
|
328
|
+
break;
|
|
329
|
+
}
|
|
330
|
+
return x;
|
|
331
|
+
}
|