sdtk-wiki-kit 0.1.3 → 0.1.4

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,264 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const { ValidationError } = require("./errors");
6
+ const {
7
+ assertWikiWorkspaceWritePath,
8
+ getPreferredWikiContentPath,
9
+ getWikiReportsPath,
10
+ isPathInsideOrEqual,
11
+ resolveProjectPath,
12
+ } = require("./wiki-paths");
13
+
14
+ const REPORT_PREFIX = "github-enrichment-review";
15
+
16
+ function toPosix(value) {
17
+ return String(value || "").replace(/\\/g, "/");
18
+ }
19
+
20
+ function todayStamp() {
21
+ return new Date().toISOString().slice(0, 10);
22
+ }
23
+
24
+ function parseFrontmatter(text) {
25
+ const source = String(text || "");
26
+ const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
27
+ if (!match) return { fields: {}, body: source };
28
+ const fields = {};
29
+ for (const line of match[1].split(/\r?\n/)) {
30
+ const separator = line.indexOf(":");
31
+ if (separator === -1) continue;
32
+ const key = line.slice(0, separator).trim();
33
+ const value = line.slice(separator + 1).trim();
34
+ fields[key] = value.replace(/^["']|["']$/g, "");
35
+ }
36
+ return { fields, body: source.slice(match[0].length) };
37
+ }
38
+
39
+ function parseInlineList(value) {
40
+ const text = String(value || "").trim();
41
+ if (!text || text === "[]") return [];
42
+ const inner = text.startsWith("[") && text.endsWith("]") ? text.slice(1, -1) : text;
43
+ return inner
44
+ .split(",")
45
+ .map((item) => item.trim().replace(/^["']|["']$/g, ""))
46
+ .filter(Boolean);
47
+ }
48
+
49
+ function collectMarkdownFiles(rootPath) {
50
+ const files = [];
51
+ if (!fs.existsSync(rootPath)) return files;
52
+ function visit(current) {
53
+ const stat = fs.statSync(current);
54
+ if (stat.isDirectory()) {
55
+ for (const child of fs.readdirSync(current).sort()) {
56
+ visit(path.join(current, child));
57
+ }
58
+ return;
59
+ }
60
+ if (stat.isFile() && current.toLowerCase().endsWith(".md")) {
61
+ files.push(current);
62
+ }
63
+ }
64
+ visit(rootPath);
65
+ return files.sort((a, b) => toPosix(a).localeCompare(toPosix(b)));
66
+ }
67
+
68
+ function extractGithubRepos(text) {
69
+ const repos = [];
70
+ const seen = new Set();
71
+ const matcher = /https?:\/\/github\.com\/([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))\/([A-Za-z0-9._-]+)/gi;
72
+ let match;
73
+ while ((match = matcher.exec(String(text || ""))) !== null) {
74
+ const owner = match[1];
75
+ const repo = match[2].replace(/[).,;:]+$/g, "").replace(/\.git$/i, "");
76
+ if (!repo || repo.includes("...")) continue;
77
+ const url = `https://github.com/${owner}/${repo}`;
78
+ const key = url.toLowerCase();
79
+ if (seen.has(key)) continue;
80
+ seen.add(key);
81
+ repos.push({ owner, repo, url });
82
+ }
83
+ return repos;
84
+ }
85
+
86
+ function firstSection(body, heading) {
87
+ const escaped = String(heading).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
88
+ const matcher = new RegExp(`^##\\s+${escaped}\\s*$([\\s\\S]*?)(?=^##\\s+|$)`, "im");
89
+ const match = String(body || "").match(matcher);
90
+ if (!match) return "";
91
+ return match[1].replace(/\s+/g, " ").trim();
92
+ }
93
+
94
+ function extractProvenanceRefs(body) {
95
+ const section = firstSection(body, "Provenance");
96
+ if (!section) return [];
97
+ return section
98
+ .split(/\s*-\s+/)
99
+ .map((item) => item.trim())
100
+ .filter((item) => item.startsWith("prov_"))
101
+ .slice(0, 12);
102
+ }
103
+
104
+ function recordFromPage(filePath, contentRoot) {
105
+ const text = fs.readFileSync(filePath, "utf-8");
106
+ const parsed = parseFrontmatter(text);
107
+ const fields = parsed.fields;
108
+ const repos = extractGithubRepos(text);
109
+ if (repos.length === 0) return [];
110
+
111
+ const relPath = toPosix(path.relative(contentRoot.path, filePath));
112
+ const type = String(fields.type || "");
113
+ const title = String(fields.title || path.basename(filePath, ".md"));
114
+ const tags = parseInlineList(fields.tags);
115
+ const sourceRefs = parseInlineList(fields.source_refs);
116
+ const summary = firstSection(parsed.body, "Summary");
117
+ const provenanceRefs = extractProvenanceRefs(parsed.body);
118
+
119
+ return repos.map((repo) => ({
120
+ record_type: "sdtk_wiki_github_enrichment_candidate",
121
+ status: "review_only_local_identity",
122
+ page_path: `${toPosix(contentRoot.relative)}/${relPath}`,
123
+ page_type: type || "unknown",
124
+ page_title: title,
125
+ repo_url: repo.url,
126
+ source_url: repo.url,
127
+ owner: repo.owner,
128
+ repo: repo.repo,
129
+ local_summary: summary || null,
130
+ local_topics: tags,
131
+ source_refs: sourceRefs,
132
+ provenance_refs: provenanceRefs,
133
+ confidence: fields.confidence || "unknown",
134
+ metadata: {
135
+ stars: null,
136
+ license: null,
137
+ description: null,
138
+ current_owner: null,
139
+ default_branch: null,
140
+ last_verified_at: null,
141
+ },
142
+ fetch_timestamp: null,
143
+ fetch_status: "not_fetched_local_review_mode",
144
+ failure_status: "not_fetched_local_review_mode",
145
+ failure_reason: "Network metadata was not requested in review-only local mode.",
146
+ enrichment_mode: "review",
147
+ mutation: "none",
148
+ }));
149
+ }
150
+
151
+ function uniqueRecords(records) {
152
+ const byRepo = new Map();
153
+ for (const record of records) {
154
+ const key = record.repo_url.toLowerCase();
155
+ const existing = byRepo.get(key);
156
+ if (!existing) {
157
+ byRepo.set(key, record);
158
+ continue;
159
+ }
160
+ existing.page_path = existing.page_path || record.page_path;
161
+ existing.local_topics = Array.from(new Set([...existing.local_topics, ...record.local_topics])).sort();
162
+ existing.source_refs = Array.from(new Set([...existing.source_refs, ...record.source_refs])).sort();
163
+ existing.provenance_refs = Array.from(new Set([...existing.provenance_refs, ...record.provenance_refs])).sort();
164
+ }
165
+ return [...byRepo.values()].sort((a, b) => a.repo_url.localeCompare(b.repo_url));
166
+ }
167
+
168
+ function renderMarkdownReport({ projectPath, reportJsonPath, records, contentRoot }) {
169
+ const lines = [
170
+ "# SDTK-WIKI GitHub Enrichment Review",
171
+ "",
172
+ `Date: ${todayStamp()}`,
173
+ `Project root: \`${projectPath}\``,
174
+ `Wiki content root: \`${contentRoot.path}\``,
175
+ `Wiki content mode: \`${contentRoot.mode}\``,
176
+ `JSON report: \`${reportJsonPath}\``,
177
+ "",
178
+ "Report-only and non-destructive: this command does not fetch network metadata, rewrite pages, mutate sources, or touch `.sdtk/atlas`.",
179
+ "Network-backed GitHub verification is deferred to a later controller-approved slice.",
180
+ "",
181
+ "## Summary",
182
+ "",
183
+ `- candidates: ${records.length}`,
184
+ "- source: github",
185
+ "- mode: review",
186
+ "- fetch status: not_fetched_local_review_mode",
187
+ "",
188
+ "## Candidates",
189
+ "",
190
+ "| Repo | Page | Confidence | Source refs | Fetch status | Pending metadata |",
191
+ "|---|---|---|---:|---|---|",
192
+ ];
193
+
194
+ for (const record of records) {
195
+ lines.push(
196
+ `| [${record.owner}/${record.repo}](${record.repo_url}) | \`${record.page_path}\` | ${record.confidence} | ${record.source_refs.length} | ${record.fetch_status} | stars, license, description, current owner |`
197
+ );
198
+ }
199
+ if (records.length === 0) {
200
+ lines.push("| none | - | - | 0 | - | - |");
201
+ }
202
+
203
+ lines.push(
204
+ "",
205
+ "## Review Notes",
206
+ "",
207
+ "- Treat every candidate as locally identified only until a future explicit network/enrichment apply issue verifies it.",
208
+ "- Do not copy stars, license, or activity claims into generated pages from this report unless those fields are later verified.",
209
+ ""
210
+ );
211
+ return lines.join("\n");
212
+ }
213
+
214
+ function runWikiGithubEnrichmentReview(options = {}) {
215
+ const projectPath = resolveProjectPath(options.projectPath || process.cwd());
216
+ if (!fs.existsSync(projectPath) || !fs.statSync(projectPath).isDirectory()) {
217
+ throw new ValidationError(`--project-path is not a valid directory: ${projectPath}`);
218
+ }
219
+
220
+ const contentRoot = getPreferredWikiContentPath(projectPath);
221
+ if (!isPathInsideOrEqual(contentRoot.path, projectPath)) {
222
+ throw new ValidationError("Refusing to read local wiki pages outside the project root.");
223
+ }
224
+ if (!fs.existsSync(contentRoot.path) || !fs.statSync(contentRoot.path).isDirectory()) {
225
+ throw new ValidationError(
226
+ `No SDTK-WIKI local wiki found at ${contentRoot.path}. Run "sdtk-wiki ingest <source-root>" and "sdtk-wiki compile --mode safe --apply" first.`
227
+ );
228
+ }
229
+
230
+ const records = uniqueRecords(collectMarkdownFiles(contentRoot.path).flatMap((filePath) =>
231
+ recordFromPage(filePath, contentRoot)
232
+ ));
233
+
234
+ const reportsRoot = getWikiReportsPath(projectPath);
235
+ assertWikiWorkspaceWritePath(reportsRoot, projectPath);
236
+ fs.mkdirSync(reportsRoot, { recursive: true });
237
+ const jsonPath = path.join(reportsRoot, `${REPORT_PREFIX}-${todayStamp()}.json`);
238
+ const markdownPath = path.join(reportsRoot, `${REPORT_PREFIX}-${todayStamp()}.md`);
239
+ assertWikiWorkspaceWritePath(jsonPath, projectPath);
240
+ assertWikiWorkspaceWritePath(markdownPath, projectPath);
241
+
242
+ const payload = {
243
+ schema_version: 1,
244
+ record_type: "sdtk_wiki_github_enrichment_review",
245
+ generated_at: new Date().toISOString(),
246
+ project_path: projectPath,
247
+ wiki_content_path: contentRoot.path,
248
+ wiki_content_mode: contentRoot.mode,
249
+ source: "github",
250
+ mode: "review",
251
+ network_fetch: false,
252
+ mutation: "none",
253
+ candidate_count: records.length,
254
+ records,
255
+ };
256
+ fs.writeFileSync(jsonPath, JSON.stringify(payload, null, 2) + "\n", "utf-8");
257
+ fs.writeFileSync(markdownPath, renderMarkdownReport({ projectPath, reportJsonPath: jsonPath, records, contentRoot }), "utf-8");
258
+
259
+ return { markdownPath, jsonPath, records, projectPath };
260
+ }
261
+
262
+ module.exports = {
263
+ runWikiGithubEnrichmentReview,
264
+ };
@@ -299,7 +299,7 @@ function conceptFromTopic(topic) {
299
299
  provenance_refs: [],
300
300
  confidence: 0.6,
301
301
  confidence_tier: "medium",
302
- target_page_path: `.sdtk/wiki/personal-brain/concepts/${slug}.md`,
302
+ target_page_path: `wiki/concepts/${slug}.md`,
303
303
  };
304
304
  }
305
305
 
@@ -547,7 +547,7 @@ function buildExtraction({ projectPath, sourceRoot }) {
547
547
  notes: qualityNotes,
548
548
  },
549
549
  provenance_refs: [],
550
- target_page_path: `.sdtk/wiki/personal-brain/sources/${sourceSlug}.md`,
550
+ target_page_path: `wiki/sources/${sourceSlug}.md`,
551
551
  };
552
552
 
553
553
  sources.push(sourceRecord);
@@ -620,7 +620,7 @@ function buildExtraction({ projectPath, sourceRoot }) {
620
620
  evidence_snippets: [],
621
621
  discovery_sources: [],
622
622
  evidence_records: [],
623
- target_page_path: `.sdtk/wiki/personal-brain/entities/tools/${safeSlug(repo.repo, "tool")}--${entityId}.md`,
623
+ target_page_path: `wiki/entities/tools/${safeSlug(repo.repo, "tool")}--${entityId}.md`,
624
624
  });
625
625
  }
