logseq-graph-living-atlas 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/.env.example +29 -0
- package/CHANGELOG.md +38 -0
- package/CODE_OF_CONDUCT.md +33 -0
- package/CONTRIBUTING.md +116 -0
- package/GOVERNANCE.md +40 -0
- package/LICENSE +21 -0
- package/MAINTAINERS.md +34 -0
- package/README.md +370 -0
- package/ROADMAP.md +38 -0
- package/SECURITY.md +43 -0
- package/SUPPORT.md +31 -0
- package/dist/assets/AtlasCanvas-p-Pb_C3p.js +172 -0
- package/dist/assets/index-Cb0dgkF5.css +1 -0
- package/dist/assets/index-D-LUf-q7.js +12 -0
- package/dist/assets/three-DQCgX68K.js +3947 -0
- package/dist/index.html +13 -0
- package/docs/ADAPTERS.md +48 -0
- package/docs/API.md +145 -0
- package/docs/ARCHITECTURE.md +93 -0
- package/docs/CONCEPTS.md +62 -0
- package/docs/MCP.md +74 -0
- package/docs/RELEASE.md +65 -0
- package/docs/REPO_GUIDE.md +92 -0
- package/docs/TROUBLESHOOTING.md +138 -0
- package/docs/assets/living-atlas-demo.png +0 -0
- package/docs/assets/living-atlas-pathfinder.png +0 -0
- package/docs/assets/living-atlas-radar.png +0 -0
- package/docs/assets/living-atlas-source-detail.png +0 -0
- package/package.json +102 -0
- package/server/brain-service.mjs +201 -0
- package/server/contracts.mjs +346 -0
- package/server/fixture/create-fixture-graph.mjs +115 -0
- package/server/graph/pathfinding.mjs +155 -0
- package/server/graph/quality.mjs +11 -0
- package/server/graph/utils.mjs +25 -0
- package/server/graph-index.mjs +920 -0
- package/server/logseq/parser.mjs +121 -0
- package/server/logseq/source-adapter.mjs +147 -0
- package/server/redaction.mjs +89 -0
- package/server/service.mjs +777 -0
- package/server/source-adapter-contract.d.ts +46 -0
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
export const SNAPSHOT_VERSION = 1;
|
|
2
|
+
export const CACHE_VERSION = 1;
|
|
3
|
+
|
|
4
|
+
export function validateSnapshot(snapshot, context = "snapshot") {
|
|
5
|
+
assertObject(snapshot, context);
|
|
6
|
+
assertEqual(snapshot.version, SNAPSHOT_VERSION, `${context}.version`);
|
|
7
|
+
assertString(snapshot.generatedAt, `${context}.generatedAt`);
|
|
8
|
+
assertObject(snapshot.totals, `${context}.totals`);
|
|
9
|
+
for (const key of ["pages", "nodes", "links", "dangling", "clusters", "active24h", "active7d"]) {
|
|
10
|
+
assertNumber(snapshot.totals[key], `${context}.totals.${key}`);
|
|
11
|
+
}
|
|
12
|
+
assertArray(snapshot.nodes, `${context}.nodes`);
|
|
13
|
+
assertArray(snapshot.links, `${context}.links`);
|
|
14
|
+
assertArray(snapshot.clusters, `${context}.clusters`);
|
|
15
|
+
assertArray(snapshot.insights, `${context}.insights`);
|
|
16
|
+
assertObject(snapshot.health, `${context}.health`);
|
|
17
|
+
for (const [index, node] of snapshot.nodes.entries()) validateNode(node, `${context}.nodes[${index}]`);
|
|
18
|
+
for (const [index, link] of snapshot.links.entries()) validateLink(link, `${context}.links[${index}]`);
|
|
19
|
+
for (const [index, cluster] of snapshot.clusters.entries()) validateCluster(cluster, `${context}.clusters[${index}]`);
|
|
20
|
+
for (const [index, insight] of snapshot.insights.entries()) validateInsight(insight, `${context}.insights[${index}]`);
|
|
21
|
+
return snapshot;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function validateApiSnapshot(snapshot, context = "api.snapshot") {
|
|
25
|
+
validateSnapshot(snapshot, context);
|
|
26
|
+
assertObject(snapshot.graph, `${context}.graph`);
|
|
27
|
+
assertString(snapshot.graph.id, `${context}.graph.id`);
|
|
28
|
+
assertString(snapshot.graph.fingerprint, `${context}.graph.fingerprint`);
|
|
29
|
+
assertNumber(snapshot.graph.pages, `${context}.graph.pages`);
|
|
30
|
+
return snapshot;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function validateHealth(payload, context = "health") {
|
|
34
|
+
assertObject(payload, context);
|
|
35
|
+
assertBoolean(payload.ok, `${context}.ok`);
|
|
36
|
+
assertString(payload.generatedAt, `${context}.generatedAt`);
|
|
37
|
+
assertObject(payload.totals, `${context}.totals`);
|
|
38
|
+
assertObject(payload.cache, `${context}.cache`);
|
|
39
|
+
assertBoolean(payload.cache.configured, `${context}.cache.configured`);
|
|
40
|
+
assertBoolean(payload.cache.hit, `${context}.cache.hit`);
|
|
41
|
+
validateManifest(payload.manifest, `${context}.manifest`);
|
|
42
|
+
assertBoolean(payload.watch, `${context}.watch`);
|
|
43
|
+
assertString(payload.bindHost, `${context}.bindHost`);
|
|
44
|
+
assertBoolean(payload.localOnly, `${context}.localOnly`);
|
|
45
|
+
if (payload.requireToken !== undefined) assertBoolean(payload.requireToken, `${context}.requireToken`);
|
|
46
|
+
return payload;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function validateDelta(delta, context = "delta") {
|
|
50
|
+
assertObject(delta, context);
|
|
51
|
+
assertEqual(delta.type, "graph_delta", `${context}.type`);
|
|
52
|
+
assertString(delta.generatedAt, `${context}.generatedAt`);
|
|
53
|
+
for (const key of ["addedNodes", "changedNodes", "removedNodes"]) {
|
|
54
|
+
assertArray(delta[key], `${context}.${key}`);
|
|
55
|
+
delta[key].forEach((node, index) => validateNode(node, `${context}.${key}[${index}]`));
|
|
56
|
+
}
|
|
57
|
+
for (const key of ["addedLinks", "removedLinks"]) {
|
|
58
|
+
assertArray(delta[key], `${context}.${key}`);
|
|
59
|
+
delta[key].forEach((link, index) => validateLink(link, `${context}.${key}[${index}]`));
|
|
60
|
+
}
|
|
61
|
+
assertArray(delta.insights, `${context}.insights`);
|
|
62
|
+
assertObject(delta.totals, `${context}.totals`);
|
|
63
|
+
if (delta.changeCounts !== undefined) {
|
|
64
|
+
assertObject(delta.changeCounts, `${context}.changeCounts`);
|
|
65
|
+
for (const key of ["addedNodes", "changedNodes", "removedNodes", "addedLinks", "removedLinks"]) {
|
|
66
|
+
assertNumber(delta.changeCounts[key], `${context}.changeCounts.${key}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (delta.events !== undefined) {
|
|
70
|
+
assertArray(delta.events, `${context}.events`);
|
|
71
|
+
delta.events.forEach((event, index) => validateLiveEvent(event, `${context}.events[${index}]`));
|
|
72
|
+
}
|
|
73
|
+
if (delta.eventSeq !== undefined) assertNumber(delta.eventSeq, `${context}.eventSeq`);
|
|
74
|
+
if (delta.eventsOmitted !== undefined) assertNumber(delta.eventsOmitted, `${context}.eventsOmitted`);
|
|
75
|
+
if (delta.reason !== undefined) assertString(delta.reason, `${context}.reason`);
|
|
76
|
+
return delta;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function validateFocusResult(result, context = "focus") {
|
|
80
|
+
assertObject(result, context);
|
|
81
|
+
assertBoolean(result.ok, `${context}.ok`);
|
|
82
|
+
if (!result.ok) {
|
|
83
|
+
assertString(result.error, `${context}.error`);
|
|
84
|
+
assertString(result.query, `${context}.query`);
|
|
85
|
+
return result;
|
|
86
|
+
}
|
|
87
|
+
assertString(result.focusKind, `${context}.focusKind`);
|
|
88
|
+
if (result.seed !== null) validateNode(result.seed, `${context}.seed`);
|
|
89
|
+
if (result.cluster !== undefined) validateCluster(result.cluster, `${context}.cluster`);
|
|
90
|
+
assertNumber(result.radius, `${context}.radius`);
|
|
91
|
+
assertArray(result.nodes, `${context}.nodes`);
|
|
92
|
+
assertArray(result.links, `${context}.links`);
|
|
93
|
+
assertBoolean(result.limited, `${context}.limited`);
|
|
94
|
+
assertNumber(result.totalMatches, `${context}.totalMatches`);
|
|
95
|
+
assertArray(result.insights, `${context}.insights`);
|
|
96
|
+
result.nodes.forEach((node, index) => validateNode(node, `${context}.nodes[${index}]`));
|
|
97
|
+
result.links.forEach((link, index) => validateLink(link, `${context}.links[${index}]`));
|
|
98
|
+
return result;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function validatePathResult(result, context = "path") {
|
|
102
|
+
assertObject(result, context);
|
|
103
|
+
assertBoolean(result.ok, `${context}.ok`);
|
|
104
|
+
if (!result.ok) {
|
|
105
|
+
assertString(result.error, `${context}.error`);
|
|
106
|
+
if (result.missing !== undefined) assertArray(result.missing, `${context}.missing`);
|
|
107
|
+
if (result.maxDepth !== undefined) assertNumber(result.maxDepth, `${context}.maxDepth`);
|
|
108
|
+
if (result.explored !== undefined) assertNumber(result.explored, `${context}.explored`);
|
|
109
|
+
return result;
|
|
110
|
+
}
|
|
111
|
+
validateNode(result.from, `${context}.from`);
|
|
112
|
+
validateNode(result.to, `${context}.to`);
|
|
113
|
+
assertNumber(result.depth, `${context}.depth`);
|
|
114
|
+
assertArray(result.nodes, `${context}.nodes`);
|
|
115
|
+
assertArray(result.links, `${context}.links`);
|
|
116
|
+
assertArray(result.steps, `${context}.steps`);
|
|
117
|
+
assertString(result.summary, `${context}.summary`);
|
|
118
|
+
result.nodes.forEach((node, index) => validateNode(node, `${context}.nodes[${index}]`));
|
|
119
|
+
result.links.forEach((link, index) => validateLink(link, `${context}.links[${index}]`));
|
|
120
|
+
result.steps.forEach((step, index) => validatePathStep(step, `${context}.steps[${index}]`));
|
|
121
|
+
if (result.routeScore !== undefined) validateRouteScore(result.routeScore, `${context}.routeScore`);
|
|
122
|
+
if (result.alternateRoutes !== undefined) assertArray(result.alternateRoutes, `${context}.alternateRoutes`);
|
|
123
|
+
return result;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function validateNodeDetail(result, context = "nodeDetail") {
|
|
127
|
+
assertObject(result, context);
|
|
128
|
+
assertBoolean(result.ok, `${context}.ok`);
|
|
129
|
+
if (!result.ok) {
|
|
130
|
+
assertString(result.error, `${context}.error`);
|
|
131
|
+
assertString(result.query, `${context}.query`);
|
|
132
|
+
return result;
|
|
133
|
+
}
|
|
134
|
+
validateNode(result.node, `${context}.node`);
|
|
135
|
+
assertObject(result.source, `${context}.source`);
|
|
136
|
+
assertString(result.source.relativePath, `${context}.source.relativePath`);
|
|
137
|
+
assertString(result.source.updatedAt, `${context}.source.updatedAt`);
|
|
138
|
+
assertObject(result.source.properties, `${context}.source.properties`);
|
|
139
|
+
assertString(result.source.preview, `${context}.source.preview`);
|
|
140
|
+
for (const key of ["backlinks", "outlinks"]) {
|
|
141
|
+
assertArray(result[key], `${context}.${key}`);
|
|
142
|
+
result[key].forEach((entry, index) => validateNodeEdge(entry, `${context}.${key}[${index}]`));
|
|
143
|
+
}
|
|
144
|
+
for (const key of ["backlinksTotal", "outlinksTotal", "edgeLimit"]) {
|
|
145
|
+
if (result[key] !== undefined) assertNumber(result[key], `${context}.${key}`);
|
|
146
|
+
}
|
|
147
|
+
assertArray(result.insights, `${context}.insights`);
|
|
148
|
+
assertObject(result.xray, `${context}.xray`);
|
|
149
|
+
assertString(result.xray.kind, `${context}.xray.kind`);
|
|
150
|
+
if (result.xray.parent !== null) assertObject(result.xray.parent, `${context}.xray.parent`);
|
|
151
|
+
if (result.xray.cluster !== null) validateCluster(result.xray.cluster, `${context}.xray.cluster`);
|
|
152
|
+
assertNumber(result.xray.staleDays, `${context}.xray.staleDays`);
|
|
153
|
+
assertArray(result.xray.proofDebt, `${context}.xray.proofDebt`);
|
|
154
|
+
assertArray(result.xray.strongest, `${context}.xray.strongest`);
|
|
155
|
+
assertArray(result.xray.signalSummary, `${context}.xray.signalSummary`);
|
|
156
|
+
return result;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function validateConnectorResult(result, context = "connectors") {
|
|
160
|
+
assertObject(result, context);
|
|
161
|
+
assertBoolean(result.ok, `${context}.ok`);
|
|
162
|
+
assertString(result.generatedAt, `${context}.generatedAt`);
|
|
163
|
+
assertArray(result.candidates, `${context}.candidates`);
|
|
164
|
+
result.candidates.forEach((candidate, index) => validateConnectorCandidate(candidate, `${context}.candidates[${index}]`));
|
|
165
|
+
return result;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function validateSearchResult(result, context = "search") {
|
|
169
|
+
assertObject(result, context);
|
|
170
|
+
assertBoolean(result.ok, `${context}.ok`);
|
|
171
|
+
assertString(result.generatedAt, `${context}.generatedAt`);
|
|
172
|
+
assertString(result.query, `${context}.query`);
|
|
173
|
+
assertNumber(result.totalMatches, `${context}.totalMatches`);
|
|
174
|
+
assertNumber(result.omitted, `${context}.omitted`);
|
|
175
|
+
assertArray(result.results, `${context}.results`);
|
|
176
|
+
result.results.forEach((node, index) => validateNode(node, `${context}.results[${index}]`));
|
|
177
|
+
return result;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function validateRecords(records, context = "records") {
|
|
181
|
+
assertArray(records, context);
|
|
182
|
+
for (const [index, record] of records.entries()) {
|
|
183
|
+
const pointer = `${context}[${index}]`;
|
|
184
|
+
assertObject(record, pointer);
|
|
185
|
+
assertString(record.id, `${pointer}.id`);
|
|
186
|
+
assertString(record.name, `${pointer}.name`);
|
|
187
|
+
assertString(record.path, `${pointer}.path`);
|
|
188
|
+
assertArray(record.out, `${pointer}.out`);
|
|
189
|
+
assertObject(record.props, `${pointer}.props`);
|
|
190
|
+
}
|
|
191
|
+
return records;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function validateManifest(manifest, context = "manifest") {
|
|
195
|
+
assertObject(manifest, context);
|
|
196
|
+
assertNumber(manifest.pages, `${context}.pages`);
|
|
197
|
+
if (manifest.graphId !== undefined) assertString(manifest.graphId, `${context}.graphId`);
|
|
198
|
+
assertString(manifest.fingerprint, `${context}.fingerprint`);
|
|
199
|
+
assertNumber(manifest.maxMtimeMs, `${context}.maxMtimeMs`);
|
|
200
|
+
return manifest;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function validateCacheEnvelope(envelope, expectedManifest = null) {
|
|
204
|
+
assertObject(envelope, "cache");
|
|
205
|
+
assertEqual(envelope.version, CACHE_VERSION, "cache.version");
|
|
206
|
+
assertString(envelope.writtenAt, "cache.writtenAt");
|
|
207
|
+
validateManifest(envelope.manifest, "cache.manifest");
|
|
208
|
+
if (expectedManifest && envelope.manifest.fingerprint !== expectedManifest.fingerprint) {
|
|
209
|
+
throw new ContractError("cache.manifest.fingerprint does not match current graph fingerprint");
|
|
210
|
+
}
|
|
211
|
+
validateSnapshot(envelope.snapshot, "cache.snapshot");
|
|
212
|
+
validateRecords(envelope.records, "cache.records");
|
|
213
|
+
return envelope;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function createCacheEnvelope(payload) {
|
|
217
|
+
validateManifest(payload.manifest);
|
|
218
|
+
validateSnapshot(payload.snapshot);
|
|
219
|
+
validateRecords(payload.records);
|
|
220
|
+
return {
|
|
221
|
+
version: CACHE_VERSION,
|
|
222
|
+
writtenAt: new Date().toISOString(),
|
|
223
|
+
manifest: payload.manifest,
|
|
224
|
+
snapshot: payload.snapshot,
|
|
225
|
+
records: payload.records
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export class ContractError extends Error {
|
|
230
|
+
constructor(message) {
|
|
231
|
+
super(message);
|
|
232
|
+
this.name = "ContractError";
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function validateNode(node, context) {
|
|
237
|
+
assertObject(node, context);
|
|
238
|
+
for (const key of ["id", "name", "type", "status", "source", "confidence", "updatedAt", "cluster", "clusterLabel", "color"]) {
|
|
239
|
+
assertString(node[key], `${context}.${key}`);
|
|
240
|
+
}
|
|
241
|
+
for (const key of ["in", "out", "total", "x", "y", "z", "size", "heat"]) {
|
|
242
|
+
assertNumber(node[key], `${context}.${key}`);
|
|
243
|
+
}
|
|
244
|
+
assertArray(node.tags, `${context}.tags`);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function validateLink(link, context) {
|
|
248
|
+
assertObject(link, context);
|
|
249
|
+
for (const key of ["id", "source", "target", "kind"]) assertString(link[key], `${context}.${key}`);
|
|
250
|
+
if (link.weight !== undefined) assertNumber(link.weight, `${context}.weight`);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function validateCluster(cluster, context) {
|
|
254
|
+
assertObject(cluster, context);
|
|
255
|
+
for (const key of ["id", "label"]) assertString(cluster[key], `${context}.${key}`);
|
|
256
|
+
for (const key of ["count", "degree", "bridges", "heat"]) assertNumber(cluster[key], `${context}.${key}`);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function validateConnectorCandidate(candidate, context) {
|
|
260
|
+
assertObject(candidate, context);
|
|
261
|
+
assertString(candidate.id, `${context}.id`);
|
|
262
|
+
validateCluster(candidate.fromCluster, `${context}.fromCluster`);
|
|
263
|
+
validateCluster(candidate.toCluster, `${context}.toCluster`);
|
|
264
|
+
for (const key of ["linkCount", "expected", "score"]) assertNumber(candidate[key], `${context}.${key}`);
|
|
265
|
+
assertString(candidate.rationale, `${context}.rationale`);
|
|
266
|
+
assertArray(candidate.nodeIds, `${context}.nodeIds`);
|
|
267
|
+
assertArray(candidate.anchors, `${context}.anchors`);
|
|
268
|
+
candidate.anchors.forEach((anchor, index) => {
|
|
269
|
+
const pointer = `${context}.anchors[${index}]`;
|
|
270
|
+
assertObject(anchor, pointer);
|
|
271
|
+
for (const key of ["id", "name", "cluster"]) assertString(anchor[key], `${pointer}.${key}`);
|
|
272
|
+
for (const key of ["degree", "heat", "debt"]) assertNumber(anchor[key], `${pointer}.${key}`);
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function validateInsight(insight, context) {
|
|
277
|
+
assertObject(insight, context);
|
|
278
|
+
for (const key of ["id", "severity", "title", "detail"]) assertString(insight[key], `${context}.${key}`);
|
|
279
|
+
assertNumber(insight.metric, `${context}.metric`);
|
|
280
|
+
assertArray(insight.nodeIds, `${context}.nodeIds`);
|
|
281
|
+
assertArray(insight.provenance, `${context}.provenance`);
|
|
282
|
+
if (insight.action !== undefined) {
|
|
283
|
+
assertObject(insight.action, `${context}.action`);
|
|
284
|
+
for (const key of ["kind", "label", "target", "rationale", "nextStep"]) assertString(insight.action[key], `${context}.action.${key}`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function validateLiveEvent(event, context) {
|
|
289
|
+
assertObject(event, context);
|
|
290
|
+
assertString(event.id, `${context}.id`);
|
|
291
|
+
assertNumber(event.seq, `${context}.seq`);
|
|
292
|
+
assertString(event.kind, `${context}.kind`);
|
|
293
|
+
assertString(event.reason, `${context}.reason`);
|
|
294
|
+
assertString(event.observedAt, `${context}.observedAt`);
|
|
295
|
+
assertString(event.actor, `${context}.actor`);
|
|
296
|
+
for (const key of ["nodeId", "nodeName", "sourceId", "targetId", "linkId", "cluster", "color"]) {
|
|
297
|
+
if (event[key] !== undefined) assertString(event[key], `${context}.${key}`);
|
|
298
|
+
}
|
|
299
|
+
for (const key of ["x", "y", "z", "weight"]) {
|
|
300
|
+
if (event[key] !== undefined) assertNumber(event[key], `${context}.${key}`);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function validateNodeEdge(entry, context) {
|
|
305
|
+
assertObject(entry, context);
|
|
306
|
+
assertString(entry.linkId, `${context}.linkId`);
|
|
307
|
+
if (entry.weight !== undefined) assertNumber(entry.weight, `${context}.weight`);
|
|
308
|
+
validateNode(entry.node, `${context}.node`);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function validatePathStep(step, context) {
|
|
312
|
+
assertObject(step, context);
|
|
313
|
+
for (const key of ["from", "to", "linkId", "direction", "evidence"]) assertString(step[key], `${context}.${key}`);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function validateRouteScore(score, context) {
|
|
317
|
+
assertObject(score, context);
|
|
318
|
+
assertString(score.label, `${context}.label`);
|
|
319
|
+
for (const key of ["score", "hops", "clusters", "freshness", "proofDebt", "linkEvidence"]) {
|
|
320
|
+
assertNumber(score[key], `${context}.${key}`);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function assertObject(value, context) {
|
|
325
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new ContractError(`${context} must be an object`);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function assertArray(value, context) {
|
|
329
|
+
if (!Array.isArray(value)) throw new ContractError(`${context} must be an array`);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function assertString(value, context) {
|
|
333
|
+
if (typeof value !== "string") throw new ContractError(`${context} must be a string`);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function assertNumber(value, context) {
|
|
337
|
+
if (typeof value !== "number" || !Number.isFinite(value)) throw new ContractError(`${context} must be a finite number`);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function assertBoolean(value, context) {
|
|
341
|
+
if (typeof value !== "boolean") throw new ContractError(`${context} must be a boolean`);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function assertEqual(value, expected, context) {
|
|
345
|
+
if (value !== expected) throw new ContractError(`${context} must be ${expected}`);
|
|
346
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const repoRoot = path.resolve(__dirname, "../..");
|
|
9
|
+
const FIXTURE_MARKER = ".living-atlas-fixture";
|
|
10
|
+
|
|
11
|
+
export function createFixtureGraph(options = {}) {
|
|
12
|
+
const root = path.resolve(options.out || fs.mkdtempSync(path.join(os.tmpdir(), "living-atlas-fixture-")));
|
|
13
|
+
prepareFixtureDirectory(root);
|
|
14
|
+
const pages = path.join(root, "pages");
|
|
15
|
+
const journals = path.join(root, "journals");
|
|
16
|
+
fs.mkdirSync(pages, { recursive: true });
|
|
17
|
+
fs.mkdirSync(journals, { recursive: true });
|
|
18
|
+
fs.writeFileSync(path.join(root, FIXTURE_MARKER), "Generated by logseq-graph-living-atlas.\n", "utf8");
|
|
19
|
+
|
|
20
|
+
const writePage = (name, body, mtimeOffsetHours = 0) => {
|
|
21
|
+
const filePath = path.join(pages, `${name}.md`);
|
|
22
|
+
fs.writeFileSync(filePath, body, "utf8");
|
|
23
|
+
touch(filePath, mtimeOffsetHours);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const writeJournal = (name, body, mtimeOffsetHours = 0) => {
|
|
27
|
+
const filePath = path.join(journals, `${name}.md`);
|
|
28
|
+
fs.writeFileSync(filePath, body, "utf8");
|
|
29
|
+
touch(filePath, mtimeOffsetHours);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
writePage("Nexus", "type:: organization\nstatus:: active\nsource:: fixture\nconfidence:: high\n- [[Atlas]] [[Project Orion]] [[Signal Desk]]\n", 1);
|
|
33
|
+
writePage("Atlas", "type:: project\ncompany:: [[Nexus]]\nstatus:: active\nsource:: fixture\nconfidence:: high\n- [[Nexus]] [[Signal Desk]] [[Data Pipeline]]\n", 2);
|
|
34
|
+
writePage("Project Orion", "type:: project\ncompany:: [[Nexus]]\nstatus:: active\nsource:: fixture\nconfidence:: medium\n- [[Nexus]] [[Field Study]] [[Signal Desk]]\n", 3);
|
|
35
|
+
writePage("Field Study", "type:: project\nstatus:: active\nsource:: fixture\nconfidence:: medium\n- [[Project Orion]] [[Atlas]]\n", 8);
|
|
36
|
+
writePage("Signal Desk", "type:: infrastructure\nstatus:: active\nsource:: fixture\nconfidence:: high\n- [[Atlas]] [[Nexus]] [[Data Pipeline]]\n", 5);
|
|
37
|
+
writePage("Data Pipeline", "type:: infrastructure\nstatus:: active\nsource:: fixture\nconfidence:: high\n- [[Signal Desk]] [[Atlas]]\n", 9);
|
|
38
|
+
writePage("Person One", "type:: person\nstatus:: active\nsource:: fixture\nconfidence:: high\n- [[Nexus]] [[Atlas]]\n", 18);
|
|
39
|
+
writePage("Person Two", "type:: person\nstatus:: active\nsource:: fixture\nconfidence:: high\n- [[Nexus]] [[Project Orion]]\n", 28);
|
|
40
|
+
writePage("Operations Hub", "type:: service\nstatus:: active\nsource:: fixture\nconfidence:: high\n- [[Signal Desk]] [[Data Pipeline]]\n", 12);
|
|
41
|
+
writePage("Unresolved Target Note", "type:: note\nstatus:: draft\n- [[Missing Reference]]\n", 200);
|
|
42
|
+
|
|
43
|
+
for (let index = 0; index < 34; index += 1) {
|
|
44
|
+
const anchor = index % 3 === 0 ? "Atlas" : index % 3 === 1 ? "Nexus" : "Project Orion";
|
|
45
|
+
const previous = index > 0 ? `[[Research Note ${index - 1}]]` : `[[${anchor}]]`;
|
|
46
|
+
writePage(
|
|
47
|
+
`Research Note ${index}`,
|
|
48
|
+
`type:: project\ntags:: [[${anchor}]]\nstatus:: ${index % 4 === 0 ? "draft" : "active"}\nsource:: fixture\nconfidence:: ${index % 5 === 0 ? "low" : "medium"}\n- [[${anchor}]] ${previous}\n`,
|
|
49
|
+
24 + index * 8
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
for (let index = 0; index < 18; index += 1) {
|
|
54
|
+
writePage(
|
|
55
|
+
`Operations Log ${index}`,
|
|
56
|
+
`type:: service\ntags:: [[Operations Hub]]\nstatus:: active\nsource:: fixture\nconfidence:: medium\n- [[Operations Hub]] [[Signal Desk]]\n`,
|
|
57
|
+
72 + index * 12
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
writeJournal("2026_05_30", "- Fixture journal for Living Atlas tests.\n- [[Atlas]] reviewed [[Signal Desk]].\n", 6);
|
|
62
|
+
writeJournal("2026_05_31", "- [[Project Orion]] generated a new [[Field Study]] follow-up.\n", 4);
|
|
63
|
+
|
|
64
|
+
return root;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function prepareFixtureDirectory(root) {
|
|
68
|
+
assertSafeFixtureOutput(root);
|
|
69
|
+
if (!fs.existsSync(root)) return;
|
|
70
|
+
if (isDirectoryEmpty(root)) {
|
|
71
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const markerPath = path.join(root, FIXTURE_MARKER);
|
|
75
|
+
if (!fs.existsSync(markerPath)) {
|
|
76
|
+
throw new Error(`Refusing to overwrite ${root}; missing ${FIXTURE_MARKER} marker.`);
|
|
77
|
+
}
|
|
78
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function assertSafeFixtureOutput(root) {
|
|
82
|
+
const parsed = path.parse(root);
|
|
83
|
+
const dangerous = new Set([parsed.root, os.homedir(), repoRoot, path.dirname(repoRoot)]);
|
|
84
|
+
if (dangerous.has(root)) {
|
|
85
|
+
throw new Error(`Refusing dangerous fixture output path: ${root}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function isDirectoryEmpty(root) {
|
|
90
|
+
return fs.statSync(root).isDirectory() && fs.readdirSync(root).length === 0;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function touch(filePath, mtimeOffsetHours) {
|
|
94
|
+
const time = new Date(Date.now() - mtimeOffsetHours * 36e5);
|
|
95
|
+
fs.utimesSync(filePath, time, time);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function parseArgs(argv) {
|
|
99
|
+
const parsed = {};
|
|
100
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
101
|
+
const arg = argv[index];
|
|
102
|
+
if (arg === "--out") {
|
|
103
|
+
parsed.out = argv[index + 1];
|
|
104
|
+
index += 1;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return parsed;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
111
|
+
const args = parseArgs(process.argv.slice(2));
|
|
112
|
+
const out = args.out ? path.resolve(repoRoot, args.out) : undefined;
|
|
113
|
+
const root = createFixtureGraph({ out });
|
|
114
|
+
console.log(root);
|
|
115
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { buildAdjacency, findNode, round } from "./utils.mjs";
|
|
2
|
+
import { proofDebtFor } from "./quality.mjs";
|
|
3
|
+
|
|
4
|
+
export function pathSnapshot(snapshot, fromQuery, toQuery, maxDepth = 7, runtime = null) {
|
|
5
|
+
const from = findNode(snapshot.nodes, fromQuery);
|
|
6
|
+
const to = findNode(snapshot.nodes, toQuery);
|
|
7
|
+
if (!from || !to) {
|
|
8
|
+
return {
|
|
9
|
+
ok: false,
|
|
10
|
+
error: "endpoint not found",
|
|
11
|
+
from: fromQuery,
|
|
12
|
+
to: toQuery,
|
|
13
|
+
missing: [!from ? "from" : null, !to ? "to" : null].filter(Boolean)
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
if (from.id === to.id) {
|
|
17
|
+
return { ok: true, from, to, depth: 0, nodes: [from], links: [], steps: [], summary: `${from.name} is the same page.` };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const { adjacency, edgeLookup } = runtime?.adjacency && runtime?.edgeLookup
|
|
21
|
+
? runtime
|
|
22
|
+
: buildAdjacency(snapshot.links);
|
|
23
|
+
const paths = findBoundedPaths(adjacency, from.id, to.id, maxDepth, 4);
|
|
24
|
+
const nodeMap = runtime?.nodeById || new Map(snapshot.nodes.map((node) => [node.id, node]));
|
|
25
|
+
const rankedRoutes = paths
|
|
26
|
+
.map((path, index) => ({ index, path, payload: pathPayload(path, nodeMap, edgeLookup) }))
|
|
27
|
+
.sort((a, b) => b.payload.routeScore.score - a.payload.routeScore.score || a.payload.routeScore.hops - b.payload.routeScore.hops || a.index - b.index);
|
|
28
|
+
const primaryRoute = rankedRoutes[0] || null;
|
|
29
|
+
const resolved = primaryRoute?.path || null;
|
|
30
|
+
|
|
31
|
+
if (!resolved) {
|
|
32
|
+
return {
|
|
33
|
+
ok: false,
|
|
34
|
+
error: "no path within depth",
|
|
35
|
+
from,
|
|
36
|
+
to,
|
|
37
|
+
maxDepth,
|
|
38
|
+
explored: countReachable(adjacency, from.id, maxDepth)
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const primary = primaryRoute.payload;
|
|
43
|
+
const alternateRoutes = rankedRoutes
|
|
44
|
+
.slice(1)
|
|
45
|
+
.map((route, index) => {
|
|
46
|
+
const { path, payload } = route;
|
|
47
|
+
return {
|
|
48
|
+
id: `alt-${index + 1}-${path.join("-")}`,
|
|
49
|
+
nodes: payload.nodes.map((node) => node.name),
|
|
50
|
+
score: payload.routeScore
|
|
51
|
+
};
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
ok: true,
|
|
56
|
+
from,
|
|
57
|
+
to,
|
|
58
|
+
depth: resolved.length - 1,
|
|
59
|
+
nodes: primary.nodes,
|
|
60
|
+
links: primary.links,
|
|
61
|
+
steps: primary.steps,
|
|
62
|
+
routeScore: primary.routeScore,
|
|
63
|
+
alternateRoutes,
|
|
64
|
+
summary: `${from.name} connects to ${to.name} through ${resolved.length - 1} graph hop${resolved.length === 2 ? "" : "s"}.`
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function pathPayload(resolved, nodeMap, edgeLookup) {
|
|
69
|
+
const nodes = resolved.map((id) => nodeMap.get(id)).filter(Boolean);
|
|
70
|
+
const links = [];
|
|
71
|
+
const steps = [];
|
|
72
|
+
for (let index = 0; index < resolved.length - 1; index += 1) {
|
|
73
|
+
const a = resolved[index];
|
|
74
|
+
const b = resolved[index + 1];
|
|
75
|
+
const edge = edgeLookup.get(`${a}|${b}`);
|
|
76
|
+
if (edge) links.push(edge);
|
|
77
|
+
const source = nodeMap.get(a);
|
|
78
|
+
const target = nodeMap.get(b);
|
|
79
|
+
steps.push({
|
|
80
|
+
from: source?.name || a,
|
|
81
|
+
to: target?.name || b,
|
|
82
|
+
linkId: edge?.id || `${a}->${b}`,
|
|
83
|
+
direction: edge?.source === a ? "outbound" : "backlink",
|
|
84
|
+
evidence: edge?.source === a
|
|
85
|
+
? `${source?.name || a} links to ${target?.name || b}`
|
|
86
|
+
: `${target?.name || b} links back to ${source?.name || a}`
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
nodes,
|
|
91
|
+
links,
|
|
92
|
+
steps,
|
|
93
|
+
routeScore: scoreRoute(nodes, links)
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function findBoundedPaths(adjacency, fromId, toId, maxDepth, limit = 4) {
|
|
98
|
+
const maxHops = Math.max(1, Math.floor(Number(maxDepth || 7)));
|
|
99
|
+
const maxPaths = Math.max(1, Math.floor(Number(limit || 4)));
|
|
100
|
+
const queue = [[fromId]];
|
|
101
|
+
const paths = [];
|
|
102
|
+
let expansions = 0;
|
|
103
|
+
while (queue.length && paths.length < maxPaths && expansions < 6000) {
|
|
104
|
+
const path = queue.shift();
|
|
105
|
+
const current = path[path.length - 1];
|
|
106
|
+
expansions += 1;
|
|
107
|
+
if (path.length - 1 >= maxHops) continue;
|
|
108
|
+
const neighbors = [...(adjacency.get(current) || [])].sort();
|
|
109
|
+
for (const neighbor of neighbors) {
|
|
110
|
+
if (path.includes(neighbor)) continue;
|
|
111
|
+
const nextPath = [...path, neighbor];
|
|
112
|
+
if (neighbor === toId) {
|
|
113
|
+
paths.push(nextPath);
|
|
114
|
+
if (paths.length >= maxPaths) break;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
queue.push(nextPath);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return paths;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function countReachable(adjacency, fromId, maxDepth) {
|
|
124
|
+
const maxHops = Math.max(1, Math.floor(Number(maxDepth || 7)));
|
|
125
|
+
const queue = [{ id: fromId, depth: 0 }];
|
|
126
|
+
const visited = new Set([fromId]);
|
|
127
|
+
while (queue.length) {
|
|
128
|
+
const current = queue.shift();
|
|
129
|
+
if (current.depth >= maxHops) continue;
|
|
130
|
+
for (const neighbor of adjacency.get(current.id) || []) {
|
|
131
|
+
if (visited.has(neighbor)) continue;
|
|
132
|
+
visited.add(neighbor);
|
|
133
|
+
queue.push({ id: neighbor, depth: current.depth + 1 });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return visited.size;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function scoreRoute(nodes, links) {
|
|
140
|
+
const avgHeat = nodes.length ? nodes.reduce((sum, node) => sum + node.heat, 0) / nodes.length : 0;
|
|
141
|
+
const avgDegree = nodes.length ? nodes.reduce((sum, node) => sum + node.total, 0) / nodes.length : 0;
|
|
142
|
+
const clusterCount = new Set(nodes.map((node) => node.cluster)).size;
|
|
143
|
+
const proofDebt = nodes.reduce((sum, node) => sum + proofDebtFor(node).length, 0);
|
|
144
|
+
const hopPenalty = Math.max(0, nodes.length - 2) * 4;
|
|
145
|
+
const score = Math.round(Math.max(1, Math.min(100, 34 + avgHeat * 26 + Math.sqrt(avgDegree) * 7 + clusterCount * 4 - proofDebt * 3 - hopPenalty)));
|
|
146
|
+
return {
|
|
147
|
+
score,
|
|
148
|
+
label: score >= 78 ? "strong path" : score >= 56 ? "usable path" : "thin path",
|
|
149
|
+
hops: Math.max(0, nodes.length - 1),
|
|
150
|
+
clusters: clusterCount,
|
|
151
|
+
freshness: round(avgHeat),
|
|
152
|
+
proofDebt,
|
|
153
|
+
linkEvidence: links.length
|
|
154
|
+
};
|
|
155
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export const META_TYPES = new Set(["schema", "query", "runbook", "glossary"]);
|
|
2
|
+
|
|
3
|
+
export function proofDebtFor(node) {
|
|
4
|
+
const debt = [];
|
|
5
|
+
if (!node.source) debt.push({ severity: "high", label: "missing source" });
|
|
6
|
+
if (!node.confidence || node.confidence.toLowerCase() === "low") debt.push({ severity: "medium", label: `confidence ${node.confidence || "missing"}` });
|
|
7
|
+
if (!node.status) debt.push({ severity: "medium", label: "missing status" });
|
|
8
|
+
if (node.total <= 1 && !META_TYPES.has(node.type) && node.type !== "redirect") debt.push({ severity: "medium", label: "few trusted links" });
|
|
9
|
+
if (Date.now() - Date.parse(node.updatedAt || new Date()) > 45 * 86400000) debt.push({ severity: "low", label: "cooling knowledge" });
|
|
10
|
+
return debt;
|
|
11
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { slugify } from "../logseq/parser.mjs";
|
|
2
|
+
|
|
3
|
+
export function findNode(nodes, query) {
|
|
4
|
+
const q = slugify(query);
|
|
5
|
+
if (!q) return null;
|
|
6
|
+
return nodes.find((node) => node.id === q) || nodes.find((node) => node.name.toLowerCase().includes(q)) || null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function buildAdjacency(links) {
|
|
10
|
+
const adjacency = new Map();
|
|
11
|
+
const edgeLookup = new Map();
|
|
12
|
+
for (const link of links) {
|
|
13
|
+
if (!adjacency.has(link.source)) adjacency.set(link.source, new Set());
|
|
14
|
+
if (!adjacency.has(link.target)) adjacency.set(link.target, new Set());
|
|
15
|
+
adjacency.get(link.source).add(link.target);
|
|
16
|
+
adjacency.get(link.target).add(link.source);
|
|
17
|
+
edgeLookup.set(`${link.source}|${link.target}`, link);
|
|
18
|
+
edgeLookup.set(`${link.target}|${link.source}`, link);
|
|
19
|
+
}
|
|
20
|
+
return { adjacency, edgeLookup };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function round(value) {
|
|
24
|
+
return Math.round(value * 1000) / 1000;
|
|
25
|
+
}
|