@stravica-ai/digital-operator-pa 0.1.7 → 0.1.10

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.
@@ -1,186 +1,260 @@
1
1
  #!/usr/bin/env node
2
2
  // graph-builder.mjs — Builds a knowledge graph from forward links and backlinks.
3
3
  //
4
- // Reads forward links from markdown files + backlinks from .backlinks.json.
4
+ // Reads forward links from markdown files, machine-readable index.json hubs,
5
+ // and backlinks from .backlinks.json.
5
6
  // Output: operator/knowledge/.graph.json
6
7
  //
7
8
  // CLI: node graph-builder.mjs [--root /path/to/repo]
9
+ //
10
+ // Link extraction and resolution live in ./links.mjs, shared with
11
+ // backlink-indexer.mjs — see that file for the resolution contract.
12
+ //
13
+ // LIBRARY + CLI, same split as backlink-indexer.mjs: `buildKnowledgeGraph()` is
14
+ // the importable body, `main()` is the CLI face, and the top-level run is
15
+ // behind a directly-invoked guard.
8
16
 
9
- import { readdir, readFile, writeFile } from 'node:fs/promises';
10
- import { join, resolve, dirname, relative, normalize } from 'node:path';
17
+ import { readFile } from 'node:fs/promises';
18
+ import { realpathSync } from 'node:fs';
19
+ import { join, resolve, dirname, relative } from 'node:path';
11
20
  import { fileURLToPath } from 'node:url';
21
+ import {
22
+ buildFileIndex,
23
+ buildIndexHubs,
24
+ deriveDomain,
25
+ extractLinks,
26
+ isIndexJson,
27
+ scanDirsFor,
28
+ toPosix,
29
+ walkFiles,
30
+ } from './links.mjs';
31
+ import { writeJsonAtomic } from './write-atomic.mjs';
12
32
 
13
33
  const __dirname = dirname(fileURLToPath(import.meta.url));
14
34
  const DEFAULT_ROOT = resolve(__dirname, '../../../..');
15
35
 
16
36
  const USAGE = `
17
- graph-builder — Build a knowledge graph from markdown links and backlinks
37
+ graph-builder — Build a knowledge graph from markdown links, index hubs and backlinks
18
38
 
19
39
  Usage:
20
40
  node graph-builder.mjs [options]
21
41
 
22
42
  Options:
23
43
  --root <path> Repository root (default: 4 levels up from this script)
44
+ --quiet Suppress the per-link diagnostics report
24
45
  --help, -h Show this help
25
46
 
26
47
  Reads forward links from .md files in operator/knowledge/ and core/knowledge/.
48
+ Reads document references from every index.json under those trees.
27
49
  Reads backlinks from operator/knowledge/.backlinks.json.
28
50
 
29
51
  Output: operator/knowledge/.graph.json
30
52
  `.trim();
31
53
 
32
- // ── Helpers ─────────────────────────────────────────────────────────
33
-
34
- async function walkDir(dir) {
35
- const results = [];
36
- let entries;
37
- try {
38
- entries = await readdir(dir, { withFileTypes: true });
39
- } catch (err) {
40
- if (err.code === 'ENOENT') return results;
41
- throw err;
42
- }
43
- for (const entry of entries) {
44
- const full = join(dir, entry.name);
45
- if (entry.isDirectory()) {
46
- if (entry.name.startsWith('.')) continue;
47
- results.push(...await walkDir(full));
48
- } else if (entry.isFile() && entry.name.endsWith('.md')) {
49
- results.push(full);
50
- }
51
- }
52
- return results;
53
- }
54
-
55
54
  function extractTitle(content) {
56
55
  const match = content.match(/^#\s+(.+)$/m);
57
56
  return match ? match[1].trim() : null;
58
57
  }
59
58
 
60
- function extractForwardLinks(content, sourcePath, repoRoot) {
61
- const links = [];
62
- const sourceDir = dirname(sourcePath);
63
-
64
- // Wiki-style: [[target]]
65
- const wikiRe = /\[\[([^\]]+)\]\]/g;
66
- let m;
67
- while ((m = wikiRe.exec(content)) !== null) {
68
- let target = m[1].trim();
69
- if (!target.endsWith('.md')) target += '.md';
70
- links.push(normalize(target));
71
- }
72
-
73
- // Markdown-style: [text](relative/path.md)
74
- const mdRe = /\[([^\]]*)\]\(([^)]+)\)/g;
75
- while ((m = mdRe.exec(content)) !== null) {
76
- const href = m[2].trim();
77
- if (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('#') || href.startsWith('mailto:')) continue;
78
- const path = href.split('#')[0];
79
- if (!path || !path.endsWith('.md')) continue;
80
- const abs = resolve(sourceDir, path);
81
- const rel = relative(repoRoot, abs);
82
- links.push(normalize(rel));
83
- }
84
-
85
- return [...new Set(links)];
59
+ function stubNode(id) {
60
+ return { id, title: id.split('/').pop().replace('.md', ''), domain: deriveDomain(id) };
86
61
  }
