sdtk-wiki-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,271 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const { CliError, ValidationError } = require("./errors");
6
+ const { analyzePages } = require("./wiki-lint");
7
+ const {
8
+ assertWikiWorkspaceWritePath,
9
+ getWikiProvenanceSourcesPath,
10
+ getWikiRawSourcesPath,
11
+ getWikiReportsPath,
12
+ getWikiWorkspacePath,
13
+ resolveProjectPath,
14
+ } = require("./wiki-paths");
15
+
16
+ const REPORT_PREFIX = "discover-plan";
17
+
18
+ const CATEGORY_LABELS = {
19
+ missing_source_coverage: "Missing source coverage",
20
+ broken_internal_link_target: "Broken internal link target",
21
+ todo_open_questions_gaps: "TODO / Open Questions / Gaps",
22
+ weak_integration: "Weak integration",
23
+ stale_candidate_human_decision: "Stale candidate requiring human decision",
24
+ contradiction_candidate_source_verification: "Contradiction candidate requiring source verification",
25
+ raw_source_not_represented: "Raw source not represented in managed pages",
26
+ };
27
+
28
+ function todayStamp(date = new Date()) {
29
+ return date.toISOString().slice(0, 10);
30
+ }
31
+
32
+ function readJsonIfPresent(filePath, fallback = null) {
33
+ if (!fs.existsSync(filePath)) return fallback;
34
+ try {
35
+ return JSON.parse(fs.readFileSync(filePath, "utf-8"));
36
+ } catch (error) {
37
+ return { __error: `Could not parse JSON at ${filePath}: ${error.message}` };
38
+ }
39
+ }
40
+
41
+ function asArray(value) {
42
+ return Array.isArray(value) ? value : [];
43
+ }
44
+
45
+ function toPosix(value) {
46
+ return String(value || "").replace(/\\/g, "/");
47
+ }
48
+
49
+ function priorityForCategory(category) {
50
+ if (
51
+ category === "missing_source_coverage" ||
52
+ category === "stale_candidate_human_decision" ||
53
+ category === "contradiction_candidate_source_verification"
54
+ ) {
55
+ return "high";
56
+ }
57
+ if (category === "broken_internal_link_target" || category === "raw_source_not_represented") {
58
+ return "medium";
59
+ }
60
+ return "normal";
61
+ }
62
+
63
+ function sourceTypeForCategory(category) {
64
+ if (category === "broken_internal_link_target") {
65
+ return "missing internal page/link";
66
+ }
67
+ if (category === "raw_source_not_represented") {
68
+ return "local document to inspect";
69
+ }
70
+ if (category === "missing_source_coverage") {
71
+ return "local source coverage review";
72
+ }
73
+ return "stakeholder/manual review need";
74
+ }
75
+
76
+ function nextActionForCategory(category) {
77
+ switch (category) {
78
+ case "missing_source_coverage":
79
+ return "Review whether the referenced local source should be restored, re-ingested, or intentionally retired.";
80
+ case "broken_internal_link_target":
81
+ return "Decide whether to create the missing managed page or update the local link target.";
82
+ case "todo_open_questions_gaps":
83
+ return "Resolve the marker through local source review before compile/apply work.";
84
+ case "weak_integration":
85
+ return "Add local context, source references, or relationships in a later compile/apply workflow.";
86
+ case "stale_candidate_human_decision":
87
+ return "Review stale evidence before any future prune/archive issue is considered.";
88
+ case "contradiction_candidate_source_verification":
89
+ return "Verify the conflicting statement against local authoritative sources.";
90
+ case "raw_source_not_represented":
91
+ return "Consider whether this raw source should feed a later compile/additive update plan.";
92
+ default:
93
+ return "Review locally.";
94
+ }
95
+ }
96
+
97
+ function addPlanItems(items, category, evidenceItems, evidenceSource) {
98
+ for (const evidence of evidenceItems) {
99
+ items.push({
100
+ category,
101
+ evidence,
102
+ evidenceSource,
103
+ suggestedSourceType: sourceTypeForCategory(category),
104
+ priority: priorityForCategory(category),
105
+ confidence: "heuristic",
106
+ safetyMode: "local-only; manual-review",
107
+ nextAction: nextActionForCategory(category),
108
+ });
109
+ }
110
+ }
111
+
112
+ function collectRawSourceGaps(projectPath) {
113
+ const rawPath = getWikiRawSourcesPath(projectPath);
114
+ const provenancePath = getWikiProvenanceSourcesPath(projectPath);
115
+ const raw = readJsonIfPresent(rawPath, { sources: [] });
116
+ const provenance = readJsonIfPresent(provenancePath, { sources: [] });
117
+ const represented = new Set(
118
+ asArray(provenance.sources)
119
+ .filter((record) => record && record.sourcePath)
120
+ .map((record) => toPosix(record.sourcePath))
121
+ );
122
+
123
+ return asArray(raw.sources)
124
+ .filter((record) => record && record.sourcePath)
125
+ .filter((record) => !represented.has(toPosix(record.sourcePath)))
126
+ .map((record) => `Raw source \`${toPosix(record.sourcePath)}\` is registered but not represented in active provenance.`);
127
+ }
128
+
129
+ function buildPlanItems(projectPath) {
130
+ const analysis = analyzePages(projectPath);
131
+ const findings = analysis.findings;
132
+ const items = [];
133
+
134
+ addPlanItems(items, "missing_source_coverage", findings.provenance || [], "lint.provenance");
135
+ addPlanItems(items, "broken_internal_link_target", findings.brokenLinks || [], "lint.brokenLinks");
136
+ addPlanItems(items, "todo_open_questions_gaps", findings.markers || [], "lint.markers");
137
+ addPlanItems(items, "weak_integration", [...(findings.orphans || []), ...(findings.downstream || []), ...(findings.thin || [])], "lint.integration");
138
+ addPlanItems(items, "stale_candidate_human_decision", findings.stale || [], "lint.stale");
139
+ addPlanItems(items, "contradiction_candidate_source_verification", findings.contradictions || [], "lint.contradictions");
140
+ addPlanItems(items, "raw_source_not_represented", collectRawSourceGaps(projectPath), "raw.sources");
141
+
142
+ return {
143
+ pageCount: analysis.pageCount,
144
+ items: items.map((item, index) => ({
145
+ ...item,
146
+ planItemId: `discover-${String(index + 1).padStart(3, "0")}`,
147
+ })),
148
+ };
149
+ }
150
+
151
+ function summarizeByCategory(items) {
152
+ const summary = Object.fromEntries(Object.keys(CATEGORY_LABELS).map((key) => [key, 0]));
153
+ for (const item of items) {
154
+ summary[item.category] = (summary[item.category] || 0) + 1;
155
+ }
156
+ return summary;
157
+ }
158
+
159
+ function renderReport({ projectPath, workspacePath, generatedAt, pageCount, items }) {
160
+ const summary = summarizeByCategory(items);
161
+ const lines = [
162
+ "# SDTK-WIKI Discovery Plan",
163
+ "",
164
+ `Generated: ${generatedAt}`,
165
+ `Project path: ${projectPath}`,
166
+ `Workspace path: ${workspacePath}`,
167
+ `Pages scanned: ${pageCount}`,
168
+ `Plan items: ${items.length}`,
169
+ "",
170
+ "Plan-only and local-only: No source was fetched, ingested, compiled, applied, pruned, deleted, archived, or persisted as query history.",
171
+ "No web or network discovery is executed. Future web research requires a separate controller-approved opt-in issue.",
172
+ "",
173
+ "## Input Evidence References",
174
+ "",
175
+ "- Direct lint/gap analysis of `.sdtk/wiki/pages`",
176
+ "- `.sdtk/wiki/provenance/sources.json` when present",
177
+ "- `.sdtk/wiki/raw/sources.json` when present",
178
+ "- BK-119 gap taxonomy",
179
+ "",
180
+ "## Gap Category Summary",
181
+ "",
182
+ "| Gap category | Count |",
183
+ "|---|---:|",
184
+ ...Object.entries(CATEGORY_LABELS).map(([key, label]) => `| \`${key}\` - ${label} | ${summary[key] || 0} |`),
185
+ "",
186
+ "## Plan Items",
187
+ "",
188
+ ];
189
+
190
+ if (items.length === 0) {
191
+ lines.push("No discovery plan items were generated from current local evidence.");
192
+ } else {
193
+ for (const item of items) {
194
+ lines.push(`### ${item.planItemId}`);
195
+ lines.push("");
196
+ lines.push(`- Gap category: \`${item.category}\``);
197
+ lines.push(`- Evidence source: \`${item.evidenceSource}\``);
198
+ lines.push(`- Evidence: ${item.evidence}`);
199
+ lines.push(`- Suggested source type: ${item.suggestedSourceType}`);
200
+ lines.push(`- Priority: ${item.priority}`);
201
+ lines.push(`- Confidence: ${item.confidence}`);
202
+ lines.push(`- Safety mode: ${item.safetyMode}`);
203
+ lines.push(`- Next action: ${item.nextAction}`);
204
+ lines.push("");
205
+ }
206
+ }
207
+
208
+ lines.push("");
209
+ lines.push("## Deferred Actions");
210
+ lines.push("");
211
+ lines.push("Fetch, ingest, compile, apply, prune, delete, archive, Ask changes, query persistence, and release work are outside BK-125.");
212
+ lines.push("");
213
+ return `${lines.join("\n").trimEnd()}\n`;
214
+ }
215
+
216
+ function ensureExistingWorkspace(projectPath) {
217
+ const workspacePath = getWikiWorkspacePath(projectPath);
218
+ if (!fs.existsSync(workspacePath) || !fs.statSync(workspacePath).isDirectory()) {
219
+ throw new ValidationError(
220
+ `No SDTK-WIKI workspace found at ${workspacePath}. Run "sdtk-wiki init" or "sdtk-wiki atlas build" first. No project files were changed.`
221
+ );
222
+ }
223
+ return workspacePath;
224
+ }
225
+
226
+ function runWikiDiscoverPlan({ projectPath }) {
227
+ const resolvedProjectPath = resolveProjectPath(projectPath || process.cwd());
228
+ if (!fs.existsSync(resolvedProjectPath) || !fs.statSync(resolvedProjectPath).isDirectory()) {
229
+ throw new ValidationError(`--project-path is not a valid directory: ${resolvedProjectPath}`);
230
+ }
231
+
232
+ try {
233
+ const workspacePath = ensureExistingWorkspace(resolvedProjectPath);
234
+ const reportsPath = getWikiReportsPath(resolvedProjectPath);
235
+ assertWikiWorkspaceWritePath(reportsPath, resolvedProjectPath);
236
+
237
+ const generatedAt = new Date().toISOString();
238
+ const result = buildPlanItems(resolvedProjectPath);
239
+ const reportPath = path.join(reportsPath, `${REPORT_PREFIX}-${todayStamp(new Date(generatedAt))}.md`);
240
+ assertWikiWorkspaceWritePath(reportPath, resolvedProjectPath);
241
+ fs.mkdirSync(reportsPath, { recursive: true });
242
+ fs.writeFileSync(
243
+ reportPath,
244
+ renderReport({
245
+ projectPath: resolvedProjectPath,
246
+ workspacePath,
247
+ generatedAt,
248
+ pageCount: result.pageCount,
249
+ items: result.items,
250
+ }),
251
+ "utf-8"
252
+ );
253
+
254
+ return {
255
+ reportPath,
256
+ pageCount: result.pageCount,
257
+ items: result.items,
258
+ summary: summarizeByCategory(result.items),
259
+ };
260
+ } catch (error) {
261
+ if (error instanceof CliError) throw error;
262
+ throw new CliError(`Failed to write SDTK-WIKI discovery plan report: ${error.message}`);
263
+ }
264
+ }
265
+
266
+ module.exports = {
267
+ REPORT_PREFIX,
268
+ buildPlanItems,
269
+ renderReport,
270
+ runWikiDiscoverPlan,
271
+ };
@@ -0,0 +1,89 @@
1
+ "use strict";
2
+
3
+ const { parseFlags } = require("./args");
4
+
5
+ const BASE_FLAG_DEFS = {
6
+ "project-path": { type: "string" },
7
+ "output-dir": { type: "string" },
8
+ "scan-root": { type: "string" },
9
+ verbose: { type: "boolean" },
10
+ };
11
+
12
+ const OPEN_FLAG_DEFS = {
13
+ ...BASE_FLAG_DEFS,
14
+ host: { type: "string" },
15
+ port: { type: "string" },
16
+ "no-open": { type: "boolean" },
17
+ };
18
+
19
+ const INIT_FLAG_DEFS = {
20
+ ...OPEN_FLAG_DEFS,
21
+ force: { type: "boolean" },
22
+ "no-build": { type: "boolean" },
23
+ };
24
+
25
+ const WATCH_FLAG_DEFS = {
26
+ ...BASE_FLAG_DEFS,
27
+ port: { type: "string" },
28
+ "no-open": { type: "boolean" },
29
+ };
30
+
31
+ const LINT_FLAG_DEFS = {
32
+ "project-path": { type: "string" },
33
+ };
34
+
35
+ const PRUNE_FLAG_DEFS = {
36
+ "project-path": { type: "string" },
37
+ "dry-run": { type: "boolean" },
38
+ };
39
+
40
+ const DISCOVER_FLAG_DEFS = {
41
+ "project-path": { type: "string" },
42
+ plan: { type: "boolean" },
43
+ };
44
+
45
+ const COMPILE_FLAG_DEFS = {
46
+ "project-path": { type: "string" },
47
+ plan: { type: "string" },
48
+ "dry-run": { type: "boolean" },
49
+ apply: { type: "boolean" },
50
+ };
51
+
52
+ function parseWikiFlags(args, defs) {
53
+ const scanRoots = [];
54
+ const filteredArgs = [];
55
+ let i = 0;
56
+
57
+ while (i < args.length) {
58
+ const arg = args[i];
59
+ if (arg === "--scan-root") {
60
+ i++;
61
+ if (i < args.length) {
62
+ scanRoots.push(args[i]);
63
+ i++;
64
+ }
65
+ } else if (arg.startsWith("--scan-root=")) {
66
+ scanRoots.push(arg.slice("--scan-root=".length));
67
+ i++;
68
+ } else {
69
+ filteredArgs.push(arg);
70
+ i++;
71
+ }
72
+ }
73
+
74
+ const { flags, positional } = parseFlags(filteredArgs, defs);
75
+ flags["scan-root"] = scanRoots.length > 0 ? scanRoots : undefined;
76
+ return { flags, positional };
77
+ }
78
+
79
+ module.exports = {
80
+ BASE_FLAG_DEFS,
81
+ COMPILE_FLAG_DEFS,
82
+ DISCOVER_FLAG_DEFS,
83
+ INIT_FLAG_DEFS,
84
+ LINT_FLAG_DEFS,
85
+ OPEN_FLAG_DEFS,
86
+ PRUNE_FLAG_DEFS,
87
+ WATCH_FLAG_DEFS,
88
+ parseWikiFlags,
89
+ };
@@ -0,0 +1,198 @@
1
+ "use strict";
2
+
3
+ const crypto = require("crypto");
4
+ const fs = require("fs");
5
+ const path = require("path");
6
+ const { ValidationError } = require("./errors");
7
+ const {
8
+ assertWikiWorkspaceWritePath,
9
+ getWikiProvenanceIngestEventsPath,
10
+ getWikiProvenancePath,
11
+ getWikiRawDescriptorsPath,
12
+ getWikiRawPath,
13
+ getWikiRawSourcesPath,
14
+ isPathInsideOrEqual,
15
+ resolveProjectPath,
16
+ } = require("./wiki-paths");
17
+
18
+ const INGEST_SCHEMA_VERSION = 1;
19
+
20
+ function nowIso() {
21
+ return new Date().toISOString();
22
+ }
23
+
24
+ function readJsonIfPresent(filePath, fallback) {
25
+ if (!fs.existsSync(filePath)) return fallback;
26
+ try {
27
+ return JSON.parse(fs.readFileSync(filePath, "utf-8"));
28
+ } catch (_) {
29
+ return fallback;
30
+ }
31
+ }
32
+
33
+ function writeJson(filePath, payload, projectPath) {
34
+ assertWikiWorkspaceWritePath(filePath, projectPath);
35
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
36
+ fs.writeFileSync(filePath, JSON.stringify(payload, null, 2) + "\n", "utf-8");
37
+ }
38
+
39
+ function rejectRemoteSource(sourceArg) {
40
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(sourceArg)) {
41
+ throw new ValidationError("Local project sources only. Remote URLs are not supported in BK-113.");
42
+ }
43
+ }
44
+
45
+ function toProjectRelative(projectPath, sourcePath) {
46
+ return path.relative(projectPath, sourcePath).split(path.sep).join("/");
47
+ }
48
+
49
+ function detectSourceType(sourcePath) {
50
+ const ext = path.extname(sourcePath).toLowerCase();
51
+ if (ext === ".md" || ext === ".markdown" || ext === ".mdx") return "markdown";
52
+ if (ext === ".txt") return "text";
53
+ return ext ? ext.slice(1) : "file";
54
+ }
55
+
56
+ function fingerprintSource(sourcePath) {
57
+ const bytes = fs.readFileSync(sourcePath);
58
+ return crypto.createHash("sha256").update(bytes).digest("hex");
59
+ }
60
+
61
+ function stableSourceId(relativePath) {
62
+ return `raw:${crypto.createHash("sha256").update(relativePath).digest("hex").slice(0, 24)}`;
63
+ }
64
+
65
+ function ensureApprovedIngestDirs(projectPath) {
66
+ const rawRoot = getWikiRawPath(projectPath);
67
+ const rawDescriptorsRoot = getWikiRawDescriptorsPath(projectPath);
68
+ const provenanceRoot = getWikiProvenancePath(projectPath);
69
+ for (const target of [rawRoot, rawDescriptorsRoot, provenanceRoot]) {
70
+ assertWikiWorkspaceWritePath(target, projectPath);
71
+ fs.mkdirSync(target, { recursive: true });
72
+ }
73
+ }
74
+
75
+ function resolveLocalSource(projectPath, sourceArg) {
76
+ if (!sourceArg || !String(sourceArg).trim()) {
77
+ throw new ValidationError("Missing required --source <path>.");
78
+ }
79
+
80
+ rejectRemoteSource(String(sourceArg));
81
+ const projectRoot = resolveProjectPath(projectPath);
82
+ const candidate = path.resolve(projectRoot, sourceArg);
83
+
84
+ if (!isPathInsideOrEqual(candidate, projectRoot)) {
85
+ throw new ValidationError(`Source must stay inside the project root: ${candidate}`);
86
+ }
87
+ if (!fs.existsSync(candidate)) {
88
+ throw new ValidationError(`Source does not exist: ${candidate}`);
89
+ }
90
+ if (!fs.statSync(candidate).isFile()) {
91
+ throw new ValidationError(`Source must be a file in BK-113: ${candidate}`);
92
+ }
93
+ return candidate;
94
+ }
95
+
96
+ function upsertRegistry(registry, record) {
97
+ const existing = Array.isArray(registry.sources) ? registry.sources : [];
98
+ const next = existing.filter((item) => item.sourceId !== record.sourceId);
99
+ next.push(record);
100
+ next.sort((a, b) => a.sourcePath.localeCompare(b.sourcePath));
101
+ return {
102
+ schemaVersion: INGEST_SCHEMA_VERSION,
103
+ product: "SDTK-WIKI",
104
+ sources: next,
105
+ };
106
+ }
107
+
108
+ function appendIngestEvent(eventsPayload, event) {
109
+ const events = Array.isArray(eventsPayload.events) ? eventsPayload.events.slice() : [];
110
+ events.push(event);
111
+ return {
112
+ schemaVersion: INGEST_SCHEMA_VERSION,
113
+ product: "SDTK-WIKI",
114
+ events,
115
+ };
116
+ }
117
+
118
+ function ingestLocalSource({ projectPath, sourceArg }) {
119
+ const projectRoot = resolveProjectPath(projectPath);
120
+ const sourcePath = resolveLocalSource(projectRoot, sourceArg);
121
+ const sourceStat = fs.statSync(sourcePath);
122
+ const relativeSourcePath = toProjectRelative(projectRoot, sourcePath);
123
+ const sourceId = stableSourceId(relativeSourcePath);
124
+ const sourceHash = fingerprintSource(sourcePath);
125
+ const ingestedAt = nowIso();
126
+ const descriptorPath = path.join(getWikiRawDescriptorsPath(projectRoot), `${sourceId.replace(/[:/]/g, "-")}.json`);
127
+ const registryPath = getWikiRawSourcesPath(projectRoot);
128
+ const eventsPath = getWikiProvenanceIngestEventsPath(projectRoot);
129
+ const descriptorRelative = toProjectRelative(projectRoot, descriptorPath);
130
+ const provenanceRelative = toProjectRelative(projectRoot, eventsPath);
131
+
132
+ ensureApprovedIngestDirs(projectRoot);
133
+
134
+ const descriptor = {
135
+ schemaVersion: INGEST_SCHEMA_VERSION,
136
+ product: "SDTK-WIKI",
137
+ sourceId,
138
+ sourcePath: relativeSourcePath,
139
+ sourceType: detectSourceType(sourcePath),
140
+ sourceHash,
141
+ sizeBytes: sourceStat.size,
142
+ mtimeMs: Math.trunc(sourceStat.mtimeMs),
143
+ ingestedAt,
144
+ revision: sourceHash.slice(0, 16),
145
+ status: "ingested",
146
+ provenancePath: provenanceRelative,
147
+ };
148
+ writeJson(descriptorPath, descriptor, projectRoot);
149
+
150
+ const record = {
151
+ sourceId,
152
+ sourcePath: relativeSourcePath,
153
+ sourceType: descriptor.sourceType,
154
+ sourceHash,
155
+ sizeBytes: descriptor.sizeBytes,
156
+ mtimeMs: descriptor.mtimeMs,
157
+ ingestedAt,
158
+ revision: descriptor.revision,
159
+ status: descriptor.status,
160
+ descriptorPath: descriptorRelative,
161
+ provenancePath: provenanceRelative,
162
+ };
163
+
164
+ const registry = upsertRegistry(
165
+ readJsonIfPresent(registryPath, { schemaVersion: INGEST_SCHEMA_VERSION, product: "SDTK-WIKI", sources: [] }),
166
+ record
167
+ );
168
+ writeJson(registryPath, registry, projectRoot);
169
+
170
+ const events = appendIngestEvent(
171
+ readJsonIfPresent(eventsPath, { schemaVersion: INGEST_SCHEMA_VERSION, product: "SDTK-WIKI", events: [] }),
172
+ {
173
+ event: "ingest.recorded",
174
+ sourceId,
175
+ sourcePath: relativeSourcePath,
176
+ sourceHash,
177
+ descriptorPath: descriptorRelative,
178
+ recordedAt: ingestedAt,
179
+ status: "ingested",
180
+ }
181
+ );
182
+ writeJson(eventsPath, events, projectRoot);
183
+
184
+ return {
185
+ sourceId,
186
+ sourcePath: relativeSourcePath,
187
+ registryPath,
188
+ descriptorPath,
189
+ eventsPath,
190
+ };
191
+ }
192
+
193
+ module.exports = {
194
+ INGEST_SCHEMA_VERSION,
195
+ ingestLocalSource,
196
+ resolveLocalSource,
197
+ stableSourceId,
198
+ };