sdtk-brain-kit 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.
@@ -0,0 +1,930 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const { CliError, ValidationError } = require("./errors");
6
+ const {
7
+ assertWikiWorkspaceWritePath,
8
+ getWikiGraphPath,
9
+ getWikiPagesPath,
10
+ getPreferredWikiContentPath,
11
+ getWikiProvenanceSourcesPath,
12
+ getWikiRawSourcesPath,
13
+ getWikiReportsPath,
14
+ getWikiWorkspacePath,
15
+ isPathInsideOrEqual,
16
+ resolveProjectPath,
17
+ } = require("./wiki-paths");
18
+
19
+ const REQUIRED_PAGE_FIELDS = [
20
+ "schema_version",
21
+ "product",
22
+ "managed_by",
23
+ "page_id",
24
+ "source_path",
25
+ "source_hash",
26
+ "title",
27
+ "family",
28
+ "role",
29
+ ];
30
+
31
+ const REQUIRED_PERSONAL_BRAIN_FIELDS = [
32
+ "id",
33
+ "title",
34
+ "type",
35
+ "status",
36
+ "created_at",
37
+ "updated_at",
38
+ "aliases",
39
+ "tags",
40
+ "related_pages",
41
+ "source_refs",
42
+ "confidence",
43
+ "review_status",
44
+ ];
45
+
46
+ const CATEGORY_DEFS = [
47
+ ["schema", "Frontmatter and schema"],
48
+ ["duplicates", "Duplicate page IDs"],
49
+ ["brokenLinks", "Broken internal links"],
50
+ ["orphans", "Orphan pages"],
51
+ ["missingReciprocal", "Missing reciprocal related_pages"],
52
+ ["provenance", "Provenance gaps"],
53
+ ["downstream", "Downstream integration gaps"],
54
+ ["thin", "Thin pages"],
55
+ ["stale", "Stale pages"],
56
+ ["markers", "TODO/Open Questions/Gaps"],
57
+ ["contradictions", "Candidate contradictions"],
58
+ ["sourceQuality", "Source quality"],
59
+ ["personalBrainQuality", "Local wiki quality gate"],
60
+ ];
61
+
62
+ const REQUIRED_PERSONAL_BRAIN_SECTIONS = {
63
+ source: ["Summary", "Source Metadata", "Source Quality", "Provenance"],
64
+ tool_entity: ["Summary", "Key Facts", "Discovery Source", "Extracted Snippet", "Topic Labels", "Why It Matters", "When To Use", "Related Repos", "Overlaps / Differences", "Open Questions", "Provenance"],
65
+ concept: ["Summary", "Key Axes", "Implementations / Examples", "Patterns", "Recommendations / Caveats", "Open Questions", "Related Pages", "Source References"],
66
+ comparison: ["Summary", "Decision Axes", "Comparison Matrix", "Candidate Tools / Repos", "Recommendations", "Caveats", "Source Confidence", "Open Questions", "Source References"],
67
+ synthesis: ["Summary", "Landscape Snapshot", "Key Patterns", "Decision Axes", "Recommended Review Path", "Caveats", "Source Confidence", "Related Comparisons", "Open Questions", "Source References"],
68
+ maintenance: ["Source Quality Findings", "Unsupported Items"],
69
+ };
70
+
71
+ const PERSONAL_BRAIN_STUB_CHAR_LIMIT = 600;
72
+ const PERSONAL_BRAIN_GIANT_PAGE_BYTES = 50000;
73
+ const LINT_CONTEXT_CHAR_LIMIT = 96;
74
+ const STRICT_SEMANTIC_PERSONAL_BRAIN_TYPES = new Set(["tool_entity", "concept", "comparison", "synthesis"]);
75
+
76
+ function toPosix(value) {
77
+ return String(value || "").replace(/\\/g, "/");
78
+ }
79
+
80
+ function stripQuotes(value) {
81
+ const text = String(value || "").trim();
82
+ if (
83
+ (text.startsWith('"') && text.endsWith('"')) ||
84
+ (text.startsWith("'") && text.endsWith("'"))
85
+ ) {
86
+ return text.slice(1, -1);
87
+ }
88
+ return text;
89
+ }
90
+
91
+ function parseInlineList(value) {
92
+ const text = String(value || "").trim();
93
+ if (!text) return [];
94
+ if (text.startsWith("[") && text.endsWith("]")) {
95
+ const inner = text.slice(1, -1).trim();
96
+ if (!inner) return [];
97
+ return inner
98
+ .split(",")
99
+ .map((item) => stripQuotes(item.trim()))
100
+ .filter(Boolean);
101
+ }
102
+ return text
103
+ .split(",")
104
+ .map((item) => stripQuotes(item.trim()))
105
+ .filter(Boolean);
106
+ }
107
+
108
+ function parseFrontmatter(text) {
109
+ const source = String(text || "");
110
+ const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
111
+ if (!match) {
112
+ return { fields: {}, body: source, hasFrontmatter: false };
113
+ }
114
+
115
+ const block = match[1].split(/\r?\n/);
116
+ const fields = {};
117
+ for (const line of block) {
118
+ const separator = line.indexOf(":");
119
+ if (separator === -1) continue;
120
+ const key = line.slice(0, separator).trim();
121
+ const rawValue = line.slice(separator + 1).trim();
122
+ fields[key] = key === "related_pages" ? parseInlineList(rawValue) : stripQuotes(rawValue);
123
+ }
124
+
125
+ return {
126
+ fields,
127
+ body: source.slice(match[0].length),
128
+ hasFrontmatter: true,
129
+ };
130
+ }
131
+
132
+ function readJsonIfPresent(filePath, fallback = null) {
133
+ if (!fs.existsSync(filePath)) return fallback;
134
+ try {
135
+ return JSON.parse(fs.readFileSync(filePath, "utf-8"));
136
+ } catch (error) {
137
+ return { __lintError: `Could not parse JSON at ${filePath}: ${error.message}` };
138
+ }
139
+ }
140
+
141
+ function listMarkdownPages(rootPath) {
142
+ if (!fs.existsSync(rootPath)) return [];
143
+ const files = [];
144
+
145
+ function walk(current) {
146
+ const entries = fs.readdirSync(current, { withFileTypes: true }).sort((a, b) =>
147
+ a.name.localeCompare(b.name)
148
+ );
149
+ for (const entry of entries) {
150
+ const absolute = path.join(current, entry.name);
151
+ if (entry.isDirectory()) {
152
+ walk(absolute);
153
+ } else if (entry.isFile() && entry.name.toLowerCase().endsWith(".md")) {
154
+ if (entry.name === "_index.md") continue;
155
+ files.push(absolute);
156
+ }
157
+ }
158
+ }
159
+
160
+ walk(rootPath);
161
+ return files;
162
+ }
163
+
164
+ function truncateForReport(value, limit = LINT_CONTEXT_CHAR_LIMIT) {
165
+ const text = String(value || "").replace(/\s+/g, " ").trim();
166
+ if (text.length <= limit) return text;
167
+ return `${text.slice(0, Math.max(0, limit - 3)).trim()}...`;
168
+ }
169
+
170
+ function isExternalMarkdownTarget(rawTarget) {
171
+ const target = String(rawTarget || "").trim();
172
+ return (
173
+ target.startsWith("#") ||
174
+ /^(?:https?:|mailto:|tel:)/i.test(target) ||
175
+ /^(?:h|ht|htt|http|https)\.{3,}(?:\s|$)/i.test(target)
176
+ );
177
+ }
178
+
179
+ function extractMarkdownLinks(body) {
180
+ const links = [];
181
+ const matcher = /\[([^\]]+)\]\(([^)]+)\)/g;
182
+ let match;
183
+ while ((match = matcher.exec(body || "")) !== null) {
184
+ const rawLabel = String(match[1] || "").trim();
185
+ const rawTarget = String(match[2] || "").trim();
186
+ if (!rawTarget) continue;
187
+ if (isExternalMarkdownTarget(rawTarget)) {
188
+ continue;
189
+ }
190
+ links.push({
191
+ target: rawTarget,
192
+ label: rawLabel,
193
+ context: truncateForReport(rawLabel || rawTarget),
194
+ });
195
+ }
196
+ return links;
197
+ }
198
+
199
+ function formatBrokenLinkFinding(pageRelPath, link) {
200
+ const target = typeof link === "string" ? link : link.target;
201
+ const context = typeof link === "string" ? "" : link.context;
202
+ const parts = [
203
+ `page: \`${pageRelPath}\``,
204
+ `missing target: \`${truncateForReport(target)}\``,
205
+ ];
206
+ if (context) {
207
+ parts.push(`context: \`${context}\``);
208
+ }
209
+ return parts.join("; ") + ".";
210
+ }
211
+
212
+ function hasHeading(body, heading) {
213
+ const escaped = String(heading).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
214
+ return new RegExp(`^##\\s+${escaped}\\s*$`, "im").test(String(body || ""));
215
+ }
216
+
217
+ function hasSourceRefs(fields) {
218
+ const raw = String(fields.source_refs || "").trim();
219
+ return Boolean(raw && raw !== "[]" && raw !== "[\"\"]");
220
+ }
221
+
222
+ function formatPercent(value) {
223
+ return `${Math.round(value * 100)}%`;
224
+ }
225
+
226
+ function createFindings() {
227
+ return Object.fromEntries(CATEGORY_DEFS.map(([key]) => [key, []]));
228
+ }
229
+
230
+ function appendFinding(findings, category, text) {
231
+ findings[category].push(text);
232
+ }
233
+
234
+ function graphDegreeMap(graphPayload) {
235
+ const degrees = new Map();
236
+ if (!graphPayload || graphPayload.__lintError) return degrees;
237
+ const edges = Array.isArray(graphPayload.edges) ? graphPayload.edges : [];
238
+ for (const edge of edges) {
239
+ const source = typeof edge.source === "string" ? edge.source : null;
240
+ const target = typeof edge.target === "string" ? edge.target : null;
241
+ if (source) degrees.set(source, (degrees.get(source) || 0) + 1);
242
+ if (target) degrees.set(target, (degrees.get(target) || 0) + 1);
243
+ }
244
+ return degrees;
245
+ }
246
+
247
+ function readLintInputs(projectPath, findings) {
248
+ const pagesRoot = getWikiPagesPath(projectPath);
249
+ const pageFiles = listMarkdownPages(pagesRoot);
250
+ const provenance = readJsonIfPresent(getWikiProvenanceSourcesPath(projectPath), { sources: [] });
251
+ const raw = readJsonIfPresent(getWikiRawSourcesPath(projectPath), { sources: [] });
252
+ const graph = readJsonIfPresent(path.join(getWikiGraphPath(projectPath), "SDTK_DOC_GRAPH.json"), {
253
+ edges: [],
254
+ });
255
+ const graphIndex = readJsonIfPresent(path.join(getWikiGraphPath(projectPath), "SDTK_DOC_INDEX.json"), {
256
+ documents: [],
257
+ });
258
+
259
+ if (provenance && provenance.__lintError) {
260
+ appendFinding(findings, "provenance", provenance.__lintError);
261
+ }
262
+ if (raw && raw.__lintError) {
263
+ appendFinding(findings, "sourceQuality", raw.__lintError);
264
+ }
265
+ if (graph && graph.__lintError) {
266
+ appendFinding(findings, "downstream", graph.__lintError);
267
+ }
268
+ if (graphIndex && graphIndex.__lintError) {
269
+ appendFinding(findings, "sourceQuality", graphIndex.__lintError);
270
+ }
271
+
272
+ const sources = provenance && Array.isArray(provenance.sources) ? provenance.sources : [];
273
+ const rawSources = raw && Array.isArray(raw.sources) ? raw.sources : [];
274
+ const graphDocuments = graphIndex && Array.isArray(graphIndex.documents) ? graphIndex.documents : [];
275
+ return { graph, graphDocuments, pageFiles, pagesRoot, rawSources, sources };
276
+ }
277
+
278
+ function normalizeSourcePath(value) {
279
+ return toPosix(value).replace(/^\.\//, "");
280
+ }
281
+
282
+ function sourceRecordPath(record) {
283
+ if (!record || typeof record !== "object") return "";
284
+ return normalizeSourcePath(record.sourcePath || record.path || record.id || "");
285
+ }
286
+
287
+ function resolveSourceFilePath(projectPath, sourcePath) {
288
+ if (!sourcePath) return null;
289
+ const nativePath = sourcePath.replace(/\//g, path.sep);
290
+ const candidate = path.isAbsolute(nativePath) ? path.resolve(nativePath) : path.resolve(projectPath, nativePath);
291
+ if (!fs.existsSync(candidate) || !fs.statSync(candidate).isFile()) return null;
292
+ return candidate;
293
+ }
294
+
295
+ function extractGithubRepos(text) {
296
+ const repos = [];
297
+ const seen = new Set();
298
+ const matcher = /(?:https?:\/\/)?(?:www\.)?github\.com\/([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))\/([A-Za-z0-9._-]+)/gi;
299
+ let match;
300
+ while ((match = matcher.exec(String(text || ""))) !== null) {
301
+ const owner = match[1];
302
+ const repo = match[2].replace(/[).,;:]+$/g, "").replace(/\.git$/i, "");
303
+ if (!repo || repo.includes("...")) continue;
304
+ const url = `https://github.com/${owner}/${repo}`;
305
+ const key = url.toLowerCase();
306
+ if (seen.has(key)) continue;
307
+ seen.add(key);
308
+ repos.push({ owner, repo, url });
309
+ }
310
+ return repos;
311
+ }
312
+
313
+ function detectMojibakeExamples(text) {
314
+ const examples = [];
315
+ const matcher = /�|Ã.|Â.|â€|ðŸ/gi;
316
+ let match;
317
+ while ((match = matcher.exec(String(text || ""))) !== null) {
318
+ const start = Math.max(0, match.index - 20);
319
+ const end = Math.min(text.length, match.index + 40);
320
+ examples.push(text.slice(start, end).replace(/\s+/g, " ").trim());
321
+ if (examples.length >= 3) break;
322
+ }
323
+ return examples;
324
+ }
325
+
326
+ function weakTitle(title, sourcePath) {
327
+ const text = String(title || "").trim();
328
+ const stem = path.basename(sourcePath || "", path.extname(sourcePath || ""));
329
+ return (
330
+ text.length < 6 ||
331
+ /^untitled|note|readme$/i.test(text) ||
332
+ text === stem.replace(/[_-]+/g, " ")
333
+ );
334
+ }
335
+
336
+ function analyzeSourceQuality(projectPath, inputs, findings) {
337
+ const provenancePaths = new Set(inputs.sources.map(sourceRecordPath).filter(Boolean));
338
+ const rawPaths = new Set(inputs.rawSources.map(sourceRecordPath).filter(Boolean));
339
+ const graphPaths = new Set(
340
+ inputs.graphDocuments
341
+ .map((record) => normalizeSourcePath(record && (record.id || record.path)))
342
+ .filter(Boolean)
343
+ );
344
+ const repoToSources = new Map();
345
+ const urlToSources = new Map();
346
+
347
+ for (const sourcePath of rawPaths) {
348
+ if (!provenancePaths.has(sourcePath)) {
349
+ appendFinding(
350
+ findings,
351
+ "sourceQuality",
352
+ `Raw source \`${sourcePath}\` is registered but absent from graph/provenance source coverage.`
353
+ );
354
+ }
355
+ }
356
+
357
+ for (const sourcePath of provenancePaths) {
358
+ if (graphPaths.size > 0 && !graphPaths.has(sourcePath)) {
359
+ appendFinding(
360
+ findings,
361
+ "sourceQuality",
362
+ `Provenance source \`${sourcePath}\` is absent from graph document index.`
363
+ );
364
+ }
365
+ }
366
+
367
+ for (const record of inputs.sources) {
368
+ const sourcePath = sourceRecordPath(record);
369
+ const resolved = resolveSourceFilePath(projectPath, sourcePath);
370
+ const title = String((record && record.title) || "");
371
+ if (!resolved) {
372
+ appendFinding(
373
+ findings,
374
+ "sourceQuality",
375
+ `Source \`${sourcePath || "(missing)"}\` could not be read for source-quality lint.`
376
+ );
377
+ continue;
378
+ }
379
+
380
+ const text = fs.readFileSync(resolved, "utf-8");
381
+ const repos = extractGithubRepos(text);
382
+ const mojibakeExamples = detectMojibakeExamples(text);
383
+
384
+ if (mojibakeExamples.length > 0) {
385
+ appendFinding(
386
+ findings,
387
+ "sourceQuality",
388
+ `Source \`${sourcePath}\` has mojibake-like text examples: ${mojibakeExamples.map((item) => `\`${item}\``).join("; ")}.`
389
+ );
390
+ }
391
+
392
+ if (repos.length === 0) {
393
+ appendFinding(findings, "sourceQuality", `Source \`${sourcePath}\` has no detected GitHub/source URL.`);
394
+ }
395
+
396
+ if (weakTitle(title, sourcePath)) {
397
+ appendFinding(findings, "sourceQuality", `Source \`${sourcePath}\` has a weak or filename-derived title \`${title || "(missing)"}\`.`);
398
+ }
399
+
400
+ if (repos.length === 0) {
401
+ appendFinding(findings, "sourceQuality", `Source \`${sourcePath}\` has low-confidence extraction because no valid GitHub repo candidate was detected.`);
402
+ }
403
+
404
+ if (repos.length > 0) {
405
+ const sourceUrl = repos[0].url;
406
+ const existing = urlToSources.get(sourceUrl.toLowerCase()) || [];
407
+ existing.push(sourcePath);
408
+ urlToSources.set(sourceUrl.toLowerCase(), existing);
409
+ }
410
+
411
+ for (const repo of repos) {
412
+ const existing = repoToSources.get(repo.url.toLowerCase()) || [];
413
+ existing.push(sourcePath);
414
+ repoToSources.set(repo.url.toLowerCase(), existing);
415
+ }
416
+ }
417
+
418
+ for (const [url, paths] of urlToSources.entries()) {
419
+ if (paths.length > 1) {
420
+ appendFinding(
421
+ findings,
422
+ "sourceQuality",
423
+ `Duplicate source URL candidate \`${url}\` appears in ${paths.map((item) => `\`${item}\``).join(", ")}.`
424
+ );
425
+ }
426
+ }
427
+
428
+ for (const [repo, paths] of repoToSources.entries()) {
429
+ if (paths.length > 1) {
430
+ appendFinding(
431
+ findings,
432
+ "sourceQuality",
433
+ `Duplicate GitHub repo candidate \`${repo}\` appears in ${paths.map((item) => `\`${item}\``).join(", ")}.`
434
+ );
435
+ }
436
+ }
437
+ }
438
+
439
+ function analyzePages(projectPath) {
440
+ const findings = createFindings();
441
+ const inputs = readLintInputs(projectPath, findings);
442
+ const pages = [];
443
+ const pagesById = new Map();
444
+ const inboundMarkdown = new Map();
445
+ const inboundRelated = new Map();
446
+
447
+ for (const filePath of inputs.pageFiles) {
448
+ const relPath = toPosix(path.relative(inputs.pagesRoot, filePath));
449
+ const parsed = parseFrontmatter(fs.readFileSync(filePath, "utf-8"));
450
+ const fields = parsed.fields;
451
+ const page = {
452
+ filePath,
453
+ relPath,
454
+ body: parsed.body,
455
+ fields,
456
+ pageId: fields.page_id || "",
457
+ sourcePath: fields.source_path || "",
458
+ sourceHash: fields.source_hash || "",
459
+ relatedPages: Array.isArray(fields.related_pages) ? fields.related_pages : [],
460
+ links: extractMarkdownLinks(parsed.body),
461
+ };
462
+ pages.push(page);
463
+
464
+ if (!parsed.hasFrontmatter) {
465
+ appendFinding(findings, "schema", `\`${relPath}\` is missing parseable frontmatter.`);
466
+ }
467
+
468
+ const missingFields = REQUIRED_PAGE_FIELDS.filter((field) => !fields[field]);
469
+ if (missingFields.length > 0) {
470
+ appendFinding(
471
+ findings,
472
+ "schema",
473
+ `\`${relPath}\` is missing required fields: ${missingFields.join(", ")}.`
474
+ );
475
+ }
476
+ if (fields.product && fields.product !== "SDTK-BRAIN") {
477
+ appendFinding(findings, "schema", `\`${relPath}\` has unexpected product \`${fields.product}\`.`);
478
+ }
479
+ if (fields.managed_by && fields.managed_by !== "sdtk-wiki") {
480
+ appendFinding(findings, "schema", `\`${relPath}\` has unexpected managed_by \`${fields.managed_by}\`.`);
481
+ }
482
+
483
+ if (page.pageId) {
484
+ if (!pagesById.has(page.pageId)) {
485
+ pagesById.set(page.pageId, []);
486
+ }
487
+ pagesById.get(page.pageId).push(page);
488
+ }
489
+ }
490
+
491
+ for (const [pageId, records] of pagesById.entries()) {
492
+ if (records.length > 1) {
493
+ appendFinding(
494
+ findings,
495
+ "duplicates",
496
+ `\`${pageId}\` appears in ${records.map((record) => `\`${record.relPath}\``).join(", ")}.`
497
+ );
498
+ }
499
+ }
500
+
501
+ const pagesByRelPath = new Map(pages.map((page) => [toPosix(path.normalize(page.relPath)), page]));
502
+ for (const page of pages) {
503
+ for (const link of page.links) {
504
+ const target = typeof link === "string" ? link : link.target;
505
+ const rawPath = target.split("#")[0].trim();
506
+ if (!rawPath) continue;
507
+ const resolved = path.resolve(path.dirname(page.filePath), rawPath);
508
+ if (!isPathInsideOrEqual(resolved, inputs.pagesRoot) || !fs.existsSync(resolved)) {
509
+ appendFinding(findings, "brokenLinks", formatBrokenLinkFinding(page.relPath, link));
510
+ continue;
511
+ }
512
+ const targetRel = toPosix(path.relative(inputs.pagesRoot, resolved));
513
+ if (pagesByRelPath.has(targetRel)) {
514
+ inboundMarkdown.set(targetRel, (inboundMarkdown.get(targetRel) || 0) + 1);
515
+ }
516
+ }
517
+
518
+ for (const targetId of page.relatedPages) {
519
+ const targetRecords = pagesById.get(targetId) || [];
520
+ if (targetRecords.length === 0) {
521
+ appendFinding(
522
+ findings,
523
+ "missingReciprocal",
524
+ `\`${page.relPath}\` declares related page \`${targetId}\`, but no target page exists.`
525
+ );
526
+ continue;
527
+ }
528
+ for (const target of targetRecords) {
529
+ inboundRelated.set(target.pageId, (inboundRelated.get(target.pageId) || 0) + 1);
530
+ if (!target.relatedPages.includes(page.pageId)) {
531
+ appendFinding(
532
+ findings,
533
+ "missingReciprocal",
534
+ `\`${page.pageId}\` references \`${targetId}\`, but the target does not reference \`${page.pageId}\`.`
535
+ );
536
+ }
537
+ }
538
+ }
539
+ }
540
+
541
+ const provenanceBySource = new Map(
542
+ inputs.sources
543
+ .filter((record) => record && typeof record.sourcePath === "string")
544
+ .map((record) => [record.sourcePath, record])
545
+ );
546
+ const degrees = graphDegreeMap(inputs.graph);
547
+
548
+ for (const page of pages) {
549
+ const sourceRecord = page.sourcePath ? provenanceBySource.get(page.sourcePath) : null;
550
+ if (page.sourcePath && !sourceRecord) {
551
+ appendFinding(
552
+ findings,
553
+ "provenance",
554
+ `\`${page.relPath}\` references source \`${page.sourcePath}\` missing from provenance sources.`
555
+ );
556
+ appendFinding(
557
+ findings,
558
+ "stale",
559
+ `\`${page.relPath}\` may be stale because source \`${page.sourcePath}\` is absent from provenance.`
560
+ );
561
+ } else if (
562
+ page.sourcePath &&
563
+ sourceRecord &&
564
+ typeof sourceRecord.sourceHash === "string" &&
565
+ page.sourceHash &&
566
+ sourceRecord.sourceHash !== page.sourceHash
567
+ ) {
568
+ appendFinding(
569
+ findings,
570
+ "provenance",
571
+ `\`${page.relPath}\` source hash differs from provenance for \`${page.sourcePath}\`.`
572
+ );
573
+ appendFinding(
574
+ findings,
575
+ "stale",
576
+ `\`${page.relPath}\` may be stale because its source hash differs from provenance.`
577
+ );
578
+ }
579
+
580
+ const inboundLinks = inboundMarkdown.get(page.relPath) || 0;
581
+ const relatedInboundCount = inboundRelated.get(page.pageId) || 0;
582
+ // BK-318: a page whose underlying source has real atlas-graph edges is not
583
+ // an orphan — the degree map was already computed but never consulted here,
584
+ // which false-flagged ~98% of atlas pages (their connectivity lives in the
585
+ // graph, not in per-page frontmatter).
586
+ const graphDegree = page.sourcePath ? degrees.get(page.sourcePath) || 0 : 0;
587
+ if (inboundLinks === 0 && relatedInboundCount === 0 && graphDegree === 0) {
588
+ appendFinding(findings, "orphans", `\`${page.relPath}\` has no inbound wiki links, reciprocal relationships, or graph edges.`);
589
+ }
590
+ if (
591
+ page.sourcePath &&
592
+ graphDegree === 0 &&
593
+ inboundLinks === 0 &&
594
+ relatedInboundCount === 0 &&
595
+ page.relatedPages.length === 0
596
+ ) {
597
+ appendFinding(
598
+ findings,
599
+ "downstream",
600
+ `\`${page.relPath}\` has no downstream integration signal for source \`${page.sourcePath}\`.`
601
+ );
602
+ }
603
+
604
+ const compactBody = page.body.replace(/\s+/g, " ").trim();
605
+ if (compactBody.length < 120) {
606
+ appendFinding(findings, "thin", `\`${page.relPath}\` is thin (${compactBody.length} normalized characters).`);
607
+ }
608
+
609
+ const markerLabels = [];
610
+ if (/\bTODO\b/i.test(page.body)) markerLabels.push("TODO");
611
+ if (/open questions/i.test(page.body)) markerLabels.push("Open Questions");
612
+ if (/\bgaps?\b/i.test(page.body)) markerLabels.push("Gaps");
613
+ if (markerLabels.length > 0) {
614
+ appendFinding(findings, "markers", `\`${page.relPath}\` contains ${markerLabels.join(", ")} markers.`);
615
+ }
616
+
617
+ if (
618
+ /\b(?:conflict|contradict|inconsistent)\b/i.test(page.body) ||
619
+ /\bmust\b[\s\S]{0,160}\bmust not\b/i.test(page.body) ||
620
+ /\bmust not\b[\s\S]{0,160}\bmust\b/i.test(page.body)
621
+ ) {
622
+ appendFinding(
623
+ findings,
624
+ "contradictions",
625
+ `\`${page.relPath}\` contains heuristic contradiction language and should be reviewed manually.`
626
+ );
627
+ }
628
+ }
629
+
630
+ analyzeSourceQuality(projectPath, inputs, findings);
631
+
632
+ const personalBrainAnalysis = analyzePersonalBrainPages(projectPath, findings);
633
+
634
+ return {
635
+ findings,
636
+ pageCount: pages.length + personalBrainAnalysis.count,
637
+ personalBrainMetrics: personalBrainAnalysis.metrics,
638
+ };
639
+ }
640
+
641
+ function analyzePersonalBrainPages(projectPath, findings) {
642
+ const contentRoot = getPreferredWikiContentPath(projectPath);
643
+ const personalBrainRoot = contentRoot.path;
644
+ const contentRelative = toPosix(contentRoot.relative);
645
+ const pageFiles = listMarkdownPages(personalBrainRoot);
646
+ const pages = [];
647
+ const metrics = {
648
+ pageCount: pageFiles.length,
649
+ contentRoot: personalBrainRoot,
650
+ contentMode: contentRoot.mode,
651
+ contentRelative,
652
+ byType: {},
653
+ frontmatterCoverage: 0,
654
+ requiredSectionCoverage: 0,
655
+ sourceRefsCoverage: 0,
656
+ stubRatio: 0,
657
+ sourcePageCount: 0,
658
+ sourceThinAnchorCount: 0,
659
+ sourceThinAnchorRatio: 0,
660
+ semanticPageCount: 0,
661
+ semanticStubCount: 0,
662
+ semanticStubRatio: 0,
663
+ giantPageCount: 0,
664
+ brokenInternalLinks: 0,
665
+ conceptCount: 0,
666
+ entityCount: 0,
667
+ comparisonCount: 0,
668
+ synthesisCount: 0,
669
+ sourceEvidenceCoverage: 0,
670
+ };
671
+ let frontmatterCount = 0;
672
+ let sectionRequiredTotal = 0;
673
+ let sectionPresentTotal = 0;
674
+ let sourceRefsEligible = 0;
675
+ let sourceRefsPresent = 0;
676
+ let sourceEvidenceEligible = 0;
677
+ let sourceEvidencePresent = 0;
678
+ let stubCount = 0;
679
+
680
+ for (const filePath of pageFiles) {
681
+ const relPath = toPosix(path.relative(personalBrainRoot, filePath));
682
+ const parsed = parseFrontmatter(fs.readFileSync(filePath, "utf-8"));
683
+ const fields = parsed.fields;
684
+ const type = String(fields.type || "unknown");
685
+ pages.push({ filePath, relPath, fields, body: parsed.body, hasFrontmatter: parsed.hasFrontmatter, type });
686
+ metrics.byType[type] = (metrics.byType[type] || 0) + 1;
687
+ if (type === "source") metrics.sourcePageCount += 1;
688
+ if (STRICT_SEMANTIC_PERSONAL_BRAIN_TYPES.has(type)) metrics.semanticPageCount += 1;
689
+ if (type === "concept") metrics.conceptCount += 1;
690
+ if (type === "tool_entity") metrics.entityCount += 1;
691
+ if (type === "comparison") metrics.comparisonCount += 1;
692
+ if (type === "synthesis") metrics.synthesisCount += 1;
693
+
694
+ if (!parsed.hasFrontmatter) {
695
+ appendFinding(findings, "schema", `${contentRelative}/${relPath} is missing parseable frontmatter.`);
696
+ continue;
697
+ }
698
+ frontmatterCount += 1;
699
+
700
+ const missingFields = REQUIRED_PERSONAL_BRAIN_FIELDS.filter((field) => !fields[field]);
701
+ if (missingFields.length > 0) {
702
+ appendFinding(
703
+ findings,
704
+ "schema",
705
+ `${contentRelative}/${relPath} is missing required local wiki fields: ${missingFields.join(", ")}.`
706
+ );
707
+ }
708
+
709
+ const requiredSections = REQUIRED_PERSONAL_BRAIN_SECTIONS[type] || [];
710
+ sectionRequiredTotal += requiredSections.length;
711
+ const missingSections = requiredSections.filter((section) => !hasHeading(parsed.body, section));
712
+ sectionPresentTotal += requiredSections.length - missingSections.length;
713
+ if (missingSections.length > 0) {
714
+ appendFinding(
715
+ findings,
716
+ "personalBrainQuality",
717
+ `${contentRelative}/${relPath} is missing required sections for ${type}: ${missingSections.join(", ")}.`
718
+ );
719
+ }
720
+
721
+ if (!["root"].includes(type)) {
722
+ sourceRefsEligible += 1;
723
+ if (hasSourceRefs(fields)) sourceRefsPresent += 1;
724
+ else appendFinding(findings, "personalBrainQuality", `${contentRelative}/${relPath} has no source_refs coverage.`);
725
+ }
726
+
727
+ if (["tool_entity", "concept", "comparison", "synthesis"].includes(type)) {
728
+ sourceEvidenceEligible += 1;
729
+ if (hasSourceRefs(fields) && /(?:source|provenance|evidence)/i.test(parsed.body)) {
730
+ sourceEvidencePresent += 1;
731
+ } else {
732
+ appendFinding(findings, "personalBrainQuality", `${contentRelative}/${relPath} lacks source evidence coverage in body/frontmatter.`);
733
+ }
734
+ }
735
+
736
+ const compactBody = parsed.body.replace(/\s+/g, " ").trim();
737
+ if (type === "source") {
738
+ metrics.sourceThinAnchorCount += 1;
739
+ } else if (compactBody.length < PERSONAL_BRAIN_STUB_CHAR_LIMIT && STRICT_SEMANTIC_PERSONAL_BRAIN_TYPES.has(type)) {
740
+ stubCount += 1;
741
+ metrics.semanticStubCount += 1;
742
+ appendFinding(findings, "personalBrainQuality", `${contentRelative}/${relPath} appears stub-like (${compactBody.length} normalized characters).`);
743
+ }
744
+
745
+ const byteSize = fs.statSync(filePath).size;
746
+ if (byteSize > PERSONAL_BRAIN_GIANT_PAGE_BYTES) {
747
+ metrics.giantPageCount += 1;
748
+ appendFinding(findings, "personalBrainQuality", `${contentRelative}/${relPath} is very large (${byteSize} bytes) and may need splitting.`);
749
+ }
750
+ }
751
+
752
+ for (const page of pages) {
753
+ for (const link of extractMarkdownLinks(page.body)) {
754
+ const target = typeof link === "string" ? link : link.target;
755
+ const rawPath = target.split("#")[0].trim();
756
+ if (!rawPath) continue;
757
+ const resolved = path.resolve(path.dirname(page.filePath), rawPath);
758
+ if (!isPathInsideOrEqual(resolved, personalBrainRoot) || !fs.existsSync(resolved)) {
759
+ appendFinding(findings, "brokenLinks", formatBrokenLinkFinding(`${contentRelative}/${page.relPath}`, link));
760
+ metrics.brokenInternalLinks += 1;
761
+ }
762
+ }
763
+ }
764
+
765
+ metrics.frontmatterCoverage = pageFiles.length > 0 ? frontmatterCount / pageFiles.length : 1;
766
+ metrics.requiredSectionCoverage = sectionRequiredTotal > 0 ? sectionPresentTotal / sectionRequiredTotal : 1;
767
+ metrics.sourceRefsCoverage = sourceRefsEligible > 0 ? sourceRefsPresent / sourceRefsEligible : 1;
768
+ metrics.stubRatio = pageFiles.length > 0 ? stubCount / pageFiles.length : 0;
769
+ metrics.sourceThinAnchorRatio = metrics.sourcePageCount > 0 ? metrics.sourceThinAnchorCount / metrics.sourcePageCount : 0;
770
+ metrics.semanticStubRatio = metrics.semanticPageCount > 0 ? metrics.semanticStubCount / metrics.semanticPageCount : 0;
771
+ metrics.sourceEvidenceCoverage = sourceEvidenceEligible > 0 ? sourceEvidencePresent / sourceEvidenceEligible : 1;
772
+
773
+ if (pageFiles.length === 0) {
774
+ appendFinding(findings, "personalBrainQuality", "No local wiki pages were found under wiki/ or legacy .brain/personal-brain.");
775
+ }
776
+ if (metrics.frontmatterCoverage < 1) {
777
+ appendFinding(findings, "personalBrainQuality", `Frontmatter coverage is ${formatPercent(metrics.frontmatterCoverage)}; expected 100%.`);
778
+ }
779
+ if (metrics.requiredSectionCoverage < 0.95) {
780
+ appendFinding(findings, "personalBrainQuality", `Required section coverage is ${formatPercent(metrics.requiredSectionCoverage)}; expected at least 95%.`);
781
+ }
782
+ if (metrics.sourceRefsCoverage < 0.9) {
783
+ appendFinding(findings, "personalBrainQuality", `Source refs coverage is ${formatPercent(metrics.sourceRefsCoverage)}; expected at least 90%.`);
784
+ }
785
+ if (metrics.semanticStubRatio > 0.1) {
786
+ appendFinding(findings, "personalBrainQuality", `Semantic stub ratio is ${formatPercent(metrics.semanticStubRatio)}; expected at most 10% for tool/concept/comparison/synthesis pages.`);
787
+ }
788
+ if (metrics.entityCount > 0 && metrics.conceptCount === 0) {
789
+ appendFinding(findings, "personalBrainQuality", "Tool/entity pages exist but no concept pages were generated.");
790
+ }
791
+ if (metrics.conceptCount > 0 && metrics.comparisonCount === 0) {
792
+ appendFinding(findings, "personalBrainQuality", "Concept pages exist but no comparison pages were generated.");
793
+ }
794
+ if (metrics.comparisonCount > 0 && metrics.synthesisCount === 0) {
795
+ appendFinding(findings, "personalBrainQuality", "Comparison pages exist but no synthesis pages were generated.");
796
+ }
797
+
798
+ return { count: pageFiles.length, metrics };
799
+ }
800
+
801
+ function todayStamp() {
802
+ return new Date().toISOString().slice(0, 10);
803
+ }
804
+
805
+ function totalFindings(findings) {
806
+ return CATEGORY_DEFS.reduce((sum, [key]) => sum + findings[key].length, 0);
807
+ }
808
+
809
+ function renderPersonalBrainMetrics(metrics) {
810
+ if (!metrics) return ["## Local Wiki Quality Metrics", "", "- No local wiki metrics available.", ""];
811
+ const typeRows = Object.entries(metrics.byType || {})
812
+ .sort(([a], [b]) => a.localeCompare(b))
813
+ .map(([type, count]) => `| ${type} | ${count} |`);
814
+ return [
815
+ "## Local Wiki Quality Metrics",
816
+ "",
817
+ `- wiki content root: ${metrics.contentRoot}`,
818
+ `- wiki content mode: ${metrics.contentMode}`,
819
+ `- local wiki pages: ${metrics.pageCount}`,
820
+ `- frontmatter coverage: ${formatPercent(metrics.frontmatterCoverage)}`,
821
+ `- required section coverage: ${formatPercent(metrics.requiredSectionCoverage)}`,
822
+ `- source refs coverage: ${formatPercent(metrics.sourceRefsCoverage)}`,
823
+ `- source evidence coverage: ${formatPercent(metrics.sourceEvidenceCoverage)}`,
824
+ `- source pages: ${metrics.sourcePageCount}`,
825
+ `- source pages accepted as provenance anchors: ${metrics.sourceThinAnchorCount}`,
826
+ `- source-page thin-anchor ratio: ${formatPercent(metrics.sourceThinAnchorRatio)}`,
827
+ `- strict semantic pages: ${metrics.semanticPageCount}`,
828
+ `- strict semantic stub pages: ${metrics.semanticStubCount}`,
829
+ `- semantic stub ratio: ${formatPercent(metrics.semanticStubRatio)}`,
830
+ `- stub ratio policy: source pages are measured separately; strict stub findings apply to tool_entity, concept, comparison, and synthesis pages.`,
831
+ `- giant page warnings: ${metrics.giantPageCount}`,
832
+ `- broken local wiki internal links: ${metrics.brokenInternalLinks}`,
833
+ `- entity pages: ${metrics.entityCount}`,
834
+ `- concept pages: ${metrics.conceptCount}`,
835
+ `- comparison pages: ${metrics.comparisonCount}`,
836
+ `- synthesis pages: ${metrics.synthesisCount}`,
837
+ "",
838
+ "| Page type | Count |",
839
+ "|---|---:|",
840
+ ...(typeRows.length > 0 ? typeRows : ["| none | 0 |"]),
841
+ "",
842
+ ];
843
+ }
844
+
845
+ function renderReport({ projectPath, workspaceRoot, findings, pageCount, personalBrainMetrics }) {
846
+ const summaryRows = CATEGORY_DEFS.map(
847
+ ([key, label]) => `| ${label} | ${findings[key].length} |`
848
+ );
849
+ const detailSections = CATEGORY_DEFS.flatMap(([key, label]) => {
850
+ const items = findings[key];
851
+ return [
852
+ `## ${label}`,
853
+ "",
854
+ ...(items.length > 0 ? items.map((item) => `- ${item}`) : ["- None."]),
855
+ "",
856
+ ];
857
+ });
858
+
859
+ return [
860
+ "# SDTK-BRAIN Lint Report",
861
+ "",
862
+ `Date: ${todayStamp()}`,
863
+ `Project root: \`${projectPath}\``,
864
+ `Workspace root: \`${workspaceRoot}\``,
865
+ `Pages scanned: ${pageCount}`,
866
+ `Total findings: ${totalFindings(findings)}`,
867
+ "",
868
+ "Report-only and non-destructive: lint writes this report and does not auto-modify wiki or source content.",
869
+ "Candidate contradictions are heuristic only and require manual review.",
870
+ "",
871
+ "## Summary",
872
+ "",
873
+ "| Category | Count |",
874
+ "|---|---:|",
875
+ ...summaryRows,
876
+ "",
877
+ ...renderPersonalBrainMetrics(personalBrainMetrics),
878
+ ...detailSections,
879
+ ].join("\n");
880
+ }
881
+
882
+ function runWikiLint(options = {}) {
883
+ const projectPath = resolveProjectPath(options.projectPath);
884
+ if (!fs.existsSync(projectPath) || !fs.statSync(projectPath).isDirectory()) {
885
+ throw new ValidationError(`--project-path is not a valid directory: ${projectPath}`);
886
+ }
887
+
888
+ const workspaceRoot = getWikiWorkspacePath(projectPath);
889
+ if (!fs.existsSync(workspaceRoot) || !fs.statSync(workspaceRoot).isDirectory()) {
890
+ throw new ValidationError(
891
+ `No SDTK-BRAIN workspace found at ${workspaceRoot}. Run "sdtk-brain init" first.`
892
+ );
893
+ }
894
+
895
+ const analysis = analyzePages(projectPath);
896
+ const reportsRoot = getWikiReportsPath(projectPath);
897
+ try {
898
+ assertWikiWorkspaceWritePath(reportsRoot, projectPath);
899
+ fs.mkdirSync(reportsRoot, { recursive: true });
900
+ const reportPath = path.join(reportsRoot, `lint-report-${todayStamp()}.md`);
901
+ assertWikiWorkspaceWritePath(reportPath, projectPath);
902
+ const report = renderReport({
903
+ projectPath,
904
+ workspaceRoot,
905
+ findings: analysis.findings,
906
+ pageCount: analysis.pageCount,
907
+ personalBrainMetrics: analysis.personalBrainMetrics,
908
+ });
909
+ fs.writeFileSync(reportPath, report + "\n", "utf-8");
910
+ return {
911
+ reportPath,
912
+ totalFindings: totalFindings(analysis.findings),
913
+ findings: analysis.findings,
914
+ personalBrainMetrics: analysis.personalBrainMetrics,
915
+ };
916
+ } catch (error) {
917
+ if (error instanceof CliError) throw error;
918
+ throw new CliError(`Failed to write SDTK-BRAIN lint report: ${error.message}`);
919
+ }
920
+ }
921
+
922
+ module.exports = {
923
+ CATEGORY_DEFS,
924
+ REQUIRED_PERSONAL_BRAIN_FIELDS,
925
+ REQUIRED_PAGE_FIELDS,
926
+ analyzePages,
927
+ parseFrontmatter,
928
+ renderReport,
929
+ runWikiLint,
930
+ };