project-librarian 0.2.1 → 0.4.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/README.ko.md +261 -93
- package/README.md +190 -76
- package/SKILL.md +16 -10
- package/dist/args.js +25 -2
- package/dist/code-index-file-policy.js +105 -2
- package/dist/code-index.js +338 -70
- package/dist/hooks.js +78 -0
- package/dist/init-project-wiki.js +265 -167
- package/dist/install-skill.js +2 -9
- package/dist/mcp-server.js +709 -0
- package/dist/migration.js +875 -60
- package/dist/modes.js +262 -62
- package/dist/retrieval-eval.js +68 -0
- package/dist/taxonomy.js +193 -0
- package/dist/templates.js +214 -36
- package/dist/wiki-concepts.js +49 -0
- package/dist/wiki-files.js +120 -27
- package/dist/wiki-graph.js +166 -0
- package/dist/wiki-visualizer.js +558 -0
- package/package.json +5 -4
- package/README.ja.md +0 -215
- package/README.zh.md +0 -215
package/dist/wiki-files.js
CHANGED
|
@@ -37,6 +37,8 @@ exports.ignoredDirs = exports.standardWikiFiles = void 0;
|
|
|
37
37
|
exports.walkMarkdownFiles = walkMarkdownFiles;
|
|
38
38
|
exports.firstHeading = firstHeading;
|
|
39
39
|
exports.compactSummary = compactSummary;
|
|
40
|
+
exports.markdownBlockSnippet = markdownBlockSnippet;
|
|
41
|
+
exports.extractMarkdownBlocks = extractMarkdownBlocks;
|
|
40
42
|
exports.splitMarkdownRow = splitMarkdownRow;
|
|
41
43
|
exports.parseMarkdownTableRows = parseMarkdownTableRows;
|
|
42
44
|
exports.wikiMarkdownFiles = wikiMarkdownFiles;
|
|
@@ -46,10 +48,9 @@ exports.extractWikiLinks = extractWikiLinks;
|
|
|
46
48
|
exports.wikiTitleForFile = wikiTitleForFile;
|
|
47
49
|
exports.metadataSummary = metadataSummary;
|
|
48
50
|
exports.stripMarkedSection = stripMarkedSection;
|
|
49
|
-
exports.extractMarkedSection = extractMarkedSection;
|
|
50
|
-
exports.withPreservedMarkedSections = withPreservedMarkedSections;
|
|
51
51
|
exports.hasGlossaryNeedSignal = hasGlossaryNeedSignal;
|
|
52
52
|
exports.hasGlossaryTable = hasGlossaryTable;
|
|
53
|
+
exports.firstTldrBullet = firstTldrBullet;
|
|
53
54
|
exports.canonicalBodyForLint = canonicalBodyForLint;
|
|
54
55
|
const fs = __importStar(require("node:fs"));
|
|
55
56
|
const path = __importStar(require("node:path"));
|
|
@@ -75,24 +76,22 @@ exports.standardWikiFiles = new Set([
|
|
|
75
76
|
"wiki/index.md",
|
|
76
77
|
"wiki/inbox/project-candidates.md",
|
|
77
78
|
"wiki/migration/inventory.md",
|
|
79
|
+
"wiki/migration/unit-map.md",
|
|
80
|
+
"wiki/migration/split-plan.md",
|
|
78
81
|
"wiki/migration/coverage.md",
|
|
79
82
|
"wiki/migration/plan.md",
|
|
80
83
|
"wiki/migration/review.md",
|
|
81
84
|
"wiki/migration/verification.md",
|
|
82
|
-
"wiki/
|
|
85
|
+
"wiki/migration/bulk-review.md",
|
|
83
86
|
"wiki/canonical/glossary.md",
|
|
84
|
-
"wiki/canonical/open-questions.md",
|
|
85
|
-
"wiki/canonical/assumptions.md",
|
|
86
|
-
"wiki/canonical/risks.md",
|
|
87
87
|
"wiki/canonical/migration-inbox.md",
|
|
88
88
|
"wiki/decisions/README.md",
|
|
89
89
|
"wiki/decisions/log.md",
|
|
90
90
|
"wiki/decisions/recent.md",
|
|
91
|
-
"wiki/decisions/decision-pack-template.md",
|
|
92
|
-
"wiki/decisions/full-adr-template.md",
|
|
93
91
|
"wiki/decisions/migration-inbox.md",
|
|
94
92
|
"wiki/meta/operating-model.md",
|
|
95
93
|
"wiki/meta/decision-policy.md",
|
|
94
|
+
"wiki/meta/document-taxonomy.md",
|
|
96
95
|
"wiki/meta/wiki-ops-v1-decisions.md",
|
|
97
96
|
"wiki/sources/karpathy-llm-wiki.md",
|
|
98
97
|
"wiki/sources/migration-inbox.md",
|
|
@@ -137,6 +136,109 @@ function compactSummary(text) {
|
|
|
137
136
|
.trim()
|
|
138
137
|
.slice(0, 180);
|
|
139
138
|
}
|
|
139
|
+
function slugForBlockId(value) {
|
|
140
|
+
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 48) || "block";
|
|
141
|
+
}
|
|
142
|
+
function isMarkdownHeading(line) {
|
|
143
|
+
return line.match(/^(#{1,6})\s+(.+?)\s*#*\s*$/);
|
|
144
|
+
}
|
|
145
|
+
function isMarkdownTableSeparator(cells) {
|
|
146
|
+
return cells.length > 0 && cells.every((cell) => /^:?-{3,}:?$/.test(cell.replace(/\s/g, "")));
|
|
147
|
+
}
|
|
148
|
+
function normalizeBlockText(text) {
|
|
149
|
+
return text.replace(/\s+/g, " ").trim();
|
|
150
|
+
}
|
|
151
|
+
function blockId(kind, line, text) {
|
|
152
|
+
return `${kind}:${line}:${slugForBlockId(text)}`;
|
|
153
|
+
}
|
|
154
|
+
function markdownBlockSnippet(block, maxLength = 180) {
|
|
155
|
+
const prefix = block.headingPath.length > 0 && block.kind !== "heading" ? `${block.headingPath.join(" > ")}: ` : "";
|
|
156
|
+
return `${prefix}${normalizeBlockText(block.text)}`.slice(0, maxLength);
|
|
157
|
+
}
|
|
158
|
+
function extractMarkdownBlocks(text) {
|
|
159
|
+
const body = (0, workspace_1.stripMetadataHeader)(text);
|
|
160
|
+
const lines = body.split(/\r?\n/);
|
|
161
|
+
const blocks = [];
|
|
162
|
+
const headingPath = [];
|
|
163
|
+
let paragraph = null;
|
|
164
|
+
let fence = null;
|
|
165
|
+
function addBlock(kind, line, blockText) {
|
|
166
|
+
const normalized = normalizeBlockText(blockText);
|
|
167
|
+
if (!normalized)
|
|
168
|
+
return;
|
|
169
|
+
blocks.push({
|
|
170
|
+
headingPath: [...headingPath],
|
|
171
|
+
id: blockId(kind, line, normalized),
|
|
172
|
+
kind,
|
|
173
|
+
line,
|
|
174
|
+
text: normalized,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
function flushParagraph() {
|
|
178
|
+
if (!paragraph)
|
|
179
|
+
return;
|
|
180
|
+
addBlock("paragraph", paragraph.line, paragraph.lines.join(" "));
|
|
181
|
+
paragraph = null;
|
|
182
|
+
}
|
|
183
|
+
lines.forEach((line, index) => {
|
|
184
|
+
const lineNumber = index + 1;
|
|
185
|
+
const trimmed = line.trim();
|
|
186
|
+
const fenceMatch = trimmed.match(/^(```+|~~~+)\s*(.*)$/);
|
|
187
|
+
if (fence) {
|
|
188
|
+
const closingFence = fenceMatch?.[1] ?? "";
|
|
189
|
+
if (closingFence.startsWith(fence.fence.slice(0, 3)) && closingFence.length >= fence.fence.length) {
|
|
190
|
+
const sample = fence.lines.map((item) => item.trim()).filter(Boolean).slice(0, 3).join(" ");
|
|
191
|
+
addBlock("code_fence", fence.line, `code fence${fence.lang ? ` ${fence.lang}` : ""}: ${sample}`);
|
|
192
|
+
fence = null;
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
fence.lines.push(line);
|
|
196
|
+
}
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (fenceMatch) {
|
|
200
|
+
flushParagraph();
|
|
201
|
+
fence = { fence: fenceMatch[1] ?? "```", lang: (fenceMatch[2] ?? "").trim(), line: lineNumber, lines: [] };
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (!trimmed) {
|
|
205
|
+
flushParagraph();
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
const heading = isMarkdownHeading(line);
|
|
209
|
+
if (heading?.[1] && heading[2]) {
|
|
210
|
+
flushParagraph();
|
|
211
|
+
const level = heading[1].length;
|
|
212
|
+
const title = heading[2].trim();
|
|
213
|
+
headingPath.splice(level - 1);
|
|
214
|
+
headingPath[level - 1] = title;
|
|
215
|
+
addBlock("heading", lineNumber, title);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (/^\s{0,3}([-*+]|\d+\.)\s+\S/.test(line)) {
|
|
219
|
+
flushParagraph();
|
|
220
|
+
addBlock("list_item", lineNumber, trimmed);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (/^\|.+\|$/.test(trimmed)) {
|
|
224
|
+
flushParagraph();
|
|
225
|
+
const cells = splitMarkdownRow(line);
|
|
226
|
+
if (!isMarkdownTableSeparator(cells))
|
|
227
|
+
addBlock("table_row", lineNumber, cells.join(" | "));
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (!paragraph)
|
|
231
|
+
paragraph = { line: lineNumber, lines: [] };
|
|
232
|
+
paragraph.lines.push(trimmed);
|
|
233
|
+
});
|
|
234
|
+
const unclosedFence = fence;
|
|
235
|
+
if (unclosedFence) {
|
|
236
|
+
const sample = unclosedFence.lines.map((item) => item.trim()).filter(Boolean).slice(0, 3).join(" ");
|
|
237
|
+
addBlock("code_fence", unclosedFence.line, `code fence${unclosedFence.lang ? ` ${unclosedFence.lang}` : ""}: ${sample}`);
|
|
238
|
+
}
|
|
239
|
+
flushParagraph();
|
|
240
|
+
return blocks;
|
|
241
|
+
}
|
|
140
242
|
function splitMarkdownRow(line) {
|
|
141
243
|
const trimmed = line.trim();
|
|
142
244
|
const row = trimmed.replace(/^\|/, "").replace(/\|$/, "");
|
|
@@ -264,25 +366,6 @@ function stripMarkedSection(text, startMarker, endMarker) {
|
|
|
264
366
|
return text;
|
|
265
367
|
return `${text.slice(0, start).trimEnd()}\n\n${text.slice(end + endMarker.length).trimStart()}`.trim() + "\n";
|
|
266
368
|
}
|
|
267
|
-
function extractMarkedSection(text, startMarker, endMarker) {
|
|
268
|
-
const start = text.indexOf(startMarker);
|
|
269
|
-
const end = text.indexOf(endMarker);
|
|
270
|
-
if (start < 0 || end <= start)
|
|
271
|
-
return "";
|
|
272
|
-
return text.slice(start, end + endMarker.length).trim();
|
|
273
|
-
}
|
|
274
|
-
function withPreservedMarkedSections(relativePath, base, markerPairs) {
|
|
275
|
-
if (!(0, workspace_1.exists)(relativePath))
|
|
276
|
-
return base;
|
|
277
|
-
const current = (0, workspace_1.read)(relativePath);
|
|
278
|
-
const preserved = markerPairs
|
|
279
|
-
.map(([startMarker, endMarker]) => extractMarkedSection(current, startMarker, endMarker))
|
|
280
|
-
.filter(Boolean)
|
|
281
|
-
.filter((section) => !base.includes(section));
|
|
282
|
-
if (preserved.length === 0)
|
|
283
|
-
return base;
|
|
284
|
-
return `${base.trimEnd()}\n\n${preserved.join("\n\n")}\n`;
|
|
285
|
-
}
|
|
286
369
|
function hasGlossaryNeedSignal(text) {
|
|
287
370
|
return /(^|\n)##\s+(Glossary|Terms|Roles|Entities|Data Model|State Model|Permissions|Events|용어|역할|엔티티|상태 모델|권한|이벤트)(\s|$)|`[^`]+`\s*(term|role|state|permission|event|entity|API|DB|UI|용어|역할|상태|권한|이벤트|엔티티)/i.test(text);
|
|
288
371
|
}
|
|
@@ -290,6 +373,16 @@ function hasGlossaryTable(text) {
|
|
|
290
373
|
const body = (0, workspace_1.stripMetadataHeader)(text);
|
|
291
374
|
return /\|\s*Term\s*\|\s*Definition\s*\|\s*Avoid\s*\|\s*Related Canonical Doc\s*\|\s*Status\s*\|/.test(body);
|
|
292
375
|
}
|
|
376
|
+
// First "## TL;DR" bullet for answer-shaped query envelopes: gives an agent the
|
|
377
|
+
// page's one-line summary without opening the page. Pages without a TL;DR section
|
|
378
|
+
// return "" and the envelope simply omits the line — quality-check separately
|
|
379
|
+
// flags the missing TL;DR, so this is optional enrichment, not a fallback path.
|
|
380
|
+
function firstTldrBullet(text) {
|
|
381
|
+
const body = (0, workspace_1.stripMetadataHeader)(text);
|
|
382
|
+
const match = body.match(/^##\s+TL;DR[^\n]*\n([\s\S]*?)(?=\n##\s|(?![\s\S]))/m);
|
|
383
|
+
const bullet = match?.[1]?.split(/\r?\n/).find((line) => /^\s*-\s+\S/.test(line));
|
|
384
|
+
return bullet ? bullet.replace(/^\s*-\s*/, "").trim().slice(0, 160) : "";
|
|
385
|
+
}
|
|
293
386
|
function canonicalBodyForLint() {
|
|
294
387
|
return (0, workspace_1.walkFilesUnder)("wiki/canonical", (file) => /\.(md|mdx)$/i.test(file) && file !== "wiki/canonical/glossary.md")
|
|
295
388
|
.map((file) => (0, workspace_1.stripMetadataHeader)((0, workspace_1.read)(file)))
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.wikiAnswerTruncationNotice = exports.wikiAnswerCharCap = exports.wikiRouterExemptPages = exports.wikiRouterDepthBudget = exports.wikiRouterRoot = void 0;
|
|
4
|
+
exports.finalizeWikiAnswer = finalizeWikiAnswer;
|
|
5
|
+
exports.buildWikiGraph = buildWikiGraph;
|
|
6
|
+
exports.wikiRouterDepths = wikiRouterDepths;
|
|
7
|
+
exports.wikiQueryGraphEvidence = wikiQueryGraphEvidence;
|
|
8
|
+
exports.wikiImpactAnswer = wikiImpactAnswer;
|
|
9
|
+
const wiki_files_1 = require("./wiki-files");
|
|
10
|
+
const workspace_1 = require("./workspace");
|
|
11
|
+
// Router reachability budget. The benchmark fixture A1 assert guarantees
|
|
12
|
+
// startup -> index -> answer page within two hops; real wikis add one hop for
|
|
13
|
+
// generated scoped routers (startup -> index -> wiki/indexes/auto-*.md -> page),
|
|
14
|
+
// so the real-wiki budget is three hops from wiki/startup.md.
|
|
15
|
+
exports.wikiRouterRoot = "wiki/startup.md";
|
|
16
|
+
exports.wikiRouterDepthBudget = 3;
|
|
17
|
+
// startup is the BFS root; README is a human entry document that is deliberately
|
|
18
|
+
// unrouted (the same exemption the orphan-page rule uses).
|
|
19
|
+
exports.wikiRouterExemptPages = new Set([exports.wikiRouterRoot, "wiki/README.md"]);
|
|
20
|
+
// Answer-shape discipline for wiki-side query/impact output: answer-first text,
|
|
21
|
+
// hard char cap, explicit truncation notice (never silent). Mirrors the MCP
|
|
22
|
+
// server constants (src/mcp-server.ts MAX_RESPONSE_CHARS / TRUNCATION_NOTICE);
|
|
23
|
+
// kept separate so the MCP server module and its node:sqlite loading path stay
|
|
24
|
+
// out of the bootstrap/diagnostics path.
|
|
25
|
+
exports.wikiAnswerCharCap = 4000;
|
|
26
|
+
exports.wikiAnswerTruncationNotice = "[truncated — refine the query]";
|
|
27
|
+
function finalizeWikiAnswer(body) {
|
|
28
|
+
if (body.length <= exports.wikiAnswerCharCap)
|
|
29
|
+
return body;
|
|
30
|
+
const budget = exports.wikiAnswerCharCap - exports.wikiAnswerTruncationNotice.length - 1;
|
|
31
|
+
return `${body.slice(0, budget > 0 ? budget : 0).trimEnd()}\n${exports.wikiAnswerTruncationNotice}`;
|
|
32
|
+
}
|
|
33
|
+
// decision_ref is frontmatter, not a wiki link, so the link extractor never sees
|
|
34
|
+
// it; normalize it here into a page edge. "none"/"-" are the documented empty
|
|
35
|
+
// markers in generated metadata headers.
|
|
36
|
+
function normalizedDecisionRef(file, value) {
|
|
37
|
+
const trimmed = value.trim();
|
|
38
|
+
if (!trimmed || trimmed === "none" || trimmed === "-")
|
|
39
|
+
return "";
|
|
40
|
+
return (0, wiki_files_1.normalizeWikiLinkTarget)(file, trimmed.replace(/^\[\[|\]\]$/g, ""));
|
|
41
|
+
}
|
|
42
|
+
function buildWikiGraph(pages) {
|
|
43
|
+
const files = new Set(pages.map((page) => page.file));
|
|
44
|
+
const links = [];
|
|
45
|
+
const incomingLinks = new Map();
|
|
46
|
+
const outgoingLinks = new Map();
|
|
47
|
+
const incomingDecisionRefs = new Map();
|
|
48
|
+
const outgoingDecisionRef = new Map();
|
|
49
|
+
for (const page of pages) {
|
|
50
|
+
for (const link of (0, wiki_files_1.extractWikiLinks)(page.file, page.text)) {
|
|
51
|
+
links.push(link);
|
|
52
|
+
outgoingLinks.set(page.file, [...(outgoingLinks.get(page.file) ?? []), link]);
|
|
53
|
+
incomingLinks.set(link.normalizedTarget, [...(incomingLinks.get(link.normalizedTarget) ?? []), link]);
|
|
54
|
+
}
|
|
55
|
+
const ref = normalizedDecisionRef(page.file, (0, workspace_1.metadataValue)(page.text, "decision_ref"));
|
|
56
|
+
if (ref && files.has(ref)) {
|
|
57
|
+
outgoingDecisionRef.set(page.file, ref);
|
|
58
|
+
incomingDecisionRefs.set(ref, [...(incomingDecisionRefs.get(ref) ?? []), page.file]);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return { files, links, incomingLinks, outgoingLinks, incomingDecisionRefs, outgoingDecisionRef };
|
|
62
|
+
}
|
|
63
|
+
// BFS depths over existing pages from wiki/startup.md (depth 0). Pages absent
|
|
64
|
+
// from the result are unreachable through the router chain. Only links whose
|
|
65
|
+
// target exists are traversed; broken links are the broken-link rule's job.
|
|
66
|
+
function wikiRouterDepths(graph) {
|
|
67
|
+
const depths = new Map();
|
|
68
|
+
if (!graph.files.has(exports.wikiRouterRoot))
|
|
69
|
+
return depths;
|
|
70
|
+
depths.set(exports.wikiRouterRoot, 0);
|
|
71
|
+
const queue = [exports.wikiRouterRoot];
|
|
72
|
+
while (queue.length > 0) {
|
|
73
|
+
const current = queue.shift();
|
|
74
|
+
const depth = depths.get(current) ?? 0;
|
|
75
|
+
for (const link of graph.outgoingLinks.get(current) ?? []) {
|
|
76
|
+
const target = link.normalizedTarget;
|
|
77
|
+
if (!graph.files.has(target) || depths.has(target))
|
|
78
|
+
continue;
|
|
79
|
+
depths.set(target, depth + 1);
|
|
80
|
+
queue.push(target);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return depths;
|
|
84
|
+
}
|
|
85
|
+
// Wiki impact: the --code-impact envelope shape applied to wiki maintenance.
|
|
86
|
+
// Given a page or term, report which pages link to it (review candidates when it
|
|
87
|
+
// changes), which pages cite it as decision_ref, what it depends on, and how the
|
|
88
|
+
// router reaches it. Bounded by sampling plus the shared answer cap.
|
|
89
|
+
const impactMatchCap = 5;
|
|
90
|
+
const impactListCap = 12;
|
|
91
|
+
function sampled(items, cap) {
|
|
92
|
+
if (items.length === 0)
|
|
93
|
+
return "none";
|
|
94
|
+
const shown = items.slice(0, cap).join(", ");
|
|
95
|
+
return items.length > cap ? `${shown}, …+${items.length - cap} more` : shown;
|
|
96
|
+
}
|
|
97
|
+
function uniqueSorted(values) {
|
|
98
|
+
return Array.from(new Set(values)).sort();
|
|
99
|
+
}
|
|
100
|
+
function plural(count, noun) {
|
|
101
|
+
return `${count} ${noun}${count === 1 ? "" : "s"}`;
|
|
102
|
+
}
|
|
103
|
+
function wikiQueryGraphEvidence(graph, file, depths = wikiRouterDepths(graph), listCap = 3) {
|
|
104
|
+
const outgoing = uniqueSorted((graph.outgoingLinks.get(file) ?? [])
|
|
105
|
+
.map((link) => link.normalizedTarget)
|
|
106
|
+
.filter((target) => target !== file && graph.files.has(target)));
|
|
107
|
+
const incoming = uniqueSorted((graph.incomingLinks.get(file) ?? [])
|
|
108
|
+
.map((link) => link.file)
|
|
109
|
+
.filter((source) => source !== file && graph.files.has(source)));
|
|
110
|
+
const incomingRefs = uniqueSorted((graph.incomingDecisionRefs.get(file) ?? [])
|
|
111
|
+
.filter((source) => source !== file && graph.files.has(source)));
|
|
112
|
+
const outgoingRef = graph.outgoingDecisionRef.get(file);
|
|
113
|
+
const depth = depths.get(file);
|
|
114
|
+
const parts = [
|
|
115
|
+
depth === undefined ? `router unreachable from ${exports.wikiRouterRoot}` : `router depth ${depth}`,
|
|
116
|
+
];
|
|
117
|
+
if (outgoing.length > 0)
|
|
118
|
+
parts.push(`links-out ${outgoing.length}: ${sampled(outgoing, listCap)}`);
|
|
119
|
+
if (outgoingRef && outgoingRef !== file && graph.files.has(outgoingRef))
|
|
120
|
+
parts.push(`decision_ref-> ${outgoingRef}`);
|
|
121
|
+
if (incoming.length > 0)
|
|
122
|
+
parts.push(`linked-by ${incoming.length}: ${sampled(incoming, listCap)}`);
|
|
123
|
+
if (incomingRefs.length > 0)
|
|
124
|
+
parts.push(`decision_ref-by ${incomingRefs.length}: ${sampled(incomingRefs, listCap)}`);
|
|
125
|
+
return parts.join("; ");
|
|
126
|
+
}
|
|
127
|
+
function wikiImpactAnswer(pages, term) {
|
|
128
|
+
const graph = buildWikiGraph(pages);
|
|
129
|
+
const depths = wikiRouterDepths(graph);
|
|
130
|
+
const textByFile = new Map(pages.map((page) => [page.file, page.text]));
|
|
131
|
+
const lowered = term.toLowerCase();
|
|
132
|
+
const exactTarget = (0, wiki_files_1.normalizeWikiLinkTarget)("wiki/index.md", term.replace(/^\[\[|\]\]$/g, ""));
|
|
133
|
+
const matches = pages
|
|
134
|
+
.map((page) => ({ file: page.file, title: (0, wiki_files_1.wikiTitleForFile)(page.file, page.text) }))
|
|
135
|
+
.filter((page) => page.file === exactTarget || page.file.toLowerCase().includes(lowered) || page.title.toLowerCase().includes(lowered))
|
|
136
|
+
.sort((a, b) => Number(b.file === exactTarget) - Number(a.file === exactTarget) || a.file.localeCompare(b.file));
|
|
137
|
+
if (matches.length === 0)
|
|
138
|
+
return `Wiki impact "${term}": no matching wiki pages.`;
|
|
139
|
+
const shown = matches.slice(0, impactMatchCap);
|
|
140
|
+
// Headline counts are unions across the shown matches, not sums: a page that
|
|
141
|
+
// links two matched targets is one review candidate, not two.
|
|
142
|
+
const incomingUnion = new Set(shown.flatMap((match) => (graph.incomingLinks.get(match.file) ?? []).map((link) => link.file)));
|
|
143
|
+
const refUnion = new Set(shown.flatMap((match) => graph.incomingDecisionRefs.get(match.file) ?? []));
|
|
144
|
+
const lines = [
|
|
145
|
+
`Wiki impact "${term}": ${plural(matches.length, "matching page")}${matches.length > impactMatchCap ? ` (top ${impactMatchCap} shown)` : ""}; review the ${plural(incomingUnion.size, "linking page")} and ${plural(refUnion.size, "decision_ref citation")} below when ${matches.length === 1 ? "this page changes" : "these pages change"}.`,
|
|
146
|
+
];
|
|
147
|
+
for (const match of shown) {
|
|
148
|
+
const text = textByFile.get(match.file) ?? "";
|
|
149
|
+
const incoming = uniqueSorted((graph.incomingLinks.get(match.file) ?? []).map((link) => link.file));
|
|
150
|
+
const refs = uniqueSorted(graph.incomingDecisionRefs.get(match.file) ?? []);
|
|
151
|
+
const outgoing = uniqueSorted((graph.outgoingLinks.get(match.file) ?? []).map((link) => link.normalizedTarget));
|
|
152
|
+
const depth = depths.get(match.file);
|
|
153
|
+
const trigger = (0, workspace_1.metadataValue)(text, "review_trigger");
|
|
154
|
+
lines.push("");
|
|
155
|
+
lines.push(`${match.file} — ${match.title}`);
|
|
156
|
+
if (trigger)
|
|
157
|
+
lines.push(` review_trigger: ${trigger}`);
|
|
158
|
+
lines.push(` incoming links (${incoming.length}): ${sampled(incoming, impactListCap)}`);
|
|
159
|
+
lines.push(` decision_ref from (${refs.length}): ${sampled(refs, impactListCap)}`);
|
|
160
|
+
lines.push(` outgoing links (${outgoing.length}): ${sampled(outgoing, impactListCap)}`);
|
|
161
|
+
lines.push(depth === undefined
|
|
162
|
+
? ` router: unreachable from ${exports.wikiRouterRoot}`
|
|
163
|
+
: ` router: reachable at depth ${depth} (budget ${exports.wikiRouterDepthBudget})`);
|
|
164
|
+
}
|
|
165
|
+
return finalizeWikiAnswer(lines.join("\n"));
|
|
166
|
+
}
|