87
62
 
88
- function deriveDomain(relPath) {
89
- const parts = relPath.split('/');
90
- if (parts.length >= 3 && (parts[0] === 'operator' || parts[0] === 'core') && parts[1] === 'knowledge') {
91
- return parts[2];
63
+ function reportUnresolved(label, entries) {
64
+ if (entries.length === 0) return;
65
+ const byReason = new Map();
66
+ for (const e of entries) byReason.set(e.reason, (byReason.get(e.reason) ?? 0) + 1);
67
+ const summary = [...byReason.entries()].map(([r, n]) => `${r}=${n}`).join(' ');
68
+ console.warn(`${label}: ${entries.length} unresolved (${summary})`);
69
+ for (const e of entries) {
70
+ const extra = e.candidates ? ` [candidates: ${e.candidates.join(', ')}]` : '';
71
+ console.warn(` ${e.reason}: ${e.source} -> ${e.target}${extra}`);
92
72
  }
93
- return null;
94
73
  }
95
74
 
96
- // ── Main ────────────────────────────────────────────────────────────
97
-
98
- async function main(args) {
99
- let root = DEFAULT_ROOT;
100
-
101
- for (let i = 0; i < args.length; i++) {
102
- const a = args[i];
103
- if (a === '--help' || a === '-h') {
104
- console.log(USAGE);
105
- return 0;
106
- }
107
- if (a === '--root') {
108
- root = resolve(args[++i]);
109
- }
110
- }
111
-
112
- const scanDirs = [
113
- join(root, 'operator', 'knowledge'),
114
- join(root, 'core', 'knowledge'),
115
- ];
75
+ /**
76
+ * Build the knowledge graph over `root` and write
77
+ * `operator/knowledge/.graph.json`.
78
+ *
79
+ * Depends on `.backlinks.json` already being current -- run
80
+ * `buildBacklinkIndex()` first. Building the graph over a stale backlinks file
81
+ * silently produces a graph whose backlink edges describe the PREVIOUS tree.
82
+ *
83
+ * @param {object} args
84
+ * @param {string} args.root workspace root the knowledge tree hangs off
85
+ * @param {object|null} [args.coverage] optional provenance stamp merged into
86
+ * the artefact as `coverage` (see backlink-indexer.mjs).
87
+ * @returns {Promise<object>} outPath, counts, diagnostics and the unresolved
88
+ * entries the CLI reports
89
+ */
90
+ export async function buildKnowledgeGraph({ root, coverage = null }) {
91
+ const scanDirs = scanDirsFor(root);
116
92
 
93
+ // ── Pass 1: enumerate the tree ────────────────────────────────────────
94
+ // Resolution needs the whole file set before any link is interpreted —
95
+ // `[[slug]]` cannot be resolved without knowing which files exist.
117
96
  const allFiles = [];
97
+ const indexPaths = [];
118
98
  for (const dir of scanDirs) {
119
- allFiles.push(...await walkDir(dir));
99
+ allFiles.push(...await walkFiles(dir));
100
+ indexPaths.push(...await walkFiles(dir, isIndexJson));
120
101
  }
102
+ const fileIds = allFiles.map((f) => toPosix(relative(root, f)));
103
+ const fileIndex = buildFileIndex(fileIds);
121
104
 
105
+ // ── Pass 2: nodes + forward edges ─────────────────────────────────────
122
106
  const nodesMap = new Map();
123
107
  const edges = [];
124
108
  const edgeSet = new Set();
109
+ const unresolvedLinks = [];
110
+
111
+ const addEdge = (from, to, type) => {
112
+ const edgeKey = `${type}:${from}->${to}`;
113
+ if (edgeSet.has(edgeKey)) return;
114
+ edgeSet.add(edgeKey);
115
+ edges.push({ from, to, type });
116
+ };
125
117
 
126
- for (const filePath of allFiles) {
118
+ for (let i = 0; i < allFiles.length; i++) {
119
+ const filePath = allFiles[i];
120
+ const relPath = fileIds[i];
127
121
  const content = await readFile(filePath, 'utf8');
128
- const relPath = relative(root, filePath);
129
122
  const title = extractTitle(content) || relPath.split('/').pop().replace('.md', '');
130
- const domain = deriveDomain(relPath);
131
123
 
132
- nodesMap.set(relPath, { id: relPath, title, domain });
124
+ nodesMap.set(relPath, { id: relPath, title, domain: deriveDomain(relPath) });
133
125
 
134
- const forwardLinks = extractForwardLinks(content, filePath, root);
135
- for (const target of forwardLinks) {
136
- const edgeKey = `link:${relPath}->${target}`;
137
- if (!edgeSet.has(edgeKey)) {
138
- edgeSet.add(edgeKey);
139
- edges.push({ from: relPath, to: target, type: 'link' });
140
- }
126
+ const { links, unresolved } = extractLinks(content, relPath, fileIndex);
127
+ unresolvedLinks.push(...unresolved);
128
+ for (const target of links) addEdge(relPath, target, 'link');
129
+ }
130
+
131
+ // ── index.json hubs ───────────────────────────────────────────────────
132
+ const indexFiles = [];
133
+ for (const indexPath of indexPaths) {
134
+ const id = toPosix(relative(root, indexPath));
135
+ let data;
136
+ try {
137
+ data = JSON.parse(await readFile(indexPath, 'utf8'));
138
+ } catch (err) {
139
+ console.warn(`Skipping unparseable index: ${id} (${err.message})`);
140
+ continue;
141
141
  }
142
+ indexFiles.push({ id, data });
142
143
  }
144
+ const hubs = buildIndexHubs(indexFiles, fileIndex);
145
+ for (const node of hubs.nodes) nodesMap.set(node.id, node);
146
+ for (const edge of hubs.edges) addEdge(edge.from, edge.to, edge.type);
143
147
 
144
- // Read backlinks and add backlink edges
148
+ // ── Backlink edges (reversed: target -> source) ────────────────────────
145
149
  const backlinksPath = join(root, 'operator', 'knowledge', '.backlinks.json');
150
+ let backlinksMissing = false;
146
151
  try {
147
152
  const raw = await readFile(backlinksPath, 'utf8');
148
153
  const backlinksData = JSON.parse(raw);
149
154
  const bl = backlinksData.backlinks || {};
150
155
  for (const [target, sources] of Object.entries(bl)) {
151
156
  for (const source of sources) {
152
- const edgeKey = `backlink:${target}->${source}`;
153
- if (!edgeSet.has(edgeKey)) {
154
- edgeSet.add(edgeKey);
155
- edges.push({ from: target, to: source, type: 'backlink' });
156
- }
157
- if (!nodesMap.has(target)) {
158
- nodesMap.set(target, { id: target, title: target.split('/').pop().replace('.md', ''), domain: deriveDomain(target) });
159
- }
160
- if (!nodesMap.has(source)) {
161
- nodesMap.set(source, { id: source, title: source.split('/').pop().replace('.md', ''), domain: deriveDomain(source) });
162
- }
157
+ addEdge(target, source, 'backlink');
158
+ if (!nodesMap.has(target)) nodesMap.set(target, stubNode(target));
159
+ if (!nodesMap.has(source)) nodesMap.set(source, stubNode(source));
163
160
  }
164
161
  }
165
162
  } catch (err) {
166
163
  if (err.code !== 'ENOENT') throw err;
167
- console.warn('No .backlinks.json found — run backlink-indexer.mjs first. Building graph from forward links only.');
164
+ backlinksMissing = true;
165
+ }
166
+
167
+ // Markdown links to files that do not exist on this host still become stub
168
+ // nodes — that is how a doc present in one entitlement tier and absent from
169
+ // the build host stays visible. Wiki links do not: an unresolved `[[slug]]`
170
+ // is a dangling reference, not a missing file.
171
+ for (const edge of edges) {
172
+ if (edge.type !== 'link') continue;
173
+ if (!nodesMap.has(edge.to)) nodesMap.set(edge.to, stubNode(edge.to));
168
174
  }
169
175
 
170
176
  const nodes = [...nodesMap.values()].sort((a, b) => a.id.localeCompare(b.id));
177
+ edges.sort((a, b) => a.type.localeCompare(b.type) || a.from.localeCompare(b.from) || a.to.localeCompare(b.to));
171
178
 
172
179
  const output = {
173
180
  version: '1.0',
174
181
  generatedAt: new Date().toISOString(),
182
+ diagnostics: {
183
+ markdownFiles: allFiles.length,
184
+ indexHubs: {
185
+ files: indexFiles.length,
186
+ entries: hubs.entryCount,
187
+ resolved: hubs.resolvedCount,
188
+ unresolved: hubs.unresolved.length,
189
+ },
190
+ wikiLinks: {
191
+ unresolved: unresolvedLinks.length,
192
+ },
193
+ },
175
194
  nodes,
176
195
  edges,
177
196
  };
197
+ if (coverage) output.coverage = coverage;
178
198
 
179
199
  const outPath = join(root, 'operator', 'knowledge', '.graph.json');
180
- await writeFile(outPath, JSON.stringify(output, null, 2) + '\n', 'utf8');
181
- console.log(`Wrote ${outPath} (${nodes.length} nodes, ${edges.length} edges)`);
200
+ await writeJsonAtomic(outPath, output);
201
+ return {
202
+ outPath,
203
+ nodeCount: nodes.length,
204
+ edgeCount: edges.length,
205
+ backlinksMissing,
206
+ indexFileCount: indexFiles.length,
207
+ hubEntryCount: hubs.entryCount,
208
+ hubResolvedCount: hubs.resolvedCount,
209
+ unresolvedLinks,
210
+ unresolvedIndexEntries: hubs.unresolved,
211
+ diagnostics: output.diagnostics,
212
+ };
213
+ }
214
+
215
+ async function main(args) {
216
+ let root = DEFAULT_ROOT;
217
+ let quiet = false;
218
+
219
+ for (let i = 0; i < args.length; i++) {
220
+ const a = args[i];
221
+ if (a === '--help' || a === '-h') {
222
+ console.log(USAGE);
223
+ return 0;
224
+ }
225
+ if (a === '--quiet') quiet = true;
226
+ if (a === '--root') {
227
+ root = resolve(args[++i]);
228
+ }
229
+ }
230
+
231
+ const result = await buildKnowledgeGraph({ root });
232
+
233
+ if (result.backlinksMissing) {
234
+ console.warn('No .backlinks.json found — run backlink-indexer.mjs first. Building graph from forward links only.');
235
+ }
236
+ console.log(`Wrote ${result.outPath} (${result.nodeCount} nodes, ${result.edgeCount} edges)`);
237
+ console.log(
238
+ `Index hubs: ${result.indexFileCount} files, ${result.hubResolvedCount}/${result.hubEntryCount} entries resolved`,
239
+ );
240
+ if (!quiet) {
241
+ reportUnresolved('Wiki links', result.unresolvedLinks);
242
+ reportUnresolved('Index entries', result.unresolvedIndexEntries);
243
+ }
182
244
  return 0;
183
245
  }
184
246
 
185
- const exitCode = await main(process.argv.slice(2));
186
- process.exit(exitCode ?? 0);
247
+ // See backlink-indexer.mjs for why both sides are realpath'd.
248
+ function isDirectlyInvoked() {
249
+ if (!process.argv[1]) return false;
250
+ try {
251
+ return fileURLToPath(import.meta.url) === realpathSync(resolve(process.argv[1]));
252
+ } catch {
253
+ return false;
254
+ }
255
+ }
256
+
257
+ if (isDirectlyInvoked()) {
258
+ const exitCode = await main(process.argv.slice(2));
259
+ process.exit(exitCode ?? 0);
260
+ }