626
626
  const entity = toolEntitiesById.get(entityId);
@@ -683,7 +683,7 @@ function buildExtraction({ projectPath, sourceRoot }) {
683
683
  provenance_refs: [],
684
684
  confidence: 0.65,
685
685
  confidence_tier: "medium",
686
- target_page_path: `.sdtk/wiki/personal-brain/concepts/${safeSlug(conceptRule.name, "concept")}.md`,
686
+ target_page_path: `wiki/concepts/${safeSlug(conceptRule.name, "concept")}.md`,
687
687
  });
688
688
  }
689
689
  const concept = conceptsById.get(conceptRule.concept_id);
@@ -745,7 +745,7 @@ function buildExtraction({ projectPath, sourceRoot }) {
745
745
  notes: [`Invalid JSON could not be parsed: ${error.message}`],
746
746
  },
747
747
  provenance_refs: [],
748
- target_page_path: `.sdtk/wiki/personal-brain/sources/${safeSlug(path.basename(filePath), "json-source")}--${sourceId.slice(0, 8)}.md`,
748
+ target_page_path: `wiki/sources/${safeSlug(path.basename(filePath), "json-source")}--${sourceId.slice(0, 8)}.md`,
749
749
  };
750
750
  sources.push(sourceRecord);
751
751
  sourceQualityFindings.push({
@@ -800,7 +800,7 @@ function buildExtraction({ projectPath, sourceRoot }) {
800
800
  notes: ["JSON parsed successfully but contained no supported repository records."],
801
801
  },
802
802
  provenance_refs: [],
803
- target_page_path: `.sdtk/wiki/personal-brain/sources/${safeSlug(path.basename(filePath), "json-source")}--${sourceId.slice(0, 8)}.md`,
803
+ target_page_path: `wiki/sources/${safeSlug(path.basename(filePath), "json-source")}--${sourceId.slice(0, 8)}.md`,
804
804
  };
805
805
  sources.push(sourceRecord);
806
806
  sourceQualityFindings.push({
@@ -892,7 +892,7 @@ function buildExtraction({ projectPath, sourceRoot }) {
892
892
  notes: qualityNotes,
893
893
  },
894
894
  provenance_refs: [],
895
- target_page_path: `.sdtk/wiki/personal-brain/sources/${safeSlug(title || sourceRelativePath, "source")}--${sourceId.slice(0, 8)}.md`,
895
+ target_page_path: `wiki/sources/${safeSlug(title || sourceRelativePath, "source")}--${sourceId.slice(0, 8)}.md`,
896
896
  };
897
897
 
898
898
  sources.push(sourceRecord);
@@ -987,7 +987,7 @@ function buildExtraction({ projectPath, sourceRoot }) {
987
987
  confidence,
988
988
  confidence_tier: confidenceBand,
989
989
  }],
990
- target_page_path: `.sdtk/wiki/personal-brain/entities/tools/${safeSlug(repo.repo, "tool")}--${entityId}.md`,
990
+ target_page_path: `wiki/entities/tools/${safeSlug(repo.repo, "tool")}--${entityId}.md`,
991
991
  });
