@dotdrelle/wiki-manager 0.15.98 → 0.15.99
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 +2 -2
- package/src/agent/graph.js +7 -4
- package/src/contracts/schemas.js +8 -17
- package/src/contracts/schemas.test.js +15 -0
- package/src/core/agentEvents.js +45 -18
- package/src/core/agentEvents.test.js +40 -0
- package/src/core/buildInfo.json +2 -2
- package/src/core/mcp.js +1 -1
- package/src/core/runtimeEventAdapter.js +99 -1
- package/src/core/runtimeEventAdapter.test.js +92 -2
- package/src/core/skillCompiler.test.js +1 -1
- package/src/core/toolLoop.js +7 -1
- package/src/core/toolLoop.test.js +28 -0
- package/src/orchestrator/dispatcher.js +19 -0
- package/src/orchestrator/knowledgeSignals.js +260 -0
- package/src/orchestrator/knowledgeSignals.test.js +193 -0
- package/src/orchestrator/proactiveReviewScheduler.js +240 -0
- package/src/orchestrator/proactiveReviewScheduler.test.js +243 -0
- package/src/orchestrator/providers/deepAgentsProvider.js +134 -29
- package/src/orchestrator/providers/deepAgentsProvider.test.js +138 -3
- package/src/orchestrator/resultAggregator.js +115 -1
- package/src/orchestrator/resultAggregator.test.js +138 -0
- package/src/runtime/server.js +239 -1
- package/src/runtime/store.js +1 -1
- package/src/runtime/store.test.js +5 -1
- package/wiki-workspace +0 -1
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { closeSync, existsSync, openSync, readFileSync, readSync, readdirSync } from 'node:fs';
|
|
3
|
+
import { join, relative, sep } from 'node:path';
|
|
4
|
+
import { parse as parseYaml } from 'yaml';
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
Deterministic corpus signals: what the LIVE wiki says about itself, with no
|
|
8
|
+
model and no ingest plan in the loop.
|
|
9
|
+
|
|
10
|
+
`detectConceptSplits` only ever saw the current ingest plan, and
|
|
11
|
+
`subjectMatchInventory` builds a prompt context without producing a
|
|
12
|
+
persistent diagnosis — so two homonym leaves already written under one concept
|
|
13
|
+
folder were invisible until now. This reads the corpus on disk and reports the
|
|
14
|
+
paths, nothing else.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// Case, accents and punctuation are not meaning.
|
|
18
|
+
export function normalizeSubject(value) {
|
|
19
|
+
return String(value ?? '')
|
|
20
|
+
.normalize('NFKD')
|
|
21
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
22
|
+
.toLowerCase()
|
|
23
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
24
|
+
.replace(/^-+|-+$/g, '');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Two homonym leaves under one concept are ONE conflict. The result is bounded
|
|
29
|
+
* for display, but `total` counts the whole set and `dropped` names what the
|
|
30
|
+
* ceiling hid — a silent cap would also freeze the fingerprint below.
|
|
31
|
+
*/
|
|
32
|
+
export function detectConceptConflicts(leaves, { max = 50 } = {}) {
|
|
33
|
+
const groups = new Map();
|
|
34
|
+
for (const leaf of leaves ?? []) {
|
|
35
|
+
const concept = String(leaf?.concept ?? '').trim();
|
|
36
|
+
const subject = normalizeSubject(leaf?.subject);
|
|
37
|
+
const path = String(leaf?.path ?? '').trim();
|
|
38
|
+
if (!concept || !subject || !path) continue;
|
|
39
|
+
const key = `${concept}\u0000${subject}`;
|
|
40
|
+
const group = groups.get(key) ?? { concept, subject, paths: [] };
|
|
41
|
+
group.paths.push(path);
|
|
42
|
+
groups.set(key, group);
|
|
43
|
+
}
|
|
44
|
+
const all = [...groups.values()]
|
|
45
|
+
.map((group) => ({ ...group, paths: [...new Set(group.paths)].sort() }))
|
|
46
|
+
.filter((group) => group.paths.length > 1)
|
|
47
|
+
.sort((a, b) => a.concept.localeCompare(b.concept) || a.subject.localeCompare(b.subject));
|
|
48
|
+
const conflicts = all.slice(0, max);
|
|
49
|
+
return { conflicts, total: all.length, dropped: all.length - conflicts.length };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A stable version for the trigger. `total` participates on purpose: a 51st
|
|
54
|
+
* conflict appearing later must MOVE the fingerprint even though it is beyond
|
|
55
|
+
* the display ceiling — otherwise it would dedup as already-seen and the corpus
|
|
56
|
+
* could degrade with no review.
|
|
57
|
+
*/
|
|
58
|
+
export function conflictFingerprint(conflicts, total = Array.isArray(conflicts) ? conflicts.length : 0) {
|
|
59
|
+
const lines = (conflicts ?? [])
|
|
60
|
+
.map((conflict) => `${conflict.concept}/${conflict.subject}:${[...conflict.paths].sort().join('|')}`)
|
|
61
|
+
.sort();
|
|
62
|
+
lines.push(`total:${total}`);
|
|
63
|
+
return createHash('sha1').update(lines.join('\n')).digest('hex').slice(0, 16);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function frontmatterSubject(raw) {
|
|
67
|
+
if (!raw || !raw.startsWith('---')) return null;
|
|
68
|
+
const end = raw.indexOf('\n---', 3);
|
|
69
|
+
if (end === -1) return null;
|
|
70
|
+
try {
|
|
71
|
+
const data = parseYaml(raw.slice(3, end)) ?? {};
|
|
72
|
+
const subject = data.subject ?? data.title;
|
|
73
|
+
return subject == null ? null : String(subject);
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// The frontmatter is at the top of the file; reading a whole page to look at
|
|
80
|
+
// its header is the cost the manager cannot pay synchronously on its event
|
|
81
|
+
// loop (the same reason llm-wiki's sidebar reads a bounded head).
|
|
82
|
+
function readFileHead(filePath, maxBytes = 4_096) {
|
|
83
|
+
let handle;
|
|
84
|
+
try {
|
|
85
|
+
handle = openSync(filePath, 'r');
|
|
86
|
+
const buffer = Buffer.allocUnsafe(maxBytes);
|
|
87
|
+
const bytes = readSync(handle, buffer, 0, maxBytes, 0);
|
|
88
|
+
return buffer.toString('utf8', 0, bytes);
|
|
89
|
+
} catch {
|
|
90
|
+
return null;
|
|
91
|
+
} finally {
|
|
92
|
+
if (handle !== undefined) {
|
|
93
|
+
try { closeSync(handle); } catch { /* already gone */ }
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Every `.md` under `wiki/concepts/`, with its top-level concept folder, its
|
|
100
|
+
* subject (frontmatter `subject`, else the file name) and its path RELATIVE to
|
|
101
|
+
* `wiki/concepts/` — nested folders included, so two leaves in different
|
|
102
|
+
* sub-folders still report two DISTINCT paths.
|
|
103
|
+
*/
|
|
104
|
+
/**
|
|
105
|
+
* The engine's source registry (`.wiki/source-registry.json`), read as the
|
|
106
|
+
* stable contract it is. Unreadable or corrupt yields no sources: a failed
|
|
107
|
+
* observation never breaks the run that triggered it.
|
|
108
|
+
*/
|
|
109
|
+
export function readSourceRegistry(rootDir) {
|
|
110
|
+
let raw;
|
|
111
|
+
try {
|
|
112
|
+
raw = readFileSync(join(String(rootDir), '.wiki', 'source-registry.json'), 'utf8');
|
|
113
|
+
} catch {
|
|
114
|
+
return { sources: [] };
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
const parsed = JSON.parse(raw);
|
|
118
|
+
return { sources: Array.isArray(parsed?.sources) ? parsed.sources : [] };
|
|
119
|
+
} catch {
|
|
120
|
+
return { sources: [] };
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Every `.md` under `wiki/`, relative to the workspace — the inventory the
|
|
126
|
+
* engine's `reconcileRegistry` calls `wikiPages`. Names only, no content.
|
|
127
|
+
*/
|
|
128
|
+
export function readWikiPages(rootDir, { max = 5_000 } = {}) {
|
|
129
|
+
const base = join(String(rootDir), 'wiki');
|
|
130
|
+
const pages = [];
|
|
131
|
+
let entries;
|
|
132
|
+
try {
|
|
133
|
+
entries = readdirSync(base, { withFileTypes: true, recursive: true });
|
|
134
|
+
} catch {
|
|
135
|
+
return pages;
|
|
136
|
+
}
|
|
137
|
+
const toPosix = (value) => value.split(sep).join('/');
|
|
138
|
+
for (const entry of entries) {
|
|
139
|
+
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
|
|
140
|
+
const parent = entry.parentPath ?? entry.path;
|
|
141
|
+
if (!parent) continue;
|
|
142
|
+
const rel = toPosix(relative(base, join(parent, entry.name)));
|
|
143
|
+
if (!rel || rel.startsWith('..')) continue;
|
|
144
|
+
pages.push(`wiki/${rel}`);
|
|
145
|
+
if (pages.length >= max) break;
|
|
146
|
+
}
|
|
147
|
+
return pages.sort();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// The engine's `orphanPages` rule: a page no ACTIVE source lists among the pages
|
|
151
|
+
// it produced. A hand-written or pre-registry page is an orphan too — a
|
|
152
|
+
// provenance gap the operator is asked about, never a deletion.
|
|
153
|
+
function orphanPagesFromRegistry(registry, wikiPages) {
|
|
154
|
+
const supported = new Set(
|
|
155
|
+
(registry?.sources ?? [])
|
|
156
|
+
.filter((source) => String(source?.status ?? 'active') === 'active')
|
|
157
|
+
.flatMap((source) => (Array.isArray(source?.producedPages) ? source.producedPages.map(String) : [])),
|
|
158
|
+
);
|
|
159
|
+
return wikiPages.map(String).filter((page) => !supported.has(page)).sort();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The registry's own deterministic staleness facts, with no rule to mirror:
|
|
166
|
+
*
|
|
167
|
+
* - `aged` — an active source whose `lastIngestedAt` (the engine writes it) is
|
|
168
|
+
* older than the window. A source never ingested (`null`) is not "aging
|
|
169
|
+
* knowledge", it simply produced none;
|
|
170
|
+
* - `vanished-archive` / `vanished-page` — the registry names a path that no
|
|
171
|
+
* longer exists. Existence is existence: this is the engine's
|
|
172
|
+
* `reconcileRegistry` truth, reached without re-implementing its rules.
|
|
173
|
+
*
|
|
174
|
+
* (Orphans — a wiki page no active source backs — need the full wiki inventory
|
|
175
|
+
* and the supported-set rule; that one stays the engine's to expose.)
|
|
176
|
+
* Bounded like the conflict scan: `total` counts the whole set.
|
|
177
|
+
*/
|
|
178
|
+
export function detectStaleKnowledge(registry, {
|
|
179
|
+
rootDir = '',
|
|
180
|
+
now = Date.now(),
|
|
181
|
+
staleAfterDays = 180,
|
|
182
|
+
max = 50,
|
|
183
|
+
exists = existsSync,
|
|
184
|
+
// The `wiki/**/*.md` inventory (`readWikiPages`), when the caller has it:
|
|
185
|
+
// orphan detection is a join on the registry, nothing more.
|
|
186
|
+
wikiPages = null,
|
|
187
|
+
} = {}) {
|
|
188
|
+
const cutoff = Number(now) - staleAfterDays * DAY_MS;
|
|
189
|
+
const evidence = [];
|
|
190
|
+
// Counted per kind over the FULL set: the ceiling line must describe the
|
|
191
|
+
// facts it is capping, not lump natures into one number. "61 source(s) not
|
|
192
|
+
// re-verified" sent the reader looking for the wrong defect.
|
|
193
|
+
const counts = { aged: 0, vanishedArchive: 0, vanishedPage: 0, orphan: 0 };
|
|
194
|
+
for (const source of registry?.sources ?? []) {
|
|
195
|
+
if (String(source?.status ?? 'active') !== 'active') continue;
|
|
196
|
+
const sourceId = String(source?.sourceId ?? '');
|
|
197
|
+
const archivePath = String(source?.archivePath ?? '');
|
|
198
|
+
if (archivePath && !exists(join(String(rootDir), archivePath))) {
|
|
199
|
+
evidence.push({ kind: 'vanished-archive', sourceId, path: archivePath });
|
|
200
|
+
counts.vanishedArchive += 1;
|
|
201
|
+
}
|
|
202
|
+
for (const page of source?.producedPages ?? []) {
|
|
203
|
+
const pagePath = String(page ?? '');
|
|
204
|
+
if (pagePath && !exists(join(String(rootDir), pagePath))) {
|
|
205
|
+
evidence.push({ kind: 'vanished-page', sourceId, path: pagePath });
|
|
206
|
+
counts.vanishedPage += 1;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
const lastIngestedAt = source?.lastIngestedAt ?? null;
|
|
210
|
+
const observed = Date.parse(String(lastIngestedAt ?? ''));
|
|
211
|
+
if (Number.isFinite(observed) && observed <= cutoff) {
|
|
212
|
+
evidence.push({ kind: 'aged', sourceId, path: archivePath, lastIngestedAt });
|
|
213
|
+
counts.aged += 1;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (Array.isArray(wikiPages)) {
|
|
217
|
+
for (const page of orphanPagesFromRegistry(registry, wikiPages)) {
|
|
218
|
+
evidence.push({ kind: 'orphan', sourceId: null, path: page });
|
|
219
|
+
counts.orphan += 1;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
evidence.sort((a, b) => a.kind.localeCompare(b.kind)
|
|
223
|
+
|| a.path.localeCompare(b.path)
|
|
224
|
+
|| String(a.sourceId ?? '').localeCompare(String(b.sourceId ?? '')));
|
|
225
|
+
const stale = evidence.slice(0, max);
|
|
226
|
+
return { stale, total: evidence.length, dropped: evidence.length - stale.length, counts };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function staleFingerprint(stale, total = Array.isArray(stale) ? stale.length : 0) {
|
|
230
|
+
const lines = (stale ?? [])
|
|
231
|
+
.map((entry) => `${entry.kind}:${entry.sourceId}:${entry.path}:${entry.lastIngestedAt ?? ''}`)
|
|
232
|
+
.sort();
|
|
233
|
+
lines.push(`total:${total}`);
|
|
234
|
+
return createHash('sha1').update(lines.join('\n')).digest('hex').slice(0, 16);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function readConceptLeaves(rootDir) {
|
|
238
|
+
const base = join(String(rootDir), 'wiki', 'concepts');
|
|
239
|
+
const leaves = [];
|
|
240
|
+
let entries;
|
|
241
|
+
try {
|
|
242
|
+
entries = readdirSync(base, { withFileTypes: true, recursive: true });
|
|
243
|
+
} catch {
|
|
244
|
+
return leaves;
|
|
245
|
+
}
|
|
246
|
+
const toPosix = (value) => value.split(sep).join('/');
|
|
247
|
+
for (const entry of entries) {
|
|
248
|
+
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
|
|
249
|
+
const parent = entry.parentPath ?? entry.path;
|
|
250
|
+
if (!parent) continue;
|
|
251
|
+
const rel = toPosix(relative(base, join(parent, entry.name)));
|
|
252
|
+
if (!rel || rel.startsWith('..')) continue;
|
|
253
|
+
const concept = rel.split('/')[0];
|
|
254
|
+
if (!concept) continue;
|
|
255
|
+
const head = readFileHead(join(parent, entry.name));
|
|
256
|
+
const subject = frontmatterSubject(head) ?? entry.name.replace(/\.md$/, '');
|
|
257
|
+
leaves.push({ path: rel, concept, subject });
|
|
258
|
+
}
|
|
259
|
+
return leaves;
|
|
260
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
conflictFingerprint,
|
|
9
|
+
detectConceptConflicts,
|
|
10
|
+
detectStaleKnowledge,
|
|
11
|
+
normalizeSubject,
|
|
12
|
+
readConceptLeaves,
|
|
13
|
+
readSourceRegistry,
|
|
14
|
+
readWikiPages,
|
|
15
|
+
staleFingerprint,
|
|
16
|
+
} from './knowledgeSignals.js';
|
|
17
|
+
|
|
18
|
+
test('normalizeSubject folds case, accents and punctuation', () => {
|
|
19
|
+
assert.equal(normalizeSubject('Jedox Cloud'), 'jedox-cloud');
|
|
20
|
+
assert.equal(normalizeSubject('jedox-cloud'), 'jedox-cloud');
|
|
21
|
+
assert.equal(normalizeSubject('Souveraineté'), 'souverainete');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('two homonym leaves under one concept are one conflict, not two', () => {
|
|
25
|
+
const { conflicts, total } = detectConceptConflicts([
|
|
26
|
+
{ path: 'saas/jedox.md', concept: 'saas', subject: 'Jedox' },
|
|
27
|
+
{ path: 'saas/jedox-cloud.md', concept: 'saas', subject: 'jedox' },
|
|
28
|
+
{ path: 'cout/jedox.md', concept: 'cout', subject: 'Jedox' },
|
|
29
|
+
{ path: 'saas/anaplan.md', concept: 'saas', subject: 'Anaplan' },
|
|
30
|
+
]);
|
|
31
|
+
assert.equal(total, 1);
|
|
32
|
+
assert.deepEqual(conflicts, [
|
|
33
|
+
{ concept: 'saas', subject: 'jedox', paths: ['saas/jedox-cloud.md', 'saas/jedox.md'] },
|
|
34
|
+
]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('a conflict in nested sub-folders keeps both distinct paths', () => {
|
|
38
|
+
const { conflicts, total } = detectConceptConflicts([
|
|
39
|
+
{ path: 'saas/produits/jedox.md', concept: 'saas', subject: 'Jedox' },
|
|
40
|
+
{ path: 'saas/vendors/jedox.md', concept: 'saas', subject: 'Jedox' },
|
|
41
|
+
]);
|
|
42
|
+
assert.equal(total, 1);
|
|
43
|
+
assert.deepEqual(conflicts[0].paths, ['saas/produits/jedox.md', 'saas/vendors/jedox.md']);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('the conflict fingerprint is stable and moves past the display ceiling', () => {
|
|
47
|
+
const a = [{ concept: 'saas', subject: 'jedox', paths: ['saas/b.md', 'saas/a.md'] }];
|
|
48
|
+
const b = [{ concept: 'saas', subject: 'jedox', paths: ['saas/a.md', 'saas/b.md'] }];
|
|
49
|
+
assert.equal(conflictFingerprint(a, 1), conflictFingerprint(b, 1));
|
|
50
|
+
// A 51st conflict beyond the cap must change the version, or it dedups away.
|
|
51
|
+
assert.notEqual(conflictFingerprint(a, 50), conflictFingerprint(a, 51));
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('the ceiling reports what it dropped instead of hiding it', () => {
|
|
55
|
+
const leaves = Array.from({ length: 60 }, (_, index) => [
|
|
56
|
+
{ path: `c/s${index}-a.md`, concept: 'c', subject: `s${index}` },
|
|
57
|
+
{ path: `c/s${index}-b.md`, concept: 'c', subject: `s${index}` },
|
|
58
|
+
]).flat();
|
|
59
|
+
const { conflicts, total, dropped } = detectConceptConflicts(leaves);
|
|
60
|
+
assert.equal(conflicts.length, 50);
|
|
61
|
+
assert.equal(total, 60);
|
|
62
|
+
assert.equal(dropped, 10);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('readConceptLeaves takes the folder as the concept and the frontmatter as the subject', () => {
|
|
66
|
+
const root = mkdtempSync(join(tmpdir(), 'signals-'));
|
|
67
|
+
try {
|
|
68
|
+
mkdirSync(join(root, 'wiki', 'concepts', 'saas', 'produits'), { recursive: true });
|
|
69
|
+
mkdirSync(join(root, 'wiki', 'concepts', 'saas', 'vendors'), { recursive: true });
|
|
70
|
+
mkdirSync(join(root, 'wiki', 'concepts', 'cout'), { recursive: true });
|
|
71
|
+
writeFileSync(join(root, 'wiki', 'concepts', 'saas', 'produits', 'jedox.md'), '---\nsubject: Jedox\n---\nbody');
|
|
72
|
+
writeFileSync(join(root, 'wiki', 'concepts', 'saas', 'vendors', 'jedox.md'), '---\nsubject: Jedox\n---\nbody');
|
|
73
|
+
writeFileSync(join(root, 'wiki', 'concepts', 'cout', 'jedox.md'), 'no frontmatter\n');
|
|
74
|
+
writeFileSync(join(root, 'wiki', 'concepts', 'README.txt'), 'ignore me');
|
|
75
|
+
|
|
76
|
+
const leaves = readConceptLeaves(root);
|
|
77
|
+
assert.deepEqual(
|
|
78
|
+
leaves.map((leaf) => leaf.path).sort(),
|
|
79
|
+
['cout/jedox.md', 'saas/produits/jedox.md', 'saas/vendors/jedox.md'],
|
|
80
|
+
);
|
|
81
|
+
const { conflicts, total } = detectConceptConflicts(leaves);
|
|
82
|
+
assert.equal(total, 1);
|
|
83
|
+
assert.equal(conflicts[0].concept, 'saas');
|
|
84
|
+
assert.deepEqual(conflicts[0].paths, ['saas/produits/jedox.md', 'saas/vendors/jedox.md']);
|
|
85
|
+
} finally {
|
|
86
|
+
rmSync(root, { recursive: true, force: true });
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('a corpus without homonyms produces no signal', () => {
|
|
91
|
+
assert.equal(
|
|
92
|
+
detectConceptConflicts([
|
|
93
|
+
{ path: 'saas/a.md', concept: 'saas', subject: 'A' },
|
|
94
|
+
{ path: 'saas/b.md', concept: 'saas', subject: 'B' },
|
|
95
|
+
]).total,
|
|
96
|
+
0,
|
|
97
|
+
);
|
|
98
|
+
assert.equal(detectConceptConflicts([]).total, 0);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('stale knowledge is aged sources plus registry paths that no longer exist', () => {
|
|
102
|
+
const now = Date.parse('2026-06-01T00:00:00.000Z');
|
|
103
|
+
const recent = '2026-05-30T00:00:00.000Z';
|
|
104
|
+
const registry = {
|
|
105
|
+
sources: [
|
|
106
|
+
{ sourceId: 'a', archivePath: 'raw/ingested/a.md', status: 'active', lastIngestedAt: recent, producedPages: ['wiki/concepts/saas/a.md'] },
|
|
107
|
+
{ sourceId: 'b', archivePath: 'raw/ingested/b.md', status: 'active', lastIngestedAt: recent, producedPages: [] },
|
|
108
|
+
{ sourceId: 'e', archivePath: 'raw/ingested/e.md', status: 'active', lastIngestedAt: recent, producedPages: ['wiki/concepts/saas/e-gone.md'] },
|
|
109
|
+
{ sourceId: 'f', archivePath: 'raw/ingested/f.md', status: 'active', lastIngestedAt: '2025-01-01T00:00:00.000Z', producedPages: [] },
|
|
110
|
+
{ sourceId: 'g', archivePath: 'raw/ingested/g.md', status: 'retracted', lastIngestedAt: '2024-01-01T00:00:00.000Z', producedPages: [] },
|
|
111
|
+
{ sourceId: 'h', archivePath: 'raw/ingested/h.md', status: 'active', lastIngestedAt: null, producedPages: [] },
|
|
112
|
+
],
|
|
113
|
+
};
|
|
114
|
+
// Only b's archive and e's produced page are gone.
|
|
115
|
+
const exists = (path) => !path.includes('raw/ingested/b.md') && !path.endsWith('e-gone.md');
|
|
116
|
+
|
|
117
|
+
const { stale, total, dropped, counts } = detectStaleKnowledge(registry, {
|
|
118
|
+
rootDir: '/ws', now, staleAfterDays: 180, exists,
|
|
119
|
+
});
|
|
120
|
+
assert.equal(total, 3, 'a recent, a retracted and a never-ingested source are not stale');
|
|
121
|
+
assert.equal(dropped, 0);
|
|
122
|
+
assert.deepEqual(counts, { aged: 1, vanishedArchive: 1, vanishedPage: 1, orphan: 0 });
|
|
123
|
+
assert.deepEqual(stale.map((entry) => entry.kind), ['aged', 'vanished-archive', 'vanished-page']);
|
|
124
|
+
assert.equal(stale.find((entry) => entry.kind === 'aged').sourceId, 'f');
|
|
125
|
+
assert.equal(stale.find((entry) => entry.kind === 'vanished-archive').path, 'raw/ingested/b.md');
|
|
126
|
+
assert.equal(stale.find((entry) => entry.kind === 'vanished-page').path, 'wiki/concepts/saas/e-gone.md');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('the stale fingerprint is stable and counts the whole set', () => {
|
|
130
|
+
const a = [{ kind: 'aged', sourceId: 'b', path: 'raw/ingested/b.md', lastIngestedAt: '2025-01-01T00:00:00.000Z' }];
|
|
131
|
+
assert.equal(staleFingerprint(a, 1), staleFingerprint(a, 1));
|
|
132
|
+
assert.notEqual(staleFingerprint(a, 1), staleFingerprint(a, 2));
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('readSourceRegistry tolerates an absent or corrupt file', () => {
|
|
136
|
+
const root = mkdtempSync(join(tmpdir(), 'registry-'));
|
|
137
|
+
try {
|
|
138
|
+
assert.deepEqual(readSourceRegistry(root), { sources: [] });
|
|
139
|
+
mkdirSync(join(root, '.wiki'), { recursive: true });
|
|
140
|
+
writeFileSync(join(root, '.wiki', 'source-registry.json'), '{not json');
|
|
141
|
+
assert.deepEqual(readSourceRegistry(root), { sources: [] });
|
|
142
|
+
writeFileSync(
|
|
143
|
+
join(root, '.wiki', 'source-registry.json'),
|
|
144
|
+
JSON.stringify({ version: 1, sources: [{ sourceId: 'a' }] }),
|
|
145
|
+
);
|
|
146
|
+
assert.equal(readSourceRegistry(root).sources.length, 1);
|
|
147
|
+
} finally {
|
|
148
|
+
rmSync(root, { recursive: true, force: true });
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('a wiki page no active source backs is an orphan fact', () => {
|
|
153
|
+
const now = Date.parse('2026-06-01T00:00:00.000Z');
|
|
154
|
+
const recent = '2026-05-30T00:00:00.000Z';
|
|
155
|
+
const registry = {
|
|
156
|
+
sources: [
|
|
157
|
+
{ sourceId: 'a', archivePath: 'raw/ingested/a.md', status: 'active', lastIngestedAt: recent, producedPages: ['wiki/concepts/saas/a.md'] },
|
|
158
|
+
{ sourceId: 'b', archivePath: 'raw/ingested/b.md', status: 'retracted', lastIngestedAt: recent, producedPages: ['wiki/concepts/saas/retracted.md'] },
|
|
159
|
+
],
|
|
160
|
+
};
|
|
161
|
+
const { stale, counts } = detectStaleKnowledge(registry, {
|
|
162
|
+
rootDir: '/ws',
|
|
163
|
+
now,
|
|
164
|
+
staleAfterDays: 180,
|
|
165
|
+
exists: () => true,
|
|
166
|
+
wikiPages: ['wiki/concepts/saas/a.md', 'wiki/concepts/saas/handwritten.md', 'wiki/concepts/saas/retracted.md'],
|
|
167
|
+
});
|
|
168
|
+
// A page backed by an ACTIVE source is not an orphan; a hand-written one and
|
|
169
|
+
// one produced by a RETRACTED source are.
|
|
170
|
+
assert.deepEqual(
|
|
171
|
+
stale.filter((entry) => entry.kind === 'orphan').map((entry) => entry.path),
|
|
172
|
+
['wiki/concepts/saas/handwritten.md', 'wiki/concepts/saas/retracted.md'],
|
|
173
|
+
);
|
|
174
|
+
assert.equal(counts.orphan, 2);
|
|
175
|
+
|
|
176
|
+
// Without the inventory, no orphan kind is invented.
|
|
177
|
+
const without = detectStaleKnowledge(registry, { rootDir: '/ws', now, exists: () => true });
|
|
178
|
+
assert.equal(without.stale.some((entry) => entry.kind === 'orphan'), false);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('readWikiPages inventories wiki/**/*.md, names only', () => {
|
|
182
|
+
const root = mkdtempSync(join(tmpdir(), 'wikipages-'));
|
|
183
|
+
try {
|
|
184
|
+
mkdirSync(join(root, 'wiki', 'concepts', 'saas'), { recursive: true });
|
|
185
|
+
mkdirSync(join(root, 'wiki', 'answers'), { recursive: true });
|
|
186
|
+
writeFileSync(join(root, 'wiki', 'concepts', 'saas', 'a.md'), 'x');
|
|
187
|
+
writeFileSync(join(root, 'wiki', 'answers', 'b.md'), 'x');
|
|
188
|
+
writeFileSync(join(root, 'wiki', 'notes.txt'), 'x');
|
|
189
|
+
assert.deepEqual(readWikiPages(root), ['wiki/answers/b.md', 'wiki/concepts/saas/a.md']);
|
|
190
|
+
} finally {
|
|
191
|
+
rmSync(root, { recursive: true, force: true });
|
|
192
|
+
}
|
|
193
|
+
});
|