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,364 @@
1
+ "use strict";
2
+
3
+ const crypto = require("crypto");
4
+ const fs = require("fs");
5
+ const os = require("os");
6
+ const path = require("path");
7
+
8
+ const SAFE_PACK_PART_RE = /^[A-Za-z0-9._-]+$/;
9
+ const REQUIRED_ENTITLEMENT_FIELDS = [
10
+ "schema_version",
11
+ "customer_id",
12
+ "plan",
13
+ "products",
14
+ "capabilities",
15
+ "issued_at",
16
+ "expires_at",
17
+ "offline_grace_until",
18
+ ];
19
+ const REQUIRED_PACK_FIELDS = [
20
+ "id",
21
+ "product",
22
+ "version",
23
+ "capabilities",
24
+ "source",
25
+ "sha256",
26
+ ];
27
+
28
+ function isSafePackPathPart(value) {
29
+ return (
30
+ typeof value === "string" &&
31
+ SAFE_PACK_PART_RE.test(value) &&
32
+ value !== "." &&
33
+ value !== ".."
34
+ );
35
+ }
36
+
37
+ function getSuiteRoot() {
38
+ return path.join(os.homedir(), ".sdtk");
39
+ }
40
+
41
+ function getEntitlementsFile() {
42
+ return path.join(getSuiteRoot(), "entitlements.json");
43
+ }
44
+
45
+ function getPacksRoot() {
46
+ return path.join(getSuiteRoot(), "packs");
47
+ }
48
+
49
+ function resolvePublicKey() {
50
+ if (process.env.SDTK_ENTITLEMENT_PUBLIC_KEY) {
51
+ return process.env.SDTK_ENTITLEMENT_PUBLIC_KEY;
52
+ }
53
+
54
+ const bundledKeyPath = path.join(
55
+ __dirname,
56
+ "..",
57
+ "..",
58
+ "assets",
59
+ "keys",
60
+ "sdtk-entitlement-public.pem"
61
+ );
62
+ if (fs.existsSync(bundledKeyPath)) {
63
+ return fs.readFileSync(bundledKeyPath, "utf8");
64
+ }
65
+ return null;
66
+ }
67
+
68
+ function canonicalize(value) {
69
+ if (Array.isArray(value)) {
70
+ return value.map(canonicalize);
71
+ }
72
+ if (value !== null && typeof value === "object") {
73
+ const sorted = {};
74
+ for (const key of Object.keys(value).sort()) {
75
+ sorted[key] = canonicalize(value[key]);
76
+ }
77
+ return sorted;
78
+ }
79
+ return value;
80
+ }
81
+
82
+ function buildCanonicalPayload(manifest) {
83
+ const { signature: _signature, ...rest } = manifest;
84
+ return Buffer.from(JSON.stringify(canonicalize(rest)), "utf8");
85
+ }
86
+
87
+ function evaluateManifestObject(manifest, nowMs) {
88
+ if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) {
89
+ return { state: "malformed", manifest: null };
90
+ }
91
+
92
+ for (const field of REQUIRED_ENTITLEMENT_FIELDS) {
93
+ if (!(field in manifest)) {
94
+ return { state: "malformed", manifest };
95
+ }
96
+ }
97
+
98
+ if (typeof manifest.schema_version !== "number") {
99
+ return { state: "malformed", manifest };
100
+ }
101
+ if (typeof manifest.customer_id !== "string" || !manifest.customer_id) {
102
+ return { state: "malformed", manifest };
103
+ }
104
+ if (typeof manifest.plan !== "string" || !manifest.plan) {
105
+ return { state: "malformed", manifest };
106
+ }
107
+ if (
108
+ !manifest.products ||
109
+ typeof manifest.products !== "object" ||
110
+ Array.isArray(manifest.products)
111
+ ) {
112
+ return { state: "malformed", manifest };
113
+ }
114
+ if (!Array.isArray(manifest.capabilities)) {
115
+ return { state: "malformed", manifest };
116
+ }
117
+ if (
118
+ typeof manifest.issued_at !== "string" ||
119
+ typeof manifest.expires_at !== "string" ||
120
+ typeof manifest.offline_grace_until !== "string"
121
+ ) {
122
+ return { state: "malformed", manifest };
123
+ }
124
+ if (
125
+ !("signature" in manifest) ||
126
+ !manifest.signature ||
127
+ typeof manifest.signature !== "string"
128
+ ) {
129
+ return { state: "unsigned", manifest };
130
+ }
131
+
132
+ const publicKeyPem = resolvePublicKey();
133
+ if (!publicKeyPem) {
134
+ return { state: "untrusted-key", manifest };
135
+ }
136
+
137
+ let verified = false;
138
+ try {
139
+ const verifier = crypto.createVerify("RSA-SHA256");
140
+ verifier.update(buildCanonicalPayload(manifest));
141
+ verified = verifier.verify(publicKeyPem, Buffer.from(manifest.signature, "base64"));
142
+ } catch (_err) {
143
+ return { state: "invalid-signature", manifest };
144
+ }
145
+
146
+ if (!verified) {
147
+ return { state: "invalid-signature", manifest };
148
+ }
149
+
150
+ const now = nowMs !== undefined ? nowMs : Date.now();
151
+ const expiresAt = new Date(manifest.expires_at).getTime();
152
+ const graceUntil = new Date(manifest.offline_grace_until).getTime();
153
+ if (Number.isNaN(expiresAt) || Number.isNaN(graceUntil)) {
154
+ return { state: "malformed", manifest };
155
+ }
156
+ if (now < expiresAt) {
157
+ return { state: "active", manifest };
158
+ }
159
+ if (now < graceUntil) {
160
+ return { state: "grace", manifest };
161
+ }
162
+ return { state: "expired", manifest };
163
+ }
164
+
165
+ function loadEntitlementState() {
166
+ const filePath = getEntitlementsFile();
167
+ if (!fs.existsSync(filePath)) {
168
+ return { state: "missing", manifest: null };
169
+ }
170
+ try {
171
+ return evaluateManifestObject(JSON.parse(fs.readFileSync(filePath, "utf8")));
172
+ } catch (_err) {
173
+ return { state: "malformed", manifest: null };
174
+ }
175
+ }
176
+
177
+ function checkCapability(capability, entitlementState) {
178
+ const state = entitlementState && entitlementState.state;
179
+ const manifest = entitlementState && entitlementState.manifest;
180
+
181
+ if (state === "active" || state === "grace") {
182
+ const capabilities = manifest && Array.isArray(manifest.capabilities)
183
+ ? manifest.capabilities
184
+ : [];
185
+ if (capabilities.includes(capability)) {
186
+ return { allowed: true };
187
+ }
188
+ return {
189
+ allowed: false,
190
+ exitCode: 1,
191
+ reason: `Capability "${capability}" is not included in the local entitlement.`,
192
+ };
193
+ }
194
+
195
+ const integrityStates = new Set(["malformed", "unsigned", "untrusted-key", "invalid-signature"]);
196
+ return {
197
+ allowed: false,
198
+ exitCode: integrityStates.has(state) ? 3 : 1,
199
+ reason: `Capability "${capability}" is blocked because local entitlement state is "${state || "missing"}".`,
200
+ };
201
+ }
202
+
203
+ function computeSha256(bytes) {
204
+ return crypto.createHash("sha256").update(bytes).digest("hex");
205
+ }
206
+
207
+ function validatePackMeta(meta) {
208
+ if (!meta || typeof meta !== "object" || Array.isArray(meta)) {
209
+ return { valid: false, reason: "pack metadata is not an object" };
210
+ }
211
+
212
+ for (const field of REQUIRED_PACK_FIELDS) {
213
+ if (!(field in meta)) {
214
+ return { valid: false, reason: `missing required field: ${field}` };
215
+ }
216
+ }
217
+
218
+ if (!isSafePackPathPart(meta.id)) {
219
+ return { valid: false, reason: "id must be a safe identifier" };
220
+ }
221
+ if (!isSafePackPathPart(meta.product)) {
222
+ return { valid: false, reason: "product must be a safe identifier" };
223
+ }
224
+ if (!isSafePackPathPart(meta.version)) {
225
+ return { valid: false, reason: "version must be a safe identifier" };
226
+ }
227
+ if (!Array.isArray(meta.capabilities)) {
228
+ return { valid: false, reason: "capabilities must be an array" };
229
+ }
230
+ if (
231
+ !meta.source ||
232
+ typeof meta.source !== "object" ||
233
+ Array.isArray(meta.source) ||
234
+ meta.source.type !== "github-content" ||
235
+ typeof meta.source.path !== "string" ||
236
+ !meta.source.path
237
+ ) {
238
+ return { valid: false, reason: 'source.type must be "github-content" with a path' };
239
+ }
240
+ if (typeof meta.sha256 !== "string" || !/^[0-9a-f]{64}$/.test(meta.sha256)) {
241
+ return { valid: false, reason: "sha256 must be a 64-character lowercase hex string" };
242
+ }
243
+ return { valid: true };
244
+ }
245
+
246
+ function getPackDir(packEntry) {
247
+ return path.join(getPacksRoot(), packEntry.product, packEntry.id, packEntry.version);
248
+ }
249
+
250
+ function getPackFile(packEntry) {
251
+ return path.join(getPackDir(packEntry), "pack.js");
252
+ }
253
+
254
+ function resolvePackState(packEntry) {
255
+ const validation = validatePackMeta(packEntry);
256
+ if (!validation.valid) {
257
+ return { state: "malformed", reason: validation.reason };
258
+ }
259
+
260
+ const packFile = getPackFile(packEntry);
261
+ const metaFile = path.join(getPackDir(packEntry), "pack.json");
262
+ if (!fs.existsSync(packFile) || !fs.existsSync(metaFile)) {
263
+ return {
264
+ state: "missing",
265
+ reason:
266
+ `Premium pack "${packEntry.id}@${packEntry.version}" is not installed for capability wiki.ask.`,
267
+ };
268
+ }
269
+
270
+ let cachedMeta;
271
+ try {
272
+ cachedMeta = JSON.parse(fs.readFileSync(metaFile, "utf8"));
273
+ } catch (_err) {
274
+ return { state: "malformed", reason: "pack.json could not be parsed" };
275
+ }
276
+
277
+ if (!cachedMeta || cachedMeta.sha256 !== packEntry.sha256) {
278
+ return { state: "stale", reason: "pack.json hash does not match signed manifest" };
279
+ }
280
+
281
+ let bytes;
282
+ try {
283
+ bytes = fs.readFileSync(packFile);
284
+ } catch (err) {
285
+ return { state: "malformed", reason: `pack.js could not be read: ${err.message}` };
286
+ }
287
+ if (computeSha256(bytes) !== packEntry.sha256) {
288
+ return { state: "stale", reason: "pack.js hash does not match signed manifest" };
289
+ }
290
+ return { state: "present" };
291
+ }
292
+
293
+ async function loadWikiAskHandler() {
294
+ const capability = "wiki.ask";
295
+ const entitlementState = loadEntitlementState();
296
+ const capabilityCheck = checkCapability(capability, entitlementState);
297
+ if (!capabilityCheck.allowed) {
298
+ return {
299
+ ok: false,
300
+ exitCode: capabilityCheck.exitCode,
301
+ message:
302
+ `${capabilityCheck.reason} Run SDTK activation or entitlement sync to install ` +
303
+ `the ${capability} premium runtime.`,
304
+ };
305
+ }
306
+
307
+ const manifest = entitlementState.manifest;
308
+ const packs = Array.isArray(manifest.premium_packs) ? manifest.premium_packs : [];
309
+ const packEntry = packs.find(
310
+ (pack) => Array.isArray(pack.capabilities) && pack.capabilities.includes(capability)
311
+ );
312
+ if (!packEntry) {
313
+ return {
314
+ ok: false,
315
+ exitCode: 1,
316
+ message:
317
+ `Premium pack providing "${capability}" is not available. Run SDTK activation ` +
318
+ `or entitlement sync to install the SDTK-WIKI Ask runtime.`,
319
+ };
320
+ }
321
+
322
+ const packState = resolvePackState(packEntry);
323
+ if (packState.state !== "present") {
324
+ return {
325
+ ok: false,
326
+ exitCode: packState.state === "malformed" ? 3 : 1,
327
+ message:
328
+ `Premium pack for "${capability}" is not ready (${packState.state}). ` +
329
+ (packState.reason || "Refresh the local entitlement/runtime cache."),
330
+ };
331
+ }
332
+
333
+ let packModule;
334
+ try {
335
+ packModule = require(getPackFile(packEntry));
336
+ } catch (err) {
337
+ return {
338
+ ok: false,
339
+ exitCode: 4,
340
+ message: `Failed to load SDTK-WIKI Ask runtime pack: ${err.message}`,
341
+ };
342
+ }
343
+
344
+ const handler = typeof packModule.wikiAsk === "function" ? packModule.wikiAsk : packModule.run;
345
+ if (typeof handler !== "function") {
346
+ return {
347
+ ok: false,
348
+ exitCode: 3,
349
+ message: 'SDTK-WIKI Ask runtime pack must export "wikiAsk" or "run".',
350
+ };
351
+ }
352
+
353
+ return { ok: true, handler, packEntry };
354
+ }
355
+
356
+ module.exports = {
357
+ buildCanonicalPayload,
358
+ canonicalize,
359
+ checkCapability,
360
+ evaluateManifestObject,
361
+ loadEntitlementState,
362
+ loadWikiAskHandler,
363
+ resolvePackState,
364
+ };
@@ -0,0 +1,334 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const { CliError, ValidationError } = require("./errors");
6
+ const { parseFrontmatter } = require("./wiki-lint");
7
+ const {
8
+ assertWikiWorkspaceWritePath,
9
+ getWikiGraphPath,
10
+ getWikiPagesPath,
11
+ getWikiProvenancePath,
12
+ getWikiProvenanceSourcesPath,
13
+ getWikiRawSourcesPath,
14
+ getWikiReportsPath,
15
+ getWikiWorkspacePath,
16
+ resolveProjectPath,
17
+ } = require("./wiki-paths");
18
+
19
+ const REPORT_PREFIX = "prune-dry-run-report";
20
+
21
+ function toPosix(value) {
22
+ return String(value || "").replace(/\\/g, "/");
23
+ }
24
+
25
+ function todayStamp(date = new Date()) {
26
+ return date.toISOString().slice(0, 10);
27
+ }
28
+
29
+ function readJsonIfPresent(filePath, fallback = null) {
30
+ if (!fs.existsSync(filePath)) return fallback;
31
+ try {
32
+ return JSON.parse(fs.readFileSync(filePath, "utf-8"));
33
+ } catch (error) {
34
+ return { __error: `Could not parse JSON at ${filePath}: ${error.message}` };
35
+ }
36
+ }
37
+
38
+ function listMarkdownPages(rootPath) {
39
+ if (!fs.existsSync(rootPath)) return [];
40
+ const results = [];
41
+ const stack = [rootPath];
42
+ while (stack.length > 0) {
43
+ const current = stack.pop();
44
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
45
+ const entryPath = path.join(current, entry.name);
46
+ if (entry.isDirectory()) {
47
+ stack.push(entryPath);
48
+ } else if (entry.isFile() && entry.name.toLowerCase().endsWith(".md")) {
49
+ results.push(entryPath);
50
+ }
51
+ }
52
+ }
53
+ return results.sort((a, b) => a.localeCompare(b));
54
+ }
55
+
56
+ function asArray(value) {
57
+ return Array.isArray(value) ? value : [];
58
+ }
59
+
60
+ function collectGraphDegree(graphPath) {
61
+ const graph = readJsonIfPresent(path.join(graphPath, "SDTK_DOC_GRAPH.json"), {});
62
+ const degree = new Map();
63
+ for (const node of asArray(graph.nodes)) {
64
+ if (node && node.id) degree.set(String(node.id), 0);
65
+ }
66
+ for (const edge of asArray(graph.edges)) {
67
+ if (!edge) continue;
68
+ const source = edge.source ? String(edge.source) : "";
69
+ const target = edge.target ? String(edge.target) : "";
70
+ if (source) degree.set(source, (degree.get(source) || 0) + 1);
71
+ if (target) degree.set(target, (degree.get(target) || 0) + 1);
72
+ }
73
+ return degree;
74
+ }
75
+
76
+ function indexActiveProvenance(projectPath) {
77
+ const data = readJsonIfPresent(getWikiProvenanceSourcesPath(projectPath), { sources: [] });
78
+ const sources = new Map();
79
+ for (const record of asArray(data.sources)) {
80
+ if (!record || !record.sourcePath) continue;
81
+ sources.set(toPosix(record.sourcePath), record);
82
+ }
83
+ return sources;
84
+ }
85
+
86
+ function collectRemovedSources(projectPath) {
87
+ const data = readJsonIfPresent(path.join(getWikiProvenancePath(projectPath), "changes.json"), {});
88
+ return new Set(asArray(data.removed).map(toPosix));
89
+ }
90
+
91
+ function indexRawSources(projectPath) {
92
+ const rawPath = getWikiRawSourcesPath(projectPath);
93
+ if (!fs.existsSync(rawPath)) {
94
+ return null;
95
+ }
96
+ const data = readJsonIfPresent(rawPath, { sources: [] });
97
+ const sources = new Map();
98
+ for (const record of asArray(data.sources)) {
99
+ if (!record || !record.sourcePath) continue;
100
+ sources.set(toPosix(record.sourcePath), record);
101
+ }
102
+ return sources;
103
+ }
104
+
105
+ function addSignal(signals, source, code, detail = "") {
106
+ signals.push({ source, code, detail });
107
+ }
108
+
109
+ function classifySignals(signals) {
110
+ const sources = new Set(signals.map((signal) => signal.source));
111
+ const hasBuildRemoved = signals.some((signal) => signal.code === "removed_source_from_build_provenance");
112
+ const hasRawInactive = signals.some((signal) => signal.source === "raw_registry");
113
+
114
+ if (sources.size >= 2 && (hasBuildRemoved || hasRawInactive)) {
115
+ return "strong_candidate";
116
+ }
117
+ if (hasBuildRemoved || hasRawInactive) {
118
+ return "candidate";
119
+ }
120
+ return "weak_signal";
121
+ }
122
+
123
+ function analyzePage({ filePath, pagesPath, projectPath, activeSources, removedSources, rawSources, graphDegree }) {
124
+ const text = fs.readFileSync(filePath, "utf-8");
125
+ const parsed = parseFrontmatter(text);
126
+ const fields = parsed.fields || {};
127
+ const relPagePath = toPosix(path.relative(projectPath, filePath));
128
+ const relToPages = toPosix(path.relative(pagesPath, filePath));
129
+ if (relToPages === "_index.md") {
130
+ return null;
131
+ }
132
+
133
+ const sourcePath = toPosix(fields.source_path || "");
134
+ const sourceHash = fields.source_hash || "";
135
+ const signals = [];
136
+
137
+ if (sourcePath && removedSources.has(sourcePath)) {
138
+ addSignal(signals, "build_provenance", "removed_source_from_build_provenance", sourcePath);
139
+ }
140
+
141
+ if (sourcePath) {
142
+ const activeRecord = activeSources.get(sourcePath);
143
+ if (!activeRecord) {
144
+ addSignal(signals, "active_provenance", "source_missing_from_active_provenance", sourcePath);
145
+ } else if (sourceHash && activeRecord.sourceHash && sourceHash !== activeRecord.sourceHash) {
146
+ addSignal(signals, "active_provenance", "source_hash_differs_from_active_provenance", sourcePath);
147
+ }
148
+ } else {
149
+ addSignal(signals, "page_frontmatter", "missing_source_path", relPagePath);
150
+ }
151
+
152
+ if (rawSources && sourcePath) {
153
+ const rawRecord = rawSources.get(sourcePath);
154
+ if (!rawRecord) {
155
+ addSignal(signals, "raw_registry", "raw_registry_binding_missing", sourcePath);
156
+ } else if (rawRecord.status && !["active", "ingested"].includes(String(rawRecord.status))) {
157
+ addSignal(signals, "raw_registry", `raw_registry_status_${rawRecord.status}`, sourcePath);
158
+ }
159
+ }
160
+
161
+ const relatedPages = Array.isArray(fields.related_pages) ? fields.related_pages : [];
162
+ if (sourcePath && (graphDegree.get(sourcePath) || 0) === 0 && relatedPages.length === 0) {
163
+ addSignal(signals, "lint_signal", "no_downstream_integration_signal", sourcePath);
164
+ }
165
+
166
+ const wordCount = String(parsed.body || "").split(/\s+/).filter(Boolean).length;
167
+ if (wordCount > 0 && wordCount < 25) {
168
+ addSignal(signals, "lint_signal", "thin_page_signal", `${wordCount} words`);
169
+ }
170
+
171
+ if (signals.length === 0) {
172
+ return null;
173
+ }
174
+
175
+ return {
176
+ pagePath: relPagePath,
177
+ pageId: fields.page_id || "",
178
+ title: fields.title || path.basename(filePath, ".md"),
179
+ sourcePath,
180
+ evidenceTier: classifySignals(signals),
181
+ safetyTier: "never_delete_automatically",
182
+ reasonCodes: signals.map((signal) => signal.code),
183
+ evidenceSources: Array.from(new Set(signals.map((signal) => signal.source))).sort(),
184
+ signals,
185
+ };
186
+ }
187
+
188
+ function tierSummary(findings) {
189
+ return findings.reduce(
190
+ (counts, finding) => {
191
+ counts[finding.evidenceTier] = (counts[finding.evidenceTier] || 0) + 1;
192
+ return counts;
193
+ },
194
+ { weak_signal: 0, candidate: 0, strong_candidate: 0 }
195
+ );
196
+ }
197
+
198
+ function recommendationForTier(tier) {
199
+ if (tier === "strong_candidate") {
200
+ return "Future explicit cleanup review only; do not delete automatically.";
201
+ }
202
+ if (tier === "candidate") {
203
+ return "Investigate as a stale candidate; not deletion proof.";
204
+ }
205
+ return "Investigate weak signal only.";
206
+ }
207
+
208
+ function renderReport({ projectPath, workspacePath, pagesScanned, findings, generatedAt }) {
209
+ const summary = tierSummary(findings);
210
+ const lines = [
211
+ "# SDTK-WIKI Prune Dry-Run Report",
212
+ "",
213
+ `Generated: ${generatedAt}`,
214
+ `Project path: ${projectPath}`,
215
+ `Workspace path: ${workspacePath}`,
216
+ `Pages scanned: ${pagesScanned}`,
217
+ `Findings: ${findings.length}`,
218
+ "",
219
+ "Report-only and non-destructive: no wiki pages were deleted or archived.",
220
+ "Safety posture: `never_delete_automatically` applies to every finding.",
221
+ "",
222
+ "## Evidence Tier Summary",
223
+ "",
224
+ "| Tier | Count | Meaning |",
225
+ "|---|---:|---|",
226
+ `| weak_signal | ${summary.weak_signal} | Investigate only. |`,
227
+ `| candidate | ${summary.candidate} | Candidate for investigation; not deletion proof. |`,
228
+ `| strong_candidate | ${summary.strong_candidate} | Future explicit cleanup review only. |`,
229
+ "| never_delete_automatically | all | No automatic delete/archive. |",
230
+ "",
231
+ "## Findings",
232
+ "",
233
+ ];
234
+
235
+ if (findings.length === 0) {
236
+ lines.push("No prune candidates were found.");
237
+ } else {
238
+ findings.forEach((finding, index) => {
239
+ lines.push(`### ${index + 1}. ${finding.pagePath}`);
240
+ lines.push("");
241
+ lines.push(`- Page ID: ${finding.pageId || "(missing)"}`);
242
+ lines.push(`- Title: ${finding.title || "(missing)"}`);
243
+ lines.push(`- Source path: ${finding.sourcePath || "(missing)"}`);
244
+ lines.push(`- Evidence tier: \`${finding.evidenceTier}\``);
245
+ lines.push("- Safety tier: `never_delete_automatically`");
246
+ lines.push(`- Recommendation: ${recommendationForTier(finding.evidenceTier)}`);
247
+ lines.push(`- Evidence sources: ${finding.evidenceSources.map((source) => `\`${source}\``).join(", ")}`);
248
+ lines.push(`- Reason codes: ${finding.reasonCodes.map((code) => `\`${code}\``).join(", ")}`);
249
+ lines.push("");
250
+ });
251
+ }
252
+
253
+ lines.push("");
254
+ lines.push("## Deferred Actions");
255
+ lines.push("");
256
+ lines.push("Delete, archive, apply, discover, compile, query-history, and Ask changes are outside BK-124.");
257
+ lines.push("");
258
+ return `${lines.join("\n").trimEnd()}\n`;
259
+ }
260
+
261
+ function ensureExistingWorkspace(projectPath) {
262
+ const workspacePath = getWikiWorkspacePath(projectPath);
263
+ if (!fs.existsSync(workspacePath) || !fs.statSync(workspacePath).isDirectory()) {
264
+ throw new ValidationError(
265
+ `No SDTK-WIKI workspace found at ${workspacePath}. Run "sdtk-wiki init" or "sdtk-wiki atlas build" first. No project files were changed.`
266
+ );
267
+ }
268
+ return workspacePath;
269
+ }
270
+
271
+ function runWikiPruneDryRun({ projectPath }) {
272
+ const resolvedProjectPath = resolveProjectPath(projectPath || process.cwd());
273
+ if (!fs.existsSync(resolvedProjectPath) || !fs.statSync(resolvedProjectPath).isDirectory()) {
274
+ throw new ValidationError(`--project-path is not a valid directory: ${resolvedProjectPath}`);
275
+ }
276
+
277
+ try {
278
+ const workspacePath = ensureExistingWorkspace(resolvedProjectPath);
279
+ const pagesPath = getWikiPagesPath(resolvedProjectPath);
280
+ const reportsPath = getWikiReportsPath(resolvedProjectPath);
281
+ assertWikiWorkspaceWritePath(reportsPath, resolvedProjectPath);
282
+
283
+ const pages = listMarkdownPages(pagesPath);
284
+ const activeSources = indexActiveProvenance(resolvedProjectPath);
285
+ const removedSources = collectRemovedSources(resolvedProjectPath);
286
+ const rawSources = indexRawSources(resolvedProjectPath);
287
+ const graphDegree = collectGraphDegree(getWikiGraphPath(resolvedProjectPath));
288
+ const findings = pages
289
+ .map((filePath) =>
290
+ analyzePage({
291
+ filePath,
292
+ pagesPath,
293
+ projectPath: resolvedProjectPath,
294
+ activeSources,
295
+ removedSources,
296
+ rawSources,
297
+ graphDegree,
298
+ })
299
+ )
300
+ .filter(Boolean);
301
+
302
+ const generatedAt = new Date().toISOString();
303
+ const reportPath = path.join(reportsPath, `${REPORT_PREFIX}-${todayStamp(new Date(generatedAt))}.md`);
304
+ assertWikiWorkspaceWritePath(reportPath, resolvedProjectPath);
305
+ fs.mkdirSync(reportsPath, { recursive: true });
306
+ fs.writeFileSync(
307
+ reportPath,
308
+ renderReport({
309
+ projectPath: resolvedProjectPath,
310
+ workspacePath,
311
+ pagesScanned: pages.filter((filePath) => path.basename(filePath) !== "_index.md").length,
312
+ findings,
313
+ generatedAt,
314
+ }),
315
+ "utf-8"
316
+ );
317
+
318
+ return {
319
+ reportPath,
320
+ pagesScanned: pages.filter((filePath) => path.basename(filePath) !== "_index.md").length,
321
+ findings,
322
+ summary: tierSummary(findings),
323
+ };
324
+ } catch (error) {
325
+ if (error instanceof CliError) throw error;
326
+ throw new CliError(`Failed to write SDTK-WIKI prune dry-run report: ${error.message}`);
327
+ }
328
+ }
329
+
330
+ module.exports = {
331
+ REPORT_PREFIX,
332
+ renderReport,
333
+ runWikiPruneDryRun,
334
+ };