pi-para 0.5.2

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