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.
@@ -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/canonical/project-brief.md",
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
+ }