992
992
  }
993
993
  const entity = toolEntitiesById.get(entityId);
@@ -1052,7 +1052,7 @@ function buildExtraction({ projectPath, sourceRoot }) {
1052
1052
  provenance_refs: conceptRule.provenance_refs || [],
1053
1053
  confidence: conceptRule.confidence || 0.6,
1054
1054
  confidence_tier: conceptRule.confidence_tier || "medium",
1055
- target_page_path: conceptRule.target_page_path || `.sdtk/wiki/personal-brain/concepts/${safeSlug(conceptRule.name, "concept")}.md`,
1055
+ target_page_path: conceptRule.target_page_path || `wiki/concepts/${safeSlug(conceptRule.name, "concept")}.md`,
1056
1056
  });
1057
1057
  }
1058
1058
  const concept = conceptsById.get(conceptRule.concept_id);
@@ -1205,7 +1205,7 @@ function buildExtraction({ projectPath, sourceRoot }) {
1205
1205
  provenance_refs: concept.provenance_refs,
1206
1206
  confidence: 0.55,
1207
1207
  confidence_tier: "medium",
1208
- target_page_path: `.sdtk/wiki/personal-brain/comparisons/${topicSlug}.md`,
1208
+ target_page_path: `wiki/comparisons/${topicSlug}.md`,
1209
1209
  });
