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.
- package/README.md +23 -18
- package/package.json +1 -1
- package/src/commands/enrich.js +51 -0
- package/src/commands/help.js +16 -11
- package/src/commands/lint.js +1 -1
- package/src/commands/operations.js +5 -4
- package/src/commands/search.js +5 -4
- package/src/commands/wiki.js +7 -6
- package/src/index.js +4 -0
- package/src/lib/wiki-compile.js +776 -28
- package/src/lib/wiki-enrich.js +264 -0
- package/src/lib/wiki-extract.js +12 -12
- package/src/lib/wiki-lint.js +90 -29
- package/src/lib/wiki-paths.js +55 -0
- package/src/lib/wiki-search.js +17 -10
|
@@ -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
|
+
};
|
package/src/lib/wiki-extract.js
CHANGED
|
@@ -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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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 ||
|
|
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:
|
|
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:
|
|
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:
|
|
1232
|
+
target_page_path: `wiki/syntheses/${topicSlug}.md`,
|
|
1233
1233
|
});
|
|
1234
1234
|
}
|
|
1235
1235
|
|
package/src/lib/wiki-lint.js
CHANGED
|
@@ -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", "
|
|
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 = /\[[^\]]
|
|
181
|
+
const matcher = /\[([^\]]+)\]\(([^)]+)\)/g;
|
|
164
182
|
let match;
|
|
165
183
|
while ((match = matcher.exec(body || "")) !== null) {
|
|
166
|
-
const
|
|
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(
|
|
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
|
|
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",
|
|
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
|
|
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",
|
|
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
|
-
|
|
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
|
-
|
|
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",
|
|
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",
|
|
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 (
|
|
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
|
-
|
|
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",
|
|
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
|
|
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",
|
|
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
|
|
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.
|
|
730
|
-
appendFinding(findings, "personalBrainQuality", `
|
|
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 ["##
|
|
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
|
-
"##
|
|
812
|
+
"## Local Wiki Quality Metrics",
|
|
760
813
|
"",
|
|
761
|
-
`-
|
|
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
|
-
`-
|
|
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
|
|
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}`,
|