pi-para 0.5.2 → 0.6.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.
- package/ARCHITECTURE.md +160 -0
- package/CHANGELOG.md +51 -0
- package/CONFIGURATION.md +243 -0
- package/README.md +140 -237
- package/SETUP.md +196 -112
- package/STRUCTURE.md +140 -0
- package/package.json +86 -18
- package/scripts/build-assets.mjs +29 -0
- package/scripts/install.ps1 +55 -0
- package/scripts/install.sh +63 -0
- package/scripts/smoke-install.mjs +63 -0
- package/skills/para/SKILL.md +1 -1
- package/skills/setup/SKILL.md +1 -1
- package/src/atomic-write.ts +17 -0
- package/src/capture.ts +4 -4
- package/src/cli-legacy.ts +43 -0
- package/src/cli.ts +133 -28
- package/src/commands.ts +203 -243
- package/src/config.ts +355 -0
- package/src/context.ts +29 -14
- package/src/credentials.ts +100 -0
- package/src/daemon.ts +1 -1
- package/src/doctor.ts +209 -0
- package/src/gepa/index.ts +1 -1
- package/src/index.ts +119 -40
- package/src/maintainer.ts +4 -4
- package/src/model-resolver.ts +96 -0
- package/src/paths.ts +48 -0
- package/src/processor.ts +4 -4
- package/src/qmd-providers.ts +63 -0
- package/src/query.ts +15 -1
- package/src/repair.ts +49 -0
- package/src/scheduler/controls.ts +125 -0
- package/src/scheduler/index.ts +210 -0
- package/src/scheduler/leases.ts +98 -0
- package/src/scheduler/session-capture.ts +146 -0
- package/src/scheduler/state.ts +304 -0
- package/src/session-tools.ts +1 -1
- package/src/settings.ts +126 -0
- package/src/setup.ts +116 -0
- package/src/status.ts +115 -0
- package/src/store.ts +116 -31
- package/src/summarize.ts +72 -16
- package/src/templates/prompts.ts +1 -1
- package/src/tools.ts +280 -90
- package/src/wiki-tool-guidance.ts +84 -0
- package/src/wiki.ts +98 -26
- package/dist/capture.d.ts +0 -34
- package/dist/capture.d.ts.map +0 -1
- package/dist/capture.js +0 -461
- package/dist/capture.js.map +0 -1
- package/dist/cli.d.ts +0 -15
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js +0 -405
- package/dist/cli.js.map +0 -1
- package/dist/commands.d.ts +0 -15
- package/dist/commands.d.ts.map +0 -1
- package/dist/commands.js +0 -786
- package/dist/commands.js.map +0 -1
- package/dist/context.d.ts +0 -61
- package/dist/context.d.ts.map +0 -1
- package/dist/context.js +0 -289
- package/dist/context.js.map +0 -1
- package/dist/daemon.d.ts +0 -49
- package/dist/daemon.d.ts.map +0 -1
- package/dist/daemon.js +0 -226
- package/dist/daemon.js.map +0 -1
- package/dist/frontmatter.d.ts +0 -59
- package/dist/frontmatter.d.ts.map +0 -1
- package/dist/frontmatter.js +0 -170
- package/dist/frontmatter.js.map +0 -1
- package/dist/gepa/dataset.d.ts +0 -36
- package/dist/gepa/dataset.d.ts.map +0 -1
- package/dist/gepa/dataset.js +0 -148
- package/dist/gepa/dataset.js.map +0 -1
- package/dist/gepa/evaluator.d.ts +0 -51
- package/dist/gepa/evaluator.d.ts.map +0 -1
- package/dist/gepa/evaluator.js +0 -317
- package/dist/gepa/evaluator.js.map +0 -1
- package/dist/gepa/index.d.ts +0 -66
- package/dist/gepa/index.d.ts.map +0 -1
- package/dist/gepa/index.js +0 -240
- package/dist/gepa/index.js.map +0 -1
- package/dist/gepa/llm.d.ts +0 -35
- package/dist/gepa/llm.d.ts.map +0 -1
- package/dist/gepa/llm.js +0 -268
- package/dist/gepa/llm.js.map +0 -1
- package/dist/gepa/optimized/capture-prompt.txt +0 -65
- package/dist/gepa/optimizer.d.ts +0 -29
- package/dist/gepa/optimizer.d.ts.map +0 -1
- package/dist/gepa/optimizer.js +0 -249
- package/dist/gepa/optimizer.js.map +0 -1
- package/dist/gepa/targets.d.ts +0 -39
- package/dist/gepa/targets.d.ts.map +0 -1
- package/dist/gepa/targets.js +0 -326
- package/dist/gepa/targets.js.map +0 -1
- package/dist/gepa/types.d.ts +0 -168
- package/dist/gepa/types.d.ts.map +0 -1
- package/dist/gepa/types.js +0 -124
- package/dist/gepa/types.js.map +0 -1
- package/dist/index.d.ts +0 -21
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -294
- package/dist/index.js.map +0 -1
- package/dist/ingest.d.ts +0 -88
- package/dist/ingest.d.ts.map +0 -1
- package/dist/ingest.js +0 -105
- package/dist/ingest.js.map +0 -1
- package/dist/link-discovery.d.ts +0 -40
- package/dist/link-discovery.d.ts.map +0 -1
- package/dist/link-discovery.js +0 -122
- package/dist/link-discovery.js.map +0 -1
- package/dist/link-utils.d.ts +0 -30
- package/dist/link-utils.d.ts.map +0 -1
- package/dist/link-utils.js +0 -145
- package/dist/link-utils.js.map +0 -1
- package/dist/lint.d.ts +0 -39
- package/dist/lint.d.ts.map +0 -1
- package/dist/lint.js +0 -827
- package/dist/lint.js.map +0 -1
- package/dist/maintainer.d.ts +0 -24
- package/dist/maintainer.d.ts.map +0 -1
- package/dist/maintainer.js +0 -358
- package/dist/maintainer.js.map +0 -1
- package/dist/processor.d.ts +0 -14
- package/dist/processor.d.ts.map +0 -1
- package/dist/processor.js +0 -361
- package/dist/processor.js.map +0 -1
- package/dist/query.d.ts +0 -69
- package/dist/query.d.ts.map +0 -1
- package/dist/query.js +0 -107
- package/dist/query.js.map +0 -1
- package/dist/raw.d.ts +0 -57
- package/dist/raw.d.ts.map +0 -1
- package/dist/raw.js +0 -219
- package/dist/raw.js.map +0 -1
- package/dist/redact.d.ts +0 -21
- package/dist/redact.d.ts.map +0 -1
- package/dist/redact.js +0 -143
- package/dist/redact.js.map +0 -1
- package/dist/scope.d.ts +0 -54
- package/dist/scope.d.ts.map +0 -1
- package/dist/scope.js +0 -161
- package/dist/scope.js.map +0 -1
- package/dist/scripts/bulk-link-fix.d.ts +0 -9
- package/dist/scripts/bulk-link-fix.d.ts.map +0 -1
- package/dist/scripts/bulk-link-fix.js +0 -106
- package/dist/scripts/bulk-link-fix.js.map +0 -1
- package/dist/session-tools.d.ts +0 -33
- package/dist/session-tools.d.ts.map +0 -1
- package/dist/session-tools.js +0 -211
- package/dist/session-tools.js.map +0 -1
- package/dist/state.d.ts +0 -54
- package/dist/state.d.ts.map +0 -1
- package/dist/state.js +0 -176
- package/dist/state.js.map +0 -1
- package/dist/store.d.ts +0 -56
- package/dist/store.d.ts.map +0 -1
- package/dist/store.js +0 -227
- package/dist/store.js.map +0 -1
- package/dist/summarize.d.ts +0 -56
- package/dist/summarize.d.ts.map +0 -1
- package/dist/summarize.js +0 -231
- package/dist/summarize.js.map +0 -1
- package/dist/tag-registry.d.ts +0 -56
- package/dist/tag-registry.d.ts.map +0 -1
- package/dist/tag-registry.js +0 -146
- package/dist/tag-registry.js.map +0 -1
- package/dist/templates/prompts.d.ts +0 -63
- package/dist/templates/prompts.d.ts.map +0 -1
- package/dist/templates/prompts.js +0 -260
- package/dist/templates/prompts.js.map +0 -1
- package/dist/tools.d.ts +0 -26
- package/dist/tools.d.ts.map +0 -1
- package/dist/tools.js +0 -1033
- package/dist/tools.js.map +0 -1
- package/dist/watcher.d.ts +0 -25
- package/dist/watcher.d.ts.map +0 -1
- package/dist/watcher.js +0 -84
- package/dist/watcher.js.map +0 -1
- package/dist/webui/client/dist/index.html +0 -1885
- package/dist/webui/server.d.ts +0 -31
- package/dist/webui/server.d.ts.map +0 -1
- package/dist/webui/server.js +0 -566
- package/dist/webui/server.js.map +0 -1
- package/dist/wiki.d.ts +0 -103
- package/dist/wiki.d.ts.map +0 -1
- package/dist/wiki.js +0 -428
- package/dist/wiki.js.map +0 -1
- package/scripts/gepa/__pycache__/dataset.cpython-313.pyc +0 -0
- package/scripts/gepa/__pycache__/lm_providers.cpython-313.pyc +0 -0
- package/scripts/gepa/__pycache__/metric.cpython-313.pyc +0 -0
- package/scripts/gepa/__pycache__/program.cpython-313.pyc +0 -0
- package/scripts/gepa/__pycache__/wiki_reader.cpython-313.pyc +0 -0
- package/scripts/gepa/uv.lock +0 -2241
package/dist/lint.js
DELETED
|
@@ -1,827 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Wiki health checks — validates wiki structure and content integrity.
|
|
3
|
-
*
|
|
4
|
-
* Checks for orphan pages, broken links, stale pages, scope drift,
|
|
5
|
-
* archive candidates, missing pages, frontmatter issues, index drift,
|
|
6
|
-
* empty categories, and duplicate slugs.
|
|
7
|
-
*
|
|
8
|
-
* When autoFix is true (default), fixable issues are repaired in-place:
|
|
9
|
-
* broken links removed, index rebuilt for drift, missing frontmatter fields set.
|
|
10
|
-
*/
|
|
11
|
-
import { readFile, writeFile } from "node:fs/promises";
|
|
12
|
-
import { join } from "node:path";
|
|
13
|
-
import { listPages, readPage, writePage, readIndex, writeIndex, PARA_CATEGORIES, } from "./wiki.js";
|
|
14
|
-
import { extractWikilinks, removeWikilink, autoLinkSlugs, syncFrontmatterLinks, } from "./link-utils.js";
|
|
15
|
-
import { normalizeTags, normalizeScopes } from "./tag-registry.js";
|
|
16
|
-
import { containsSecrets, redactSecrets } from "./redact.js";
|
|
17
|
-
import { CURRENT_SCHEMA_VERSION, migrateToLatest, parseFrontmatter, serializeFrontmatter, } from "./frontmatter.js";
|
|
18
|
-
// -- Helpers ----------------------------------------------------------------
|
|
19
|
-
function daysBetween(a, b) {
|
|
20
|
-
return Math.abs(b.getTime() - a.getTime()) / (1000 * 60 * 60 * 24);
|
|
21
|
-
}
|
|
22
|
-
function makeStats(pages, totalLinks, brokenLinks, orphanCount, logContent) {
|
|
23
|
-
const byCategory = {
|
|
24
|
-
projects: 0,
|
|
25
|
-
areas: 0,
|
|
26
|
-
resources: 0,
|
|
27
|
-
archives: 0,
|
|
28
|
-
};
|
|
29
|
-
let oldest = "";
|
|
30
|
-
let newest = "";
|
|
31
|
-
for (const p of pages) {
|
|
32
|
-
byCategory[p.category]++;
|
|
33
|
-
const created = p.frontmatter.created;
|
|
34
|
-
if (!oldest || created < oldest)
|
|
35
|
-
oldest = created;
|
|
36
|
-
if (!newest || created > newest)
|
|
37
|
-
newest = created;
|
|
38
|
-
}
|
|
39
|
-
// Find last ingest date from log
|
|
40
|
-
let lastIngest = "";
|
|
41
|
-
const ingestRe = /## \[(\d{4}-\d{2}-\d{2})\] ingest/g;
|
|
42
|
-
let match;
|
|
43
|
-
while ((match = ingestRe.exec(logContent)) !== null) {
|
|
44
|
-
if (!lastIngest || match[1] > lastIngest)
|
|
45
|
-
lastIngest = match[1];
|
|
46
|
-
}
|
|
47
|
-
return {
|
|
48
|
-
totalPages: pages.length,
|
|
49
|
-
byCategory,
|
|
50
|
-
totalLinks,
|
|
51
|
-
brokenLinks,
|
|
52
|
-
orphanPages: orphanCount,
|
|
53
|
-
oldestPage: oldest,
|
|
54
|
-
newestPage: newest,
|
|
55
|
-
lastIngest,
|
|
56
|
-
};
|
|
57
|
-
}
|
|
58
|
-
// -- Checks -----------------------------------------------------------------
|
|
59
|
-
/** 1. Orphan pages: no inbound [[wikilinks]] from any other page */
|
|
60
|
-
function checkOrphans(pages, allSlugs) {
|
|
61
|
-
// Build inbound link map
|
|
62
|
-
const inbound = new Set();
|
|
63
|
-
for (const p of pages) {
|
|
64
|
-
const bodyLinks = extractWikilinks(p.body);
|
|
65
|
-
const fmLinks = p.frontmatter.links;
|
|
66
|
-
for (const link of [...bodyLinks, ...fmLinks]) {
|
|
67
|
-
inbound.add(link);
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
const issues = [];
|
|
71
|
-
for (const p of pages) {
|
|
72
|
-
if (!inbound.has(p.slug)) {
|
|
73
|
-
issues.push({
|
|
74
|
-
severity: "info",
|
|
75
|
-
category: "orphan",
|
|
76
|
-
page: `${p.category}/${p.slug}`,
|
|
77
|
-
message: `Orphan page — no inbound [[wikilinks]] to "${p.frontmatter.title}"`,
|
|
78
|
-
autoFixable: false,
|
|
79
|
-
});
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
return issues;
|
|
83
|
-
}
|
|
84
|
-
/** 2. Broken links: [[wikilinks]] pointing to non-existent pages */
|
|
85
|
-
function checkBrokenLinks(pages, allSlugs) {
|
|
86
|
-
const issues = [];
|
|
87
|
-
for (const p of pages) {
|
|
88
|
-
const bodyLinks = extractWikilinks(p.body);
|
|
89
|
-
const fmLinks = p.frontmatter.links;
|
|
90
|
-
const allLinks = new Set([...bodyLinks, ...fmLinks]);
|
|
91
|
-
for (const link of allLinks) {
|
|
92
|
-
if (!allSlugs.has(link)) {
|
|
93
|
-
issues.push({
|
|
94
|
-
severity: "error",
|
|
95
|
-
category: "broken-link",
|
|
96
|
-
page: `${p.category}/${p.slug}`,
|
|
97
|
-
message: `Broken link [[${link}]] — target page does not exist`,
|
|
98
|
-
autoFixable: true,
|
|
99
|
-
});
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
return issues;
|
|
104
|
-
}
|
|
105
|
-
/** 3. Stale pages: not updated in > staleDays */
|
|
106
|
-
function checkStale(pages, staleDays, now) {
|
|
107
|
-
const issues = [];
|
|
108
|
-
for (const p of pages) {
|
|
109
|
-
if (p.category === "archives")
|
|
110
|
-
continue; // archives are expected to be stale
|
|
111
|
-
const updated = new Date(p.frontmatter.updated);
|
|
112
|
-
if (daysBetween(updated, now) > staleDays) {
|
|
113
|
-
issues.push({
|
|
114
|
-
severity: "warning",
|
|
115
|
-
category: "stale",
|
|
116
|
-
page: `${p.category}/${p.slug}`,
|
|
117
|
-
message: `Stale page — not updated in >${staleDays} days (last: ${p.frontmatter.updated})`,
|
|
118
|
-
autoFixable: false,
|
|
119
|
-
});
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
return issues;
|
|
123
|
-
}
|
|
124
|
-
/** 4. Scope drift: projects/ pages where scope doesn't include the slug */
|
|
125
|
-
function checkScopeDrift(pages) {
|
|
126
|
-
const issues = [];
|
|
127
|
-
for (const p of pages) {
|
|
128
|
-
if (p.category !== "projects")
|
|
129
|
-
continue;
|
|
130
|
-
if (!p.frontmatter.scope.includes(p.slug)) {
|
|
131
|
-
issues.push({
|
|
132
|
-
severity: "warning",
|
|
133
|
-
category: "scope-drift",
|
|
134
|
-
page: `${p.category}/${p.slug}`,
|
|
135
|
-
message: `Scope drift — projects/ page scope [${p.frontmatter.scope.join(", ")}] does not include its own slug "${p.slug}"`,
|
|
136
|
-
autoFixable: true,
|
|
137
|
-
});
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
return issues;
|
|
141
|
-
}
|
|
142
|
-
/** 5. Archive candidates: project pages with no recent log entries */
|
|
143
|
-
function checkArchiveCandidates(pages, logContent, staleDays, now) {
|
|
144
|
-
const issues = [];
|
|
145
|
-
for (const p of pages) {
|
|
146
|
-
if (p.category !== "projects")
|
|
147
|
-
continue;
|
|
148
|
-
// Check if the page or its slug appears in any recent log entry
|
|
149
|
-
const logEntryRe = /## \[(\d{4}-\d{2}-\d{2})\] \w+ \|[^\n]*/g;
|
|
150
|
-
let hasRecentEntry = false;
|
|
151
|
-
let m;
|
|
152
|
-
while ((m = logEntryRe.exec(logContent)) !== null) {
|
|
153
|
-
const entryDate = new Date(m[0].match(/\d{4}-\d{2}-\d{2}/)[0]);
|
|
154
|
-
const entryText = m[0];
|
|
155
|
-
if (daysBetween(entryDate, now) <= staleDays &&
|
|
156
|
-
(entryText.includes(p.slug) || entryText.includes(p.frontmatter.title))) {
|
|
157
|
-
hasRecentEntry = true;
|
|
158
|
-
break;
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
// Also check lines below each entry header for page references
|
|
162
|
-
if (!hasRecentEntry) {
|
|
163
|
-
const updated = new Date(p.frontmatter.updated);
|
|
164
|
-
if (daysBetween(updated, now) > staleDays) {
|
|
165
|
-
issues.push({
|
|
166
|
-
severity: "info",
|
|
167
|
-
category: "archive-candidate",
|
|
168
|
-
page: `${p.category}/${p.slug}`,
|
|
169
|
-
message: `Archive candidate — project has no recent log entries and hasn't been updated in >${staleDays} days`,
|
|
170
|
-
autoFixable: false,
|
|
171
|
-
});
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
return issues;
|
|
176
|
-
}
|
|
177
|
-
/** 6. Missing pages: slugs referenced in 2+ pages but lacking own page */
|
|
178
|
-
function checkMissing(pages, allSlugs) {
|
|
179
|
-
// Count how many distinct pages reference each non-existent slug
|
|
180
|
-
const refCounts = new Map();
|
|
181
|
-
for (const p of pages) {
|
|
182
|
-
const bodyLinks = extractWikilinks(p.body);
|
|
183
|
-
const fmLinks = p.frontmatter.links;
|
|
184
|
-
const allLinks = new Set([...bodyLinks, ...fmLinks]);
|
|
185
|
-
for (const link of allLinks) {
|
|
186
|
-
if (!allSlugs.has(link)) {
|
|
187
|
-
if (!refCounts.has(link))
|
|
188
|
-
refCounts.set(link, new Set());
|
|
189
|
-
refCounts.get(link).add(p.slug);
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
const issues = [];
|
|
194
|
-
for (const [slug, refs] of refCounts) {
|
|
195
|
-
if (refs.size >= 2) {
|
|
196
|
-
issues.push({
|
|
197
|
-
severity: "info",
|
|
198
|
-
category: "missing-page",
|
|
199
|
-
message: `Missing page "[[${slug}]]" — referenced by ${refs.size} pages: ${[...refs].join(", ")}`,
|
|
200
|
-
autoFixable: false,
|
|
201
|
-
});
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
return issues;
|
|
205
|
-
}
|
|
206
|
-
/** 7. Empty categories: PARA categories with zero pages */
|
|
207
|
-
function checkEmptyCategories(pages) {
|
|
208
|
-
const counts = {
|
|
209
|
-
projects: 0,
|
|
210
|
-
areas: 0,
|
|
211
|
-
resources: 0,
|
|
212
|
-
archives: 0,
|
|
213
|
-
};
|
|
214
|
-
for (const p of pages) {
|
|
215
|
-
counts[p.category]++;
|
|
216
|
-
}
|
|
217
|
-
const issues = [];
|
|
218
|
-
for (const cat of PARA_CATEGORIES) {
|
|
219
|
-
if (cat === "archives")
|
|
220
|
-
continue; // archives being empty is fine
|
|
221
|
-
if (counts[cat] === 0) {
|
|
222
|
-
issues.push({
|
|
223
|
-
severity: "info",
|
|
224
|
-
category: "empty-category",
|
|
225
|
-
message: `Empty category "${cat}" — no pages`,
|
|
226
|
-
autoFixable: false,
|
|
227
|
-
});
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
return issues;
|
|
231
|
-
}
|
|
232
|
-
/** 8. Frontmatter issues: missing required fields, empty scope */
|
|
233
|
-
function checkFrontmatter(pages) {
|
|
234
|
-
const issues = [];
|
|
235
|
-
for (const p of pages) {
|
|
236
|
-
const fm = p.frontmatter;
|
|
237
|
-
if (!fm.title || fm.title === "Untitled") {
|
|
238
|
-
issues.push({
|
|
239
|
-
severity: "error",
|
|
240
|
-
category: "frontmatter",
|
|
241
|
-
page: `${p.category}/${p.slug}`,
|
|
242
|
-
message: `Missing or default title`,
|
|
243
|
-
autoFixable: true,
|
|
244
|
-
});
|
|
245
|
-
}
|
|
246
|
-
if (fm.scope.length === 0) {
|
|
247
|
-
issues.push({
|
|
248
|
-
severity: "warning",
|
|
249
|
-
category: "frontmatter",
|
|
250
|
-
page: `${p.category}/${p.slug}`,
|
|
251
|
-
message: `Empty scope — page has no scope tags`,
|
|
252
|
-
autoFixable: true,
|
|
253
|
-
});
|
|
254
|
-
}
|
|
255
|
-
if (!fm.created) {
|
|
256
|
-
issues.push({
|
|
257
|
-
severity: "error",
|
|
258
|
-
category: "frontmatter",
|
|
259
|
-
page: `${p.category}/${p.slug}`,
|
|
260
|
-
message: `Missing created date`,
|
|
261
|
-
autoFixable: true,
|
|
262
|
-
});
|
|
263
|
-
}
|
|
264
|
-
if (!fm.updated) {
|
|
265
|
-
issues.push({
|
|
266
|
-
severity: "error",
|
|
267
|
-
category: "frontmatter",
|
|
268
|
-
page: `${p.category}/${p.slug}`,
|
|
269
|
-
message: `Missing updated date`,
|
|
270
|
-
autoFixable: true,
|
|
271
|
-
});
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
return issues;
|
|
275
|
-
}
|
|
276
|
-
/** 9. Index drift: pages on disk but not referenced in index.md */
|
|
277
|
-
function checkIndexDrift(pages, indexContent) {
|
|
278
|
-
const issues = [];
|
|
279
|
-
for (const p of pages) {
|
|
280
|
-
// Check if slug appears as [[slug]] in the index
|
|
281
|
-
if (!indexContent.includes(`[[${p.slug}]]`)) {
|
|
282
|
-
issues.push({
|
|
283
|
-
severity: "warning",
|
|
284
|
-
category: "index-drift",
|
|
285
|
-
page: `${p.category}/${p.slug}`,
|
|
286
|
-
message: `Page not listed in index.md`,
|
|
287
|
-
autoFixable: true,
|
|
288
|
-
});
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
return issues;
|
|
292
|
-
}
|
|
293
|
-
/** 10. Duplicate slugs: same slug in different categories */
|
|
294
|
-
function checkDuplicateSlugs(pages) {
|
|
295
|
-
const slugMap = new Map();
|
|
296
|
-
for (const p of pages) {
|
|
297
|
-
const existing = slugMap.get(p.slug) ?? [];
|
|
298
|
-
existing.push(p.category);
|
|
299
|
-
slugMap.set(p.slug, existing);
|
|
300
|
-
}
|
|
301
|
-
const issues = [];
|
|
302
|
-
for (const [slug, categories] of slugMap) {
|
|
303
|
-
if (categories.length > 1) {
|
|
304
|
-
issues.push({
|
|
305
|
-
severity: "warning",
|
|
306
|
-
category: "duplicate-slug",
|
|
307
|
-
message: `Duplicate slug "${slug}" exists in: ${categories.join(", ")}`,
|
|
308
|
-
autoFixable: false,
|
|
309
|
-
});
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
return issues;
|
|
313
|
-
}
|
|
314
|
-
/** 11. Link sync: slug mentions in body not wrapped in [[wikilinks]] */
|
|
315
|
-
function checkLinkSync(pages, allSlugs) {
|
|
316
|
-
const issues = [];
|
|
317
|
-
for (const p of pages) {
|
|
318
|
-
// Find slugs mentioned in body but not wrapped in [[...]]
|
|
319
|
-
const bodyLinks = new Set(extractWikilinks(p.body));
|
|
320
|
-
const sortedSlugs = [...allSlugs]
|
|
321
|
-
.filter(s => s !== p.slug)
|
|
322
|
-
.sort((a, b) => b.length - a.length);
|
|
323
|
-
const unlinked = [];
|
|
324
|
-
for (const slug of sortedSlugs) {
|
|
325
|
-
if (bodyLinks.has(slug))
|
|
326
|
-
continue;
|
|
327
|
-
// Check if slug appears as plain text in body (word-boundary match)
|
|
328
|
-
const escaped = slug.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
329
|
-
const re = new RegExp(`(?<![a-z0-9-])${escaped}(?![a-z0-9-])`);
|
|
330
|
-
if (re.test(p.body)) {
|
|
331
|
-
unlinked.push(slug);
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
if (unlinked.length > 0) {
|
|
335
|
-
issues.push({
|
|
336
|
-
severity: "warning",
|
|
337
|
-
category: "link-sync",
|
|
338
|
-
page: `${p.category}/${p.slug}`,
|
|
339
|
-
message: `${unlinked.length} unlinked slug mention(s): ${unlinked.join(", ")}`,
|
|
340
|
-
autoFixable: true,
|
|
341
|
-
});
|
|
342
|
-
}
|
|
343
|
-
// Check frontmatter.links out of sync with body
|
|
344
|
-
const expectedLinks = syncFrontmatterLinks(p.body);
|
|
345
|
-
const fmSet = new Set(p.frontmatter.links);
|
|
346
|
-
const expectedSet = new Set(expectedLinks);
|
|
347
|
-
const missingInFm = expectedLinks.filter(l => !fmSet.has(l));
|
|
348
|
-
const extraInFm = p.frontmatter.links.filter(l => !expectedSet.has(l));
|
|
349
|
-
if (missingInFm.length > 0 || extraInFm.length > 0) {
|
|
350
|
-
issues.push({
|
|
351
|
-
severity: "warning",
|
|
352
|
-
category: "link-sync",
|
|
353
|
-
page: `${p.category}/${p.slug}`,
|
|
354
|
-
message: `frontmatter.links out of sync with body [[wikilinks]]`,
|
|
355
|
-
autoFixable: true,
|
|
356
|
-
});
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
return issues;
|
|
360
|
-
}
|
|
361
|
-
/** 12. Tag health: spaces, scope duplicates, non-canonical tags */
|
|
362
|
-
function checkTagHealth(pages) {
|
|
363
|
-
const issues = [];
|
|
364
|
-
for (const p of pages) {
|
|
365
|
-
const tags = p.frontmatter.tags;
|
|
366
|
-
const scope = p.frontmatter.scope;
|
|
367
|
-
const scopeSet = new Set(scope.map(s => s.toLowerCase()));
|
|
368
|
-
// Check for tags with spaces
|
|
369
|
-
const spaceTags = tags.filter(t => t.includes(" "));
|
|
370
|
-
if (spaceTags.length > 0) {
|
|
371
|
-
issues.push({
|
|
372
|
-
severity: "warning",
|
|
373
|
-
category: "tag-health",
|
|
374
|
-
page: `${p.category}/${p.slug}`,
|
|
375
|
-
message: `Tags with spaces: ${spaceTags.map(t => `"${t}"`).join(", ")}`,
|
|
376
|
-
autoFixable: true,
|
|
377
|
-
});
|
|
378
|
-
}
|
|
379
|
-
// Check for tags duplicating scope
|
|
380
|
-
const scopeDupes = tags.filter(t => scopeSet.has(t.toLowerCase()));
|
|
381
|
-
if (scopeDupes.length > 0) {
|
|
382
|
-
issues.push({
|
|
383
|
-
severity: "info",
|
|
384
|
-
category: "tag-health",
|
|
385
|
-
page: `${p.category}/${p.slug}`,
|
|
386
|
-
message: `Tags duplicating scope: ${scopeDupes.join(", ")}`,
|
|
387
|
-
autoFixable: true,
|
|
388
|
-
});
|
|
389
|
-
}
|
|
390
|
-
// Check if normalization would change the tag list
|
|
391
|
-
const normalized = normalizeTags(tags, scope);
|
|
392
|
-
const currentSorted = [...tags].sort();
|
|
393
|
-
if (JSON.stringify(normalized) !== JSON.stringify(currentSorted)) {
|
|
394
|
-
// Only report if not already covered by the above checks
|
|
395
|
-
const aliasChanges = tags.filter(t => {
|
|
396
|
-
const n = t.trim().toLowerCase().replace(/\s+/g, "-");
|
|
397
|
-
return normalized.includes(n) === false && !scopeSet.has(n) && !t.includes(" ");
|
|
398
|
-
});
|
|
399
|
-
if (aliasChanges.length > 0) {
|
|
400
|
-
issues.push({
|
|
401
|
-
severity: "info",
|
|
402
|
-
category: "tag-health",
|
|
403
|
-
page: `${p.category}/${p.slug}`,
|
|
404
|
-
message: `Tags to canonicalize: ${aliasChanges.join(", ")}`,
|
|
405
|
-
autoFixable: true,
|
|
406
|
-
});
|
|
407
|
-
}
|
|
408
|
-
}
|
|
409
|
-
}
|
|
410
|
-
return issues;
|
|
411
|
-
}
|
|
412
|
-
/** 13. Secrets: detect API keys, tokens, passwords in page content */
|
|
413
|
-
function checkSecrets(pages) {
|
|
414
|
-
const issues = [];
|
|
415
|
-
for (const p of pages) {
|
|
416
|
-
if (containsSecrets(p.body)) {
|
|
417
|
-
issues.push({
|
|
418
|
-
severity: "error",
|
|
419
|
-
category: "secrets",
|
|
420
|
-
page: `${p.category}/${p.slug}`,
|
|
421
|
-
message: `Page contains potential secrets (API keys, tokens, passwords)`,
|
|
422
|
-
autoFixable: true,
|
|
423
|
-
});
|
|
424
|
-
}
|
|
425
|
-
}
|
|
426
|
-
return issues;
|
|
427
|
-
}
|
|
428
|
-
/** 15. Schema version: pages below CURRENT_SCHEMA_VERSION need migration */
|
|
429
|
-
function checkSchemaVersion(pages) {
|
|
430
|
-
const issues = [];
|
|
431
|
-
for (const p of pages) {
|
|
432
|
-
const version = p.frontmatter.schemaVersion ?? 1;
|
|
433
|
-
if (version < CURRENT_SCHEMA_VERSION) {
|
|
434
|
-
issues.push({
|
|
435
|
-
severity: "warning",
|
|
436
|
-
category: "schema-version",
|
|
437
|
-
page: `${p.category}/${p.slug}`,
|
|
438
|
-
message: `Schema version ${version} is below current (${CURRENT_SCHEMA_VERSION}) — needs migration`,
|
|
439
|
-
autoFixable: true,
|
|
440
|
-
});
|
|
441
|
-
}
|
|
442
|
-
}
|
|
443
|
-
return issues;
|
|
444
|
-
}
|
|
445
|
-
/** 14. PARA category misuse: reference docs in projects/ instead of resources/ */
|
|
446
|
-
function checkCategoryMisuse(pages) {
|
|
447
|
-
const issues = [];
|
|
448
|
-
// projects/ should have goal-defined work with end dates, not reference docs.
|
|
449
|
-
// Heuristic: if a projects/ page's slug contains a scope project name as prefix
|
|
450
|
-
// (e.g. "pi-para-daemon" in scope "pi-para"), it's likely a sub-topic reference
|
|
451
|
-
// doc, not a standalone project.
|
|
452
|
-
for (const p of pages) {
|
|
453
|
-
if (p.category !== "projects")
|
|
454
|
-
continue;
|
|
455
|
-
// Check if the slug is a sub-topic of a scope value
|
|
456
|
-
// e.g. slug "pi-para-daemon" with scope ["pi-para"] → sub-topic
|
|
457
|
-
const isSubtopic = p.frontmatter.scope.some((s) => s !== p.slug && s !== "global" && p.slug.startsWith(s + "-"));
|
|
458
|
-
if (isSubtopic) {
|
|
459
|
-
issues.push({
|
|
460
|
-
severity: "warning",
|
|
461
|
-
category: "category-misuse",
|
|
462
|
-
page: `${p.category}/${p.slug}`,
|
|
463
|
-
message: `Likely a reference doc, not a project — slug "${p.slug}" is a sub-topic of scope [${p.frontmatter.scope.join(", ")}]. Consider moving to resources/.`,
|
|
464
|
-
autoFixable: false,
|
|
465
|
-
});
|
|
466
|
-
}
|
|
467
|
-
}
|
|
468
|
-
return issues;
|
|
469
|
-
}
|
|
470
|
-
// -- Auto-fix ---------------------------------------------------------------
|
|
471
|
-
/** Fix secrets: redact API keys, tokens, passwords from page content */
|
|
472
|
-
async function fixSecrets(wikiDir, pages) {
|
|
473
|
-
const fixed = [];
|
|
474
|
-
for (const p of pages) {
|
|
475
|
-
if (!containsSecrets(p.body))
|
|
476
|
-
continue;
|
|
477
|
-
const { text: redacted, redactions } = redactSecrets(p.body);
|
|
478
|
-
if (redactions > 0) {
|
|
479
|
-
const fixedPage = {
|
|
480
|
-
...p,
|
|
481
|
-
body: redacted,
|
|
482
|
-
frontmatter: {
|
|
483
|
-
...p.frontmatter,
|
|
484
|
-
updated: new Date().toISOString(),
|
|
485
|
-
},
|
|
486
|
-
};
|
|
487
|
-
await writePage(wikiDir, fixedPage);
|
|
488
|
-
fixed.push({
|
|
489
|
-
severity: "error",
|
|
490
|
-
category: "secrets",
|
|
491
|
-
page: `${p.category}/${p.slug}`,
|
|
492
|
-
message: `Fixed: redacted ${redactions} secret(s)`,
|
|
493
|
-
autoFixable: true,
|
|
494
|
-
});
|
|
495
|
-
}
|
|
496
|
-
}
|
|
497
|
-
return fixed;
|
|
498
|
-
}
|
|
499
|
-
/** Fix link sync: auto-link slug mentions and sync frontmatter.links */
|
|
500
|
-
async function fixLinkSync(wikiDir, pages, allSlugs) {
|
|
501
|
-
const fixed = [];
|
|
502
|
-
for (const p of pages) {
|
|
503
|
-
const originalBody = p.body;
|
|
504
|
-
const originalLinks = [...p.frontmatter.links];
|
|
505
|
-
// Auto-link slug mentions
|
|
506
|
-
const newBody = autoLinkSlugs(p.body, allSlugs, p.slug);
|
|
507
|
-
// Sync frontmatter.links
|
|
508
|
-
const newLinks = syncFrontmatterLinks(newBody);
|
|
509
|
-
// Check if anything changed
|
|
510
|
-
const bodyChanged = newBody !== originalBody;
|
|
511
|
-
const linksChanged = JSON.stringify(newLinks.sort()) !== JSON.stringify(originalLinks.sort());
|
|
512
|
-
if (bodyChanged || linksChanged) {
|
|
513
|
-
const addedLinks = newLinks.filter(l => !originalLinks.includes(l));
|
|
514
|
-
const fixedPage = {
|
|
515
|
-
...p,
|
|
516
|
-
body: newBody,
|
|
517
|
-
frontmatter: {
|
|
518
|
-
...p.frontmatter,
|
|
519
|
-
links: newLinks,
|
|
520
|
-
updated: new Date().toISOString(),
|
|
521
|
-
},
|
|
522
|
-
};
|
|
523
|
-
await writePage(wikiDir, fixedPage);
|
|
524
|
-
if (addedLinks.length > 0) {
|
|
525
|
-
fixed.push({
|
|
526
|
-
severity: "warning",
|
|
527
|
-
category: "link-sync",
|
|
528
|
-
page: `${p.category}/${p.slug}`,
|
|
529
|
-
message: `Fixed: auto-linked ${addedLinks.length} slug(s): ${addedLinks.join(", ")}`,
|
|
530
|
-
autoFixable: true,
|
|
531
|
-
});
|
|
532
|
-
}
|
|
533
|
-
if (linksChanged && addedLinks.length === 0) {
|
|
534
|
-
fixed.push({
|
|
535
|
-
severity: "warning",
|
|
536
|
-
category: "link-sync",
|
|
537
|
-
page: `${p.category}/${p.slug}`,
|
|
538
|
-
message: `Fixed: synced frontmatter.links with body [[wikilinks]]`,
|
|
539
|
-
autoFixable: true,
|
|
540
|
-
});
|
|
541
|
-
}
|
|
542
|
-
}
|
|
543
|
-
}
|
|
544
|
-
return fixed;
|
|
545
|
-
}
|
|
546
|
-
/** Fix tag health: normalize all tags via registry */
|
|
547
|
-
async function fixTagHealth(wikiDir, pages) {
|
|
548
|
-
const fixed = [];
|
|
549
|
-
for (const p of pages) {
|
|
550
|
-
const original = [...p.frontmatter.tags];
|
|
551
|
-
const normalized = normalizeTags(p.frontmatter.tags, p.frontmatter.scope);
|
|
552
|
-
if (JSON.stringify(original.sort()) !== JSON.stringify(normalized)) {
|
|
553
|
-
const removed = original.filter(t => !normalized.includes(t.trim().toLowerCase().replace(/\s+/g, "-")));
|
|
554
|
-
p.frontmatter.tags = normalized;
|
|
555
|
-
p.frontmatter.updated = new Date().toISOString();
|
|
556
|
-
await writePage(wikiDir, p);
|
|
557
|
-
fixed.push({
|
|
558
|
-
severity: "warning",
|
|
559
|
-
category: "tag-health",
|
|
560
|
-
page: `${p.category}/${p.slug}`,
|
|
561
|
-
message: `Fixed: normalized tags [${original.join(", ")}] → [${normalized.join(", ")}]`,
|
|
562
|
-
autoFixable: true,
|
|
563
|
-
});
|
|
564
|
-
}
|
|
565
|
-
}
|
|
566
|
-
return fixed;
|
|
567
|
-
}
|
|
568
|
-
/** Fix broken links: remove [[broken]] from body and frontmatter.links */
|
|
569
|
-
async function fixBrokenLinks(wikiDir, pages, allSlugs) {
|
|
570
|
-
const fixed = [];
|
|
571
|
-
for (const p of pages) {
|
|
572
|
-
const bodyLinks = extractWikilinks(p.body);
|
|
573
|
-
const fmLinks = p.frontmatter.links;
|
|
574
|
-
const allLinks = new Set([...bodyLinks, ...fmLinks]);
|
|
575
|
-
const broken = [...allLinks].filter((l) => !allSlugs.has(l));
|
|
576
|
-
if (broken.length === 0)
|
|
577
|
-
continue;
|
|
578
|
-
let newBody = p.body;
|
|
579
|
-
for (const link of broken) {
|
|
580
|
-
newBody = removeWikilink(newBody, link);
|
|
581
|
-
}
|
|
582
|
-
const newFmLinks = fmLinks.filter((l) => allSlugs.has(l));
|
|
583
|
-
const fixedPage = {
|
|
584
|
-
...p,
|
|
585
|
-
body: newBody,
|
|
586
|
-
frontmatter: { ...p.frontmatter, links: newFmLinks },
|
|
587
|
-
};
|
|
588
|
-
await writePage(wikiDir, fixedPage);
|
|
589
|
-
for (const link of broken) {
|
|
590
|
-
fixed.push({
|
|
591
|
-
severity: "error",
|
|
592
|
-
category: "broken-link",
|
|
593
|
-
page: `${p.category}/${p.slug}`,
|
|
594
|
-
message: `Fixed: removed broken link [[${link}]]`,
|
|
595
|
-
autoFixable: true,
|
|
596
|
-
});
|
|
597
|
-
}
|
|
598
|
-
}
|
|
599
|
-
return fixed;
|
|
600
|
-
}
|
|
601
|
-
/** Fix index drift: rebuild index.md to include all pages */
|
|
602
|
-
async function fixIndexDrift(wikiDir, pages, indexContent) {
|
|
603
|
-
const missing = [];
|
|
604
|
-
for (const p of pages) {
|
|
605
|
-
if (!indexContent.includes(`[[${p.slug}]]`)) {
|
|
606
|
-
missing.push(p);
|
|
607
|
-
}
|
|
608
|
-
}
|
|
609
|
-
if (missing.length === 0)
|
|
610
|
-
return [];
|
|
611
|
-
// Group missing pages by category
|
|
612
|
-
const byCategory = new Map();
|
|
613
|
-
for (const p of missing) {
|
|
614
|
-
const list = byCategory.get(p.category) ?? [];
|
|
615
|
-
list.push(p);
|
|
616
|
-
byCategory.set(p.category, list);
|
|
617
|
-
}
|
|
618
|
-
// Append missing entries to each category section in the index
|
|
619
|
-
let newIndex = indexContent;
|
|
620
|
-
for (const cat of PARA_CATEGORIES) {
|
|
621
|
-
const catPages = byCategory.get(cat);
|
|
622
|
-
if (!catPages || catPages.length === 0)
|
|
623
|
-
continue;
|
|
624
|
-
// Find the category section header
|
|
625
|
-
const headerRe = new RegExp(`(## ${cat.charAt(0).toUpperCase() + cat.slice(1)}[^\n]*\n)`, "i");
|
|
626
|
-
const headerMatch = newIndex.match(headerRe);
|
|
627
|
-
if (headerMatch && headerMatch.index !== undefined) {
|
|
628
|
-
const insertPos = headerMatch.index + headerMatch[0].length;
|
|
629
|
-
const lines = catPages
|
|
630
|
-
.map((p) => `- [[${p.slug}]] — ${p.frontmatter.title}\n`)
|
|
631
|
-
.join("");
|
|
632
|
-
newIndex =
|
|
633
|
-
newIndex.slice(0, insertPos) + lines + newIndex.slice(insertPos);
|
|
634
|
-
}
|
|
635
|
-
}
|
|
636
|
-
// Remove placeholder lines like "_No active projects yet._"
|
|
637
|
-
newIndex = newIndex.replace(/\n_No [^_]*yet\._\n?/g, "\n");
|
|
638
|
-
await writeIndex(wikiDir, newIndex);
|
|
639
|
-
return missing.map((p) => ({
|
|
640
|
-
severity: "warning",
|
|
641
|
-
category: "index-drift",
|
|
642
|
-
page: `${p.category}/${p.slug}`,
|
|
643
|
-
message: `Fixed: added [[${p.slug}]] to index.md`,
|
|
644
|
-
autoFixable: true,
|
|
645
|
-
}));
|
|
646
|
-
}
|
|
647
|
-
/** Fix frontmatter issues: set missing fields to defaults */
|
|
648
|
-
async function fixFrontmatter(wikiDir, pages) {
|
|
649
|
-
const fixed = [];
|
|
650
|
-
for (const p of pages) {
|
|
651
|
-
const fm = p.frontmatter;
|
|
652
|
-
let changed = false;
|
|
653
|
-
if (!fm.title || fm.title === "Untitled") {
|
|
654
|
-
// Derive title from slug
|
|
655
|
-
fm.title = p.slug
|
|
656
|
-
.split("-")
|
|
657
|
-
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
658
|
-
.join(" ");
|
|
659
|
-
changed = true;
|
|
660
|
-
fixed.push({
|
|
661
|
-
severity: "error",
|
|
662
|
-
category: "frontmatter",
|
|
663
|
-
page: `${p.category}/${p.slug}`,
|
|
664
|
-
message: `Fixed: set title to "${fm.title}"`,
|
|
665
|
-
autoFixable: true,
|
|
666
|
-
});
|
|
667
|
-
}
|
|
668
|
-
if (fm.scope.length === 0) {
|
|
669
|
-
// Default scope based on category
|
|
670
|
-
if (p.category === "areas") {
|
|
671
|
-
fm.scope = ["global"];
|
|
672
|
-
}
|
|
673
|
-
else if (p.category === "projects") {
|
|
674
|
-
fm.scope = [p.slug];
|
|
675
|
-
}
|
|
676
|
-
else {
|
|
677
|
-
fm.scope = ["global"];
|
|
678
|
-
}
|
|
679
|
-
changed = true;
|
|
680
|
-
fixed.push({
|
|
681
|
-
severity: "warning",
|
|
682
|
-
category: "frontmatter",
|
|
683
|
-
page: `${p.category}/${p.slug}`,
|
|
684
|
-
message: `Fixed: set scope to [${fm.scope.join(", ")}]`,
|
|
685
|
-
autoFixable: true,
|
|
686
|
-
});
|
|
687
|
-
}
|
|
688
|
-
if (changed) {
|
|
689
|
-
fm.updated = new Date().toISOString();
|
|
690
|
-
await writePage(wikiDir, { ...p, frontmatter: fm });
|
|
691
|
-
}
|
|
692
|
-
}
|
|
693
|
-
return fixed;
|
|
694
|
-
}
|
|
695
|
-
/** Fix schema version: run migrations on pages below current version */
|
|
696
|
-
async function fixSchemaVersion(wikiDir, pages) {
|
|
697
|
-
const fixed = [];
|
|
698
|
-
for (const p of pages) {
|
|
699
|
-
const version = p.frontmatter.schemaVersion ?? 1;
|
|
700
|
-
if (version >= CURRENT_SCHEMA_VERSION)
|
|
701
|
-
continue;
|
|
702
|
-
// Read the raw file to get the original frontmatter as a plain object
|
|
703
|
-
const filePath = join(wikiDir, p.category, `${p.slug}.md`);
|
|
704
|
-
const content = await readFile(filePath, "utf-8");
|
|
705
|
-
const { frontmatter: rawFm, body: rawBody } = parseFrontmatter(content);
|
|
706
|
-
const rawObj = rawFm;
|
|
707
|
-
const result = migrateToLatest(rawObj, rawBody);
|
|
708
|
-
// Write back with migrated content
|
|
709
|
-
const migratedFm = result.fm;
|
|
710
|
-
migratedFm.updated = new Date().toISOString();
|
|
711
|
-
await writePage(wikiDir, {
|
|
712
|
-
category: p.category,
|
|
713
|
-
slug: p.slug,
|
|
714
|
-
frontmatter: migratedFm,
|
|
715
|
-
body: result.body,
|
|
716
|
-
});
|
|
717
|
-
fixed.push({
|
|
718
|
-
severity: "warning",
|
|
719
|
-
category: "schema-version",
|
|
720
|
-
page: `${p.category}/${p.slug}`,
|
|
721
|
-
message: `Fixed: migrated schema version ${version} → ${migratedFm.schemaVersion}`,
|
|
722
|
-
autoFixable: true,
|
|
723
|
-
});
|
|
724
|
-
}
|
|
725
|
-
return fixed;
|
|
726
|
-
}
|
|
727
|
-
/** Fix scope drift: add slug to projects/ page scope */
|
|
728
|
-
async function fixScopeDrift(wikiDir, pages) {
|
|
729
|
-
const fixed = [];
|
|
730
|
-
for (const p of pages) {
|
|
731
|
-
if (p.category !== "projects")
|
|
732
|
-
continue;
|
|
733
|
-
if (p.frontmatter.scope.includes(p.slug))
|
|
734
|
-
continue;
|
|
735
|
-
p.frontmatter.scope.push(p.slug);
|
|
736
|
-
p.frontmatter.updated = new Date().toISOString();
|
|
737
|
-
await writePage(wikiDir, p);
|
|
738
|
-
fixed.push({
|
|
739
|
-
severity: "warning",
|
|
740
|
-
category: "scope-drift",
|
|
741
|
-
page: `${p.category}/${p.slug}`,
|
|
742
|
-
message: `Fixed: added "${p.slug}" to scope`,
|
|
743
|
-
autoFixable: true,
|
|
744
|
-
});
|
|
745
|
-
}
|
|
746
|
-
return fixed;
|
|
747
|
-
}
|
|
748
|
-
// -- Main -------------------------------------------------------------------
|
|
749
|
-
export async function lintWiki(wikiDir, options) {
|
|
750
|
-
const staleDays = options?.staleDays ?? 90;
|
|
751
|
-
const autoFix = options?.autoFix ?? true;
|
|
752
|
-
const now = new Date();
|
|
753
|
-
// Load all pages
|
|
754
|
-
const refs = await listPages(wikiDir);
|
|
755
|
-
const pages = [];
|
|
756
|
-
for (const ref of refs) {
|
|
757
|
-
const page = await readPage(wikiDir, ref.category, ref.slug);
|
|
758
|
-
if (page)
|
|
759
|
-
pages.push(page);
|
|
760
|
-
}
|
|
761
|
-
// Build slug set
|
|
762
|
-
const allSlugs = new Set(pages.map((p) => p.slug));
|
|
763
|
-
// Load index and log
|
|
764
|
-
const indexContent = await readIndex(wikiDir);
|
|
765
|
-
let logContent = "";
|
|
766
|
-
try {
|
|
767
|
-
logContent = await readFile(join(wikiDir, "log.md"), "utf-8");
|
|
768
|
-
}
|
|
769
|
-
catch {
|
|
770
|
-
// log.md may not exist
|
|
771
|
-
}
|
|
772
|
-
// Count total outgoing links
|
|
773
|
-
let totalLinks = 0;
|
|
774
|
-
for (const p of pages) {
|
|
775
|
-
totalLinks += extractWikilinks(p.body).length;
|
|
776
|
-
totalLinks += p.frontmatter.links.length;
|
|
777
|
-
}
|
|
778
|
-
// Run all checks
|
|
779
|
-
const issues = [
|
|
780
|
-
...checkOrphans(pages, allSlugs),
|
|
781
|
-
...checkBrokenLinks(pages, allSlugs),
|
|
782
|
-
...checkStale(pages, staleDays, now),
|
|
783
|
-
...checkScopeDrift(pages),
|
|
784
|
-
...checkArchiveCandidates(pages, logContent, staleDays, now),
|
|
785
|
-
...checkMissing(pages, allSlugs),
|
|
786
|
-
...checkEmptyCategories(pages),
|
|
787
|
-
...checkFrontmatter(pages),
|
|
788
|
-
...checkIndexDrift(pages, indexContent),
|
|
789
|
-
...checkDuplicateSlugs(pages),
|
|
790
|
-
...checkLinkSync(pages, allSlugs),
|
|
791
|
-
...checkTagHealth(pages),
|
|
792
|
-
...checkCategoryMisuse(pages),
|
|
793
|
-
...checkSecrets(pages),
|
|
794
|
-
...checkSchemaVersion(pages),
|
|
795
|
-
];
|
|
796
|
-
// Auto-fix if enabled
|
|
797
|
-
const fixed = [];
|
|
798
|
-
if (autoFix) {
|
|
799
|
-
// Fix secrets first — highest priority
|
|
800
|
-
fixed.push(...(await fixSecrets(wikiDir, pages)));
|
|
801
|
-
fixed.push(...(await fixBrokenLinks(wikiDir, pages, allSlugs)));
|
|
802
|
-
fixed.push(...(await fixLinkSync(wikiDir, pages, allSlugs)));
|
|
803
|
-
fixed.push(...(await fixTagHealth(wikiDir, pages)));
|
|
804
|
-
fixed.push(...(await fixIndexDrift(wikiDir, pages, indexContent)));
|
|
805
|
-
fixed.push(...(await fixFrontmatter(wikiDir, pages)));
|
|
806
|
-
fixed.push(...(await fixScopeDrift(wikiDir, pages)));
|
|
807
|
-
fixed.push(...(await fixSchemaVersion(wikiDir, pages)));
|
|
808
|
-
}
|
|
809
|
-
// Recount broken links for stats
|
|
810
|
-
const brokenLinkCount = issues.filter((i) => i.category === "broken-link").length;
|
|
811
|
-
const orphanCount = issues.filter((i) => i.category === "orphan").length;
|
|
812
|
-
const stats = makeStats(pages, totalLinks, brokenLinkCount, orphanCount, logContent);
|
|
813
|
-
// Remove auto-fixed issues from the reported issues list
|
|
814
|
-
const fixedKeys = new Set(fixed.map((f) => `${f.category}:${f.page ?? ""}:${f.message}`));
|
|
815
|
-
// Filter: keep issues that weren't fixed, or that aren't fixable
|
|
816
|
-
const remainingIssues = autoFix
|
|
817
|
-
? issues.filter((i) => {
|
|
818
|
-
if (!i.autoFixable)
|
|
819
|
-
return true;
|
|
820
|
-
// For fixable issues, check if a corresponding fix was applied
|
|
821
|
-
// Match by category + page
|
|
822
|
-
return !fixed.some((f) => f.category === i.category && f.page === i.page);
|
|
823
|
-
})
|
|
824
|
-
: issues;
|
|
825
|
-
return { issues: remainingIssues, fixed, stats };
|
|
826
|
-
}
|
|
827
|
-
//# sourceMappingURL=lint.js.map
|