1210
1210
  syntheses.push({
1211
1211
  synthesis_id: `synthesis_${topicSlug}_${sha256(concept.source_refs.join("|")).slice(0, 8)}`,
@@ -1214,7 +1214,7 @@ function buildExtraction({ projectPath, sourceRoot }) {
1214
1214
  landscape_axes: decisionAxes,
1215
1215
  candidate_tools: matrixRows,
1216
1216
  patterns: asArray(concept.patterns),
1217
- related_comparison_path: `.sdtk/wiki/personal-brain/comparisons/${topicSlug}.md`,
1217
+ related_comparison_path: `wiki/comparisons/${topicSlug}.md`,
1218
1218
  source_confidence_summary: confidenceSummary(matrixRows),
1219
1219
  recommendations: [
1220
1220
  ...recommendations,
@@ -1229,7 +1229,7 @@ function buildExtraction({ projectPath, sourceRoot }) {
1229
1229
  provenance_refs: concept.provenance_refs,
1230
1230
  confidence: 0.55,
1231
1231
  confidence_tier: "medium",
1232
- target_page_path: `.sdtk/wiki/personal-brain/syntheses/${topicSlug}.md`,
1232
+ target_page_path: `wiki/syntheses/${topicSlug}.md`,
1233
1233
  });
1234
1234
  }
1235
1235
 
@@ -7,6 +7,7 @@ const {
7
7
  assertWikiWorkspaceWritePath,
8
8
  getWikiGraphPath,
9
9
  getWikiPagesPath,
10
+ getPreferredWikiContentPath,
10
11
  getWikiProvenanceSourcesPath,
11
12
  getWikiRawSourcesPath,
12
13
  getWikiReportsPath,
@@ -55,7 +56,7 @@ const CATEGORY_DEFS = [
55
56
  ["markers", "TODO/Open Questions/Gaps"],
56
57
  ["contradictions", "Candidate contradictions"],
57
58
  ["sourceQuality", "Source quality"],
58
- ["personalBrainQuality", "Personal-brain quality gate"],
59
+ ["personalBrainQuality", "Local wiki quality gate"],
59
60
  ];
60
61
 
61
62
  const REQUIRED_PERSONAL_BRAIN_SECTIONS = {
@@ -69,6 +70,8 @@ const REQUIRED_PERSONAL_BRAIN_SECTIONS = {
69
70
 
70
71
  const PERSONAL_BRAIN_STUB_CHAR_LIMIT = 600;
71
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"]);
72
75
 
73
76
  function toPosix(value) {
74
77
  return String(value || "").replace(/\\/g, "/");
@@ -158,24 +161,54 @@ function listMarkdownPages(rootPath) {
158
161
  return files;
159
162
  }
160
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
+
161
179
  function extractMarkdownLinks(body) {
162
180
  const links = [];
163
- const matcher = /\[[^\]]+\]\(([^)]+)\)/g;
181
+ const matcher = /\[([^\]]+)\]\(([^)]+)\)/g;
164
182
  let match;
165
183
  while ((match = matcher.exec(body || "")) !== null) {
166
- const rawTarget = String(match[1] || "").trim();
184
+ const rawLabel = String(match[1] || "").trim();
185
+ const rawTarget = String(match[2] || "").trim();
167
186
  if (!rawTarget) continue;
168
- if (
169
- rawTarget.startsWith("#") ||
170
- /^(?:https?:|mailto:|tel:)/i.test(rawTarget)
171
- ) {
187
+ if (isExternalMarkdownTarget(rawTarget)) {
172
188
  continue;
173
189
  }
174
- links.push(rawTarget);
190
+ links.push({
191
+ target: rawTarget,
192
+ label: rawLabel,
193
+ context: truncateForReport(rawLabel || rawTarget),
194
+ });
175
195
  }
176
196
  return links;
177
197
  }
178
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
+
179
212
  function hasHeading(body, heading) {
180
213
  const escaped = String(heading).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
181
214
  return new RegExp(`^##\\s+${escaped}\\s*$`, "im").test(String(body || ""));
@@ -468,11 +501,12 @@ function analyzePages(projectPath) {
468
501
  const pagesByRelPath = new Map(pages.map((page) => [toPosix(path.normalize(page.relPath)), page]));
469
502
  for (const page of pages) {
470
503
  for (const link of page.links) {
471
- const rawPath = link.split("#")[0].trim();
504
+ const target = typeof link === "string" ? link : link.target;
505
+ const rawPath = target.split("#")[0].trim();
472
506
  if (!rawPath) continue;
473
507
  const resolved = path.resolve(path.dirname(page.filePath), rawPath);
474
508
  if (!isPathInsideOrEqual(resolved, inputs.pagesRoot) || !fs.existsSync(resolved)) {
475
- appendFinding(findings, "brokenLinks", `\`${page.relPath}\` links to missing \`${link}\`.`);
509
+ appendFinding(findings, "brokenLinks", formatBrokenLinkFinding(page.relPath, link));
476
510
  continue;
477
511
  }
478
512
  const targetRel = toPosix(path.relative(inputs.pagesRoot, resolved));
@@ -602,16 +636,27 @@ function analyzePages(projectPath) {
602
636
  }
603
637
 
604
638
  function analyzePersonalBrainPages(projectPath, findings) {
605
- const personalBrainRoot = path.join(getWikiWorkspacePath(projectPath), "personal-brain");
639
+ const contentRoot = getPreferredWikiContentPath(projectPath);
640
+ const personalBrainRoot = contentRoot.path;
641
+ const contentRelative = toPosix(contentRoot.relative);
606
642
  const pageFiles = listMarkdownPages(personalBrainRoot);
607
643
  const pages = [];
608
644
  const metrics = {
609
645
  pageCount: pageFiles.length,
646
+ contentRoot: personalBrainRoot,
647
+ contentMode: contentRoot.mode,
648
+ contentRelative,
610
649
  byType: {},
611
650
  frontmatterCoverage: 0,
612
651
  requiredSectionCoverage: 0,
613
652
  sourceRefsCoverage: 0,
614
653
  stubRatio: 0,
654
+ sourcePageCount: 0,
655
+ sourceThinAnchorCount: 0,
656
+ sourceThinAnchorRatio: 0,
657
+ semanticPageCount: 0,
658
+ semanticStubCount: 0,
659
+ semanticStubRatio: 0,
615
660
  giantPageCount: 0,
616
661
  brokenInternalLinks: 0,
617
662
  conceptCount: 0,
@@ -636,13 +681,15 @@ function analyzePersonalBrainPages(projectPath, findings) {
636
681
  const type = String(fields.type || "unknown");
637
682
  pages.push({ filePath, relPath, fields, body: parsed.body, hasFrontmatter: parsed.hasFrontmatter, type });
638
683
  metrics.byType[type] = (metrics.byType[type] || 0) + 1;
684
+ if (type === "source") metrics.sourcePageCount += 1;
685
+ if (STRICT_SEMANTIC_PERSONAL_BRAIN_TYPES.has(type)) metrics.semanticPageCount += 1;
639
686
  if (type === "concept") metrics.conceptCount += 1;
640
687
  if (type === "tool_entity") metrics.entityCount += 1;
641
688
  if (type === "comparison") metrics.comparisonCount += 1;
642
689
  if (type === "synthesis") metrics.synthesisCount += 1;
643
690
 
644
691
  if (!parsed.hasFrontmatter) {
645
- appendFinding(findings, "schema", `personal-brain/${relPath} is missing parseable frontmatter.`);
692
+ appendFinding(findings, "schema", `${contentRelative}/${relPath} is missing parseable frontmatter.`);
646
693
  continue;
647
694
  }
648
695
  frontmatterCount += 1;
@@ -652,7 +699,7 @@ function analyzePersonalBrainPages(projectPath, findings) {
652
699
  appendFinding(
653
700
  findings,
654
701
  "schema",
655
- `personal-brain/${relPath} is missing required personal-brain fields: ${missingFields.join(", ")}.`
702
+ `${contentRelative}/${relPath} is missing required local wiki fields: ${missingFields.join(", ")}.`
656
703
  );
657
704
  }
658
705
 
@@ -664,14 +711,14 @@ function analyzePersonalBrainPages(projectPath, findings) {
664
711
  appendFinding(
665
712
  findings,
666
713
  "personalBrainQuality",
667
- `personal-brain/${relPath} is missing required sections for ${type}: ${missingSections.join(", ")}.`
714
+ `${contentRelative}/${relPath} is missing required sections for ${type}: ${missingSections.join(", ")}.`
668
715
  );
669
716
  }
670
717
 
671
718
  if (!["root"].includes(type)) {
672
719
  sourceRefsEligible += 1;
673
720
  if (hasSourceRefs(fields)) sourceRefsPresent += 1;
674
- else appendFinding(findings, "personalBrainQuality", `personal-brain/${relPath} has no source_refs coverage.`);
721
+ else appendFinding(findings, "personalBrainQuality", `${contentRelative}/${relPath} has no source_refs coverage.`);
675
722
  }
676
723
 
677
724
  if (["tool_entity", "concept", "comparison", "synthesis"].includes(type)) {
@@ -679,30 +726,34 @@ function analyzePersonalBrainPages(projectPath, findings) {
679
726
  if (hasSourceRefs(fields) && /(?:source|provenance|evidence)/i.test(parsed.body)) {
680
727
  sourceEvidencePresent += 1;
681
728
  } else {
682
- appendFinding(findings, "personalBrainQuality", `personal-brain/${relPath} lacks source evidence coverage in body/frontmatter.`);
729
+ appendFinding(findings, "personalBrainQuality", `${contentRelative}/${relPath} lacks source evidence coverage in body/frontmatter.`);
683
730
  }
684
731
  }
685
732
 
686
733
  const compactBody = parsed.body.replace(/\s+/g, " ").trim();
687
- if (compactBody.length < PERSONAL_BRAIN_STUB_CHAR_LIMIT && !["root"].includes(type)) {
734
+ if (type === "source") {
735
+ metrics.sourceThinAnchorCount += 1;
736
+ } else if (compactBody.length < PERSONAL_BRAIN_STUB_CHAR_LIMIT && STRICT_SEMANTIC_PERSONAL_BRAIN_TYPES.has(type)) {
688
737
  stubCount += 1;
689
- appendFinding(findings, "personalBrainQuality", `personal-brain/${relPath} appears stub-like (${compactBody.length} normalized characters).`);
738
+ metrics.semanticStubCount += 1;
739
+ appendFinding(findings, "personalBrainQuality", `${contentRelative}/${relPath} appears stub-like (${compactBody.length} normalized characters).`);
690
740
  }
691
741
 
692
742
  const byteSize = fs.statSync(filePath).size;
693
743
  if (byteSize > PERSONAL_BRAIN_GIANT_PAGE_BYTES) {
694
744
  metrics.giantPageCount += 1;
695
- appendFinding(findings, "personalBrainQuality", `personal-brain/${relPath} is very large (${byteSize} bytes) and may need splitting.`);
745
+ appendFinding(findings, "personalBrainQuality", `${contentRelative}/${relPath} is very large (${byteSize} bytes) and may need splitting.`);
696
746
  }
697
747
  }
698
748
 
699
749
  for (const page of pages) {
700
750
  for (const link of extractMarkdownLinks(page.body)) {
701
- const rawPath = link.split("#")[0].trim();
751
+ const target = typeof link === "string" ? link : link.target;
752
+ const rawPath = target.split("#")[0].trim();
702
753
  if (!rawPath) continue;
703
754
  const resolved = path.resolve(path.dirname(page.filePath), rawPath);
704
755
  if (!isPathInsideOrEqual(resolved, personalBrainRoot) || !fs.existsSync(resolved)) {
705
- appendFinding(findings, "brokenLinks", `personal-brain/${page.relPath} links to missing ${link}.`);
756
+ appendFinding(findings, "brokenLinks", formatBrokenLinkFinding(`${contentRelative}/${page.relPath}`, link));
706
757
  metrics.brokenInternalLinks += 1;
707
758
  }
708
759
  }
@@ -712,10 +763,12 @@ function analyzePersonalBrainPages(projectPath, findings) {
712
763
  metrics.requiredSectionCoverage = sectionRequiredTotal > 0 ? sectionPresentTotal / sectionRequiredTotal : 1;
713
764
  metrics.sourceRefsCoverage = sourceRefsEligible > 0 ? sourceRefsPresent / sourceRefsEligible : 1;
714
765
  metrics.stubRatio = pageFiles.length > 0 ? stubCount / pageFiles.length : 0;
766
+ metrics.sourceThinAnchorRatio = metrics.sourcePageCount > 0 ? metrics.sourceThinAnchorCount / metrics.sourcePageCount : 0;
767
+ metrics.semanticStubRatio = metrics.semanticPageCount > 0 ? metrics.semanticStubCount / metrics.semanticPageCount : 0;
715
768
  metrics.sourceEvidenceCoverage = sourceEvidenceEligible > 0 ? sourceEvidencePresent / sourceEvidenceEligible : 1;
716
769
 
717
770
  if (pageFiles.length === 0) {
718
- appendFinding(findings, "personalBrainQuality", "No personal-brain pages were found under .sdtk/wiki/personal-brain.");
771
+ appendFinding(findings, "personalBrainQuality", "No local wiki pages were found under wiki/ or legacy .sdtk/wiki/personal-brain.");
719
772
  }
720
773
  if (metrics.frontmatterCoverage < 1) {
721
774
  appendFinding(findings, "personalBrainQuality", `Frontmatter coverage is ${formatPercent(metrics.frontmatterCoverage)}; expected 100%.`);
@@ -726,8 +779,8 @@ function analyzePersonalBrainPages(projectPath, findings) {
726
779
  if (metrics.sourceRefsCoverage < 0.9) {
727
780
  appendFinding(findings, "personalBrainQuality", `Source refs coverage is ${formatPercent(metrics.sourceRefsCoverage)}; expected at least 90%.`);
728
781
  }
729
- if (metrics.stubRatio > 0.1) {
730
- appendFinding(findings, "personalBrainQuality", `Stub ratio is ${formatPercent(metrics.stubRatio)}; expected at most 10%.`);
782
+ if (metrics.semanticStubRatio > 0.1) {
783
+ appendFinding(findings, "personalBrainQuality", `Semantic stub ratio is ${formatPercent(metrics.semanticStubRatio)}; expected at most 10% for tool/concept/comparison/synthesis pages.`);
731
784
  }
732
785
  if (metrics.entityCount > 0 && metrics.conceptCount === 0) {
733
786
  appendFinding(findings, "personalBrainQuality", "Tool/entity pages exist but no concept pages were generated.");
@@ -751,21 +804,29 @@ function totalFindings(findings) {
751
804
  }
752
805
 
753
806
  function renderPersonalBrainMetrics(metrics) {
754
- if (!metrics) return ["## Personal-Brain Quality Metrics", "", "- No personal-brain metrics available.", ""];
807
+ if (!metrics) return ["## Local Wiki Quality Metrics", "", "- No local wiki metrics available.", ""];
755
808
  const typeRows = Object.entries(metrics.byType || {})
756
809
  .sort(([a], [b]) => a.localeCompare(b))
757
810
  .map(([type, count]) => `| ${type} | ${count} |`);
758
811
  return [
759
- "## Personal-Brain Quality Metrics",
812
+ "## Local Wiki Quality Metrics",
760
813
  "",
761
- `- personal-brain pages: ${metrics.pageCount}`,
814
+ `- wiki content root: ${metrics.contentRoot}`,
815
+ `- wiki content mode: ${metrics.contentMode}`,
816
+ `- local wiki pages: ${metrics.pageCount}`,
762
817
  `- frontmatter coverage: ${formatPercent(metrics.frontmatterCoverage)}`,
763
818
  `- required section coverage: ${formatPercent(metrics.requiredSectionCoverage)}`,
764
819
  `- source refs coverage: ${formatPercent(metrics.sourceRefsCoverage)}`,
765
820
  `- source evidence coverage: ${formatPercent(metrics.sourceEvidenceCoverage)}`,
766
- `- stub ratio: ${formatPercent(metrics.stubRatio)}`,
821
+ `- source pages: ${metrics.sourcePageCount}`,
822
+ `- source pages accepted as provenance anchors: ${metrics.sourceThinAnchorCount}`,
823
+ `- source-page thin-anchor ratio: ${formatPercent(metrics.sourceThinAnchorRatio)}`,
824
+ `- strict semantic pages: ${metrics.semanticPageCount}`,
825
+ `- strict semantic stub pages: ${metrics.semanticStubCount}`,
826
+ `- semantic stub ratio: ${formatPercent(metrics.semanticStubRatio)}`,
827
+ `- stub ratio policy: source pages are measured separately; strict stub findings apply to tool_entity, concept, comparison, and synthesis pages.`,
767
828
  `- giant page warnings: ${metrics.giantPageCount}`,
768
- `- broken personal-brain internal links: ${metrics.brokenInternalLinks}`,
829
+ `- broken local wiki internal links: ${metrics.brokenInternalLinks}`,
769
830
  `- entity pages: ${metrics.entityCount}`,
770
831
  `- concept pages: ${metrics.conceptCount}`,
771
832
  `- comparison pages: ${metrics.comparisonCount}`,