@coralai/sps-cli 0.50.24 → 0.51.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/README.md +18 -1
- package/dist/commands/projectInit.d.ts +15 -0
- package/dist/commands/projectInit.d.ts.map +1 -1
- package/dist/commands/projectInit.js +191 -3
- package/dist/commands/projectInit.js.map +1 -1
- package/dist/commands/wikiCommand.d.ts +77 -0
- package/dist/commands/wikiCommand.d.ts.map +1 -0
- package/dist/commands/wikiCommand.js +489 -0
- package/dist/commands/wikiCommand.js.map +1 -0
- package/dist/console/routes/projects.d.ts.map +1 -1
- package/dist/console/routes/projects.js +1 -0
- package/dist/console/routes/projects.js.map +1 -1
- package/dist/console-assets/assets/{index-BgOHCIG1.css → index-DlwaKe2l.css} +1 -1
- package/dist/console-assets/assets/{index-QBai48VV.js → index-Gjim492C.js} +1 -1
- package/dist/console-assets/index.html +2 -2
- package/dist/core/taskPrompts.d.ts +12 -0
- package/dist/core/taskPrompts.d.ts.map +1 -1
- package/dist/core/taskPrompts.js +14 -0
- package/dist/core/taskPrompts.js.map +1 -1
- package/dist/core/wiki/frontmatter.d.ts +55 -0
- package/dist/core/wiki/frontmatter.d.ts.map +1 -0
- package/dist/core/wiki/frontmatter.js +109 -0
- package/dist/core/wiki/frontmatter.js.map +1 -0
- package/dist/core/wiki/hot.d.ts +27 -0
- package/dist/core/wiki/hot.d.ts.map +1 -0
- package/dist/core/wiki/hot.js +124 -0
- package/dist/core/wiki/hot.js.map +1 -0
- package/dist/core/wiki/index-builder.d.ts +37 -0
- package/dist/core/wiki/index-builder.d.ts.map +1 -0
- package/dist/core/wiki/index-builder.js +130 -0
- package/dist/core/wiki/index-builder.js.map +1 -0
- package/dist/core/wiki/linter.d.ts +76 -0
- package/dist/core/wiki/linter.d.ts.map +1 -0
- package/dist/core/wiki/linter.js +280 -0
- package/dist/core/wiki/linter.js.map +1 -0
- package/dist/core/wiki/log.d.ts +24 -0
- package/dist/core/wiki/log.d.ts.map +1 -0
- package/dist/core/wiki/log.js +107 -0
- package/dist/core/wiki/log.js.map +1 -0
- package/dist/core/wiki/manifest.d.ts +59 -0
- package/dist/core/wiki/manifest.d.ts.map +1 -0
- package/dist/core/wiki/manifest.js +180 -0
- package/dist/core/wiki/manifest.js.map +1 -0
- package/dist/core/wiki/page.d.ts +72 -0
- package/dist/core/wiki/page.d.ts.map +1 -0
- package/dist/core/wiki/page.js +221 -0
- package/dist/core/wiki/page.js.map +1 -0
- package/dist/core/wiki/reader.d.ts +102 -0
- package/dist/core/wiki/reader.d.ts.map +1 -0
- package/dist/core/wiki/reader.js +225 -0
- package/dist/core/wiki/reader.js.map +1 -0
- package/dist/core/wiki/scaffold.d.ts +42 -0
- package/dist/core/wiki/scaffold.d.ts.map +1 -0
- package/dist/core/wiki/scaffold.js +223 -0
- package/dist/core/wiki/scaffold.js.map +1 -0
- package/dist/core/wiki/searcher.d.ts +73 -0
- package/dist/core/wiki/searcher.d.ts.map +1 -0
- package/dist/core/wiki/searcher.js +216 -0
- package/dist/core/wiki/searcher.js.map +1 -0
- package/dist/core/wiki/sources.d.ts +84 -0
- package/dist/core/wiki/sources.d.ts.map +1 -0
- package/dist/core/wiki/sources.js +261 -0
- package/dist/core/wiki/sources.js.map +1 -0
- package/dist/core/wiki/types.d.ts +904 -0
- package/dist/core/wiki/types.d.ts.map +1 -0
- package/dist/core/wiki/types.js +109 -0
- package/dist/core/wiki/types.js.map +1 -0
- package/dist/engines/StageEngine.d.ts +17 -1
- package/dist/engines/StageEngine.d.ts.map +1 -1
- package/dist/engines/StageEngine.js +85 -0
- package/dist/engines/StageEngine.js.map +1 -1
- package/dist/main.js +78 -1
- package/dist/main.js.map +1 -1
- package/dist/services/ProjectService.d.ts +2 -0
- package/dist/services/ProjectService.d.ts.map +1 -1
- package/dist/services/ProjectService.js.map +1 -1
- package/dist/shared/wikiPaths.d.ts +38 -0
- package/dist/shared/wikiPaths.d.ts.map +1 -0
- package/dist/shared/wikiPaths.js +89 -0
- package/dist/shared/wikiPaths.js.map +1 -0
- package/package.json +1 -1
- package/skills/wiki-update/SKILL.md +300 -0
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module core/wiki/linter
|
|
3
|
+
* @description Wiki lint:orphan / dead link / frontmatter gap / stale 检查
|
|
4
|
+
*
|
|
5
|
+
* @layer core
|
|
6
|
+
*
|
|
7
|
+
* doc-28 §10 wiki check 的代码实现。纯函数(除文件 mtime 读取):给一组 Page +
|
|
8
|
+
* manifest,返一份 Report。
|
|
9
|
+
*
|
|
10
|
+
* 四类问题:
|
|
11
|
+
* - orphan — 页未被任何其他页 related[] 或 body wikilink 引用
|
|
12
|
+
* - dead-link — wikilink 指向不存在的 page
|
|
13
|
+
* - fm-gap — frontmatter 缺关键字段(其实 zod schema 已校验,这里防漏)
|
|
14
|
+
* - stale — manifest 里 source 的 hash 跟当前文件不一致(content drift)
|
|
15
|
+
*
|
|
16
|
+
* 不做:
|
|
17
|
+
* - 自动修复(删 dead link、合并 orphan)—— 安全交给人决定
|
|
18
|
+
* - 跨项目检查
|
|
19
|
+
*/
|
|
20
|
+
import { existsSync, statSync } from 'node:fs';
|
|
21
|
+
import { resolve } from 'node:path';
|
|
22
|
+
import { tryHashFile } from './manifest.js';
|
|
23
|
+
export function lintWiki(input) {
|
|
24
|
+
const issues = [];
|
|
25
|
+
issues.push(...checkOrphans(input.pages));
|
|
26
|
+
issues.push(...checkDeadLinks(input.pages));
|
|
27
|
+
issues.push(...checkFrontmatterGaps(input.pages));
|
|
28
|
+
issues.push(...checkStaleSources(input.manifest, input.repoDir));
|
|
29
|
+
return summarize(issues);
|
|
30
|
+
}
|
|
31
|
+
// ─── Orphan detection ────────────────────────────────────────────
|
|
32
|
+
/**
|
|
33
|
+
* Orphan = page that nobody links to (via frontmatter related[] OR body wikilink).
|
|
34
|
+
*
|
|
35
|
+
* A solo page can still be valuable (e.g. just-written lesson) — orphan is **warn**,
|
|
36
|
+
* not error.
|
|
37
|
+
*/
|
|
38
|
+
export function checkOrphans(pages) {
|
|
39
|
+
const ids = new Set(pages.map((p) => p.pageId));
|
|
40
|
+
const refCount = new Map();
|
|
41
|
+
for (const id of ids)
|
|
42
|
+
refCount.set(id, 0);
|
|
43
|
+
for (const p of pages) {
|
|
44
|
+
// related[] is wikilinks "[[Page Name]]" — strip brackets and check both
|
|
45
|
+
// bare-title and "type/title" forms against ids.
|
|
46
|
+
for (const link of p.frontmatter.related) {
|
|
47
|
+
const target = resolveLinkToId(link, ids);
|
|
48
|
+
if (target)
|
|
49
|
+
refCount.set(target, (refCount.get(target) ?? 0) + 1);
|
|
50
|
+
}
|
|
51
|
+
// Body wikilinks
|
|
52
|
+
for (const link of extractBodyWikilinks(p.body)) {
|
|
53
|
+
const target = resolveLinkToId(`[[${link}]]`, ids);
|
|
54
|
+
if (target)
|
|
55
|
+
refCount.set(target, (refCount.get(target) ?? 0) + 1);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const out = [];
|
|
59
|
+
for (const [pageId, count] of refCount) {
|
|
60
|
+
if (count === 0) {
|
|
61
|
+
out.push({
|
|
62
|
+
kind: 'orphan',
|
|
63
|
+
severity: 'warn',
|
|
64
|
+
pageId,
|
|
65
|
+
message: `Page "${pageId}" is not referenced by any other page.`,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
// ─── Dead-link detection ─────────────────────────────────────────
|
|
72
|
+
/**
|
|
73
|
+
* Dead link = wikilink "[[X]]" where X (resolved) is not in the page set.
|
|
74
|
+
*
|
|
75
|
+
* Errors for related[] (frontmatter); warnings for body links (in-flight writing).
|
|
76
|
+
*/
|
|
77
|
+
export function checkDeadLinks(pages) {
|
|
78
|
+
const ids = new Set(pages.map((p) => p.pageId));
|
|
79
|
+
const out = [];
|
|
80
|
+
for (const p of pages) {
|
|
81
|
+
for (const link of p.frontmatter.related) {
|
|
82
|
+
if (!resolveLinkToId(link, ids)) {
|
|
83
|
+
out.push({
|
|
84
|
+
kind: 'dead-link',
|
|
85
|
+
severity: 'error',
|
|
86
|
+
pageId: p.pageId,
|
|
87
|
+
target: link,
|
|
88
|
+
message: `frontmatter related: "${link}" does not resolve to any page.`,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
for (const link of extractBodyWikilinks(p.body)) {
|
|
93
|
+
if (!resolveLinkToId(`[[${link}]]`, ids)) {
|
|
94
|
+
out.push({
|
|
95
|
+
kind: 'dead-link',
|
|
96
|
+
severity: 'warn',
|
|
97
|
+
pageId: p.pageId,
|
|
98
|
+
target: link,
|
|
99
|
+
message: `body wikilink: "[[${link}]]" does not resolve.`,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
// ─── Frontmatter gap detection ───────────────────────────────────
|
|
107
|
+
/**
|
|
108
|
+
* Catches missing soft-required fields the zod schema can't enforce
|
|
109
|
+
* (e.g. empty title, empty TL;DR, all default tags). Schema-level errors
|
|
110
|
+
* are caught at parse time — this is a content sniff.
|
|
111
|
+
*/
|
|
112
|
+
export function checkFrontmatterGaps(pages) {
|
|
113
|
+
const out = [];
|
|
114
|
+
for (const p of pages) {
|
|
115
|
+
const fm = p.frontmatter;
|
|
116
|
+
if (!fm.title.trim()) {
|
|
117
|
+
out.push({
|
|
118
|
+
kind: 'fm-gap',
|
|
119
|
+
severity: 'error',
|
|
120
|
+
pageId: p.pageId,
|
|
121
|
+
target: 'title',
|
|
122
|
+
message: 'frontmatter title is empty.',
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
if (fm.tags.length === 0) {
|
|
126
|
+
out.push({
|
|
127
|
+
kind: 'fm-gap',
|
|
128
|
+
severity: 'warn',
|
|
129
|
+
pageId: p.pageId,
|
|
130
|
+
target: 'tags',
|
|
131
|
+
message: 'frontmatter tags is empty (page won\'t match any skill).',
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
if (!hasTLDR(p.body)) {
|
|
135
|
+
out.push({
|
|
136
|
+
kind: 'fm-gap',
|
|
137
|
+
severity: 'warn',
|
|
138
|
+
pageId: p.pageId,
|
|
139
|
+
target: 'body',
|
|
140
|
+
message: 'body has no `## TL;DR` section (search/preview will use fallback).',
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
// ─── Stale source detection ──────────────────────────────────────
|
|
147
|
+
/**
|
|
148
|
+
* Compare manifest's stored sha256 against current file hash on disk.
|
|
149
|
+
* Mismatch = source has drifted since last ingest → its derived pages may be stale.
|
|
150
|
+
*
|
|
151
|
+
* Files that don't exist on disk anymore are flagged as 'stale' too (they should
|
|
152
|
+
* be removed from manifest via finalize).
|
|
153
|
+
*/
|
|
154
|
+
export function checkStaleSources(manifest, repoDir) {
|
|
155
|
+
const out = [];
|
|
156
|
+
for (const [path, entry] of Object.entries(manifest.sources)) {
|
|
157
|
+
const abs = resolve(repoDir, path);
|
|
158
|
+
if (!existsSync(abs)) {
|
|
159
|
+
out.push({
|
|
160
|
+
kind: 'stale',
|
|
161
|
+
severity: 'warn',
|
|
162
|
+
pageId: null,
|
|
163
|
+
target: path,
|
|
164
|
+
message: `Source "${path}" no longer exists. Run \`sps wiki update <project> --finalize\` to clean.`,
|
|
165
|
+
});
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
const currentHash = tryHashFile(abs);
|
|
169
|
+
if (currentHash && currentHash !== entry.sha256) {
|
|
170
|
+
// Best effort: include affected pages
|
|
171
|
+
const affected = entry.pages.length > 0 ? ` (affects: ${entry.pages.join(', ')})` : '';
|
|
172
|
+
out.push({
|
|
173
|
+
kind: 'stale',
|
|
174
|
+
severity: 'warn',
|
|
175
|
+
pageId: null,
|
|
176
|
+
target: path,
|
|
177
|
+
message: `Source "${path}" has changed since ingest${affected}.`,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return out;
|
|
182
|
+
}
|
|
183
|
+
// ─── Helpers ──────────────────────────────────────────────────────
|
|
184
|
+
/**
|
|
185
|
+
* Resolve a "[[X]]" or "[[type/X]]" wikilink against the known pageId set.
|
|
186
|
+
*
|
|
187
|
+
* Strategy:
|
|
188
|
+
* - "[[type/X]]" → look up exact pageId
|
|
189
|
+
* - "[[X]]" → look up any pageId ending with "/X" (any type matches)
|
|
190
|
+
*
|
|
191
|
+
* Returns the resolved pageId, or null if unresolvable.
|
|
192
|
+
*/
|
|
193
|
+
export function resolveLinkToId(wikilink, ids) {
|
|
194
|
+
const m = wikilink.match(/^\[\[([^[\]]+)\]\]$/);
|
|
195
|
+
if (!m)
|
|
196
|
+
return null;
|
|
197
|
+
const target = m[1].trim();
|
|
198
|
+
if (target.includes('/')) {
|
|
199
|
+
return ids.has(target) ? target : null;
|
|
200
|
+
}
|
|
201
|
+
// No prefix — find any pageId whose title matches
|
|
202
|
+
for (const id of ids) {
|
|
203
|
+
const slash = id.lastIndexOf('/');
|
|
204
|
+
if (slash >= 0 && id.slice(slash + 1) === target)
|
|
205
|
+
return id;
|
|
206
|
+
}
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
const BODY_LINK_RE = /\[\[([^[\]]+)\]\]/g;
|
|
210
|
+
export function extractBodyWikilinks(body) {
|
|
211
|
+
const out = [];
|
|
212
|
+
for (const m of body.matchAll(BODY_LINK_RE)) {
|
|
213
|
+
out.push(m[1].trim());
|
|
214
|
+
}
|
|
215
|
+
return out;
|
|
216
|
+
}
|
|
217
|
+
const TLDR_RE = /^##\s+TL;DR\b/m;
|
|
218
|
+
function hasTLDR(body) {
|
|
219
|
+
return TLDR_RE.test(body);
|
|
220
|
+
}
|
|
221
|
+
function summarize(issues) {
|
|
222
|
+
const counts = {
|
|
223
|
+
orphan: 0,
|
|
224
|
+
'dead-link': 0,
|
|
225
|
+
'fm-gap': 0,
|
|
226
|
+
stale: 0,
|
|
227
|
+
};
|
|
228
|
+
let errorCount = 0;
|
|
229
|
+
let warnCount = 0;
|
|
230
|
+
for (const i of issues) {
|
|
231
|
+
counts[i.kind] += 1;
|
|
232
|
+
if (i.severity === 'error')
|
|
233
|
+
errorCount += 1;
|
|
234
|
+
else
|
|
235
|
+
warnCount += 1;
|
|
236
|
+
}
|
|
237
|
+
return { issues: [...issues], counts, errorCount, warnCount };
|
|
238
|
+
}
|
|
239
|
+
// ─── Mtime helper (used by status command) ────────────────────────
|
|
240
|
+
/**
|
|
241
|
+
* Compare source mtime vs page mtime. Returns paths whose source is newer.
|
|
242
|
+
*
|
|
243
|
+
* Best-effort — file mtime precision varies (ext4 ns vs HFS+ 1s); we use a
|
|
244
|
+
* 60s threshold to avoid false positives.
|
|
245
|
+
*/
|
|
246
|
+
export function findOutdatedPages(manifest, pages, repoDir, thresholdMs = 60_000) {
|
|
247
|
+
const out = [];
|
|
248
|
+
const pageMtimeById = new Map();
|
|
249
|
+
for (const p of pages) {
|
|
250
|
+
try {
|
|
251
|
+
pageMtimeById.set(p.pageId, statSync(p.filePath).mtime);
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
// ignore
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
for (const [sourcePath, entry] of Object.entries(manifest.sources)) {
|
|
258
|
+
if (entry.pages.length === 0)
|
|
259
|
+
continue;
|
|
260
|
+
let sourceMtime;
|
|
261
|
+
try {
|
|
262
|
+
sourceMtime = statSync(resolve(repoDir, sourcePath)).mtime;
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
const outdated = [];
|
|
268
|
+
for (const pid of entry.pages) {
|
|
269
|
+
const pmtime = pageMtimeById.get(pid);
|
|
270
|
+
if (!pmtime)
|
|
271
|
+
continue;
|
|
272
|
+
if (sourceMtime.getTime() - pmtime.getTime() > thresholdMs)
|
|
273
|
+
outdated.push(pid);
|
|
274
|
+
}
|
|
275
|
+
if (outdated.length > 0)
|
|
276
|
+
out.push({ sourcePath, pageIds: outdated, sourceMtime });
|
|
277
|
+
}
|
|
278
|
+
return out;
|
|
279
|
+
}
|
|
280
|
+
//# sourceMappingURL=linter.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"linter.js","sourceRoot":"","sources":["../../../src/core/wiki/linter.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export type LogAction = 'ingest' | 'write' | 'update' | 'lint' | 'init' | 'add' | 'delete';
|
|
2
|
+
export interface LogEntry {
|
|
3
|
+
action: LogAction;
|
|
4
|
+
/** 时间戳;缺省 now */
|
|
5
|
+
timestamp?: string;
|
|
6
|
+
/** Source 路径或 page id 或简短描述 */
|
|
7
|
+
target: string;
|
|
8
|
+
/** 一句话描述(≤ 100 字符) */
|
|
9
|
+
message: string;
|
|
10
|
+
/** 受影响的 page id 列表(可选) */
|
|
11
|
+
pages?: readonly string[];
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* 在 log.md 顶部插入一条 entry。
|
|
15
|
+
*
|
|
16
|
+
* - 文件不存在:创建带 header
|
|
17
|
+
* - 已有内容:保留最近 MAX_ENTRIES 条(旧的截掉)
|
|
18
|
+
*/
|
|
19
|
+
export declare function appendLog(repoDir: string, entry: LogEntry): void;
|
|
20
|
+
/**
|
|
21
|
+
* 读 log.md 全文。文件不存在返默认 header。
|
|
22
|
+
*/
|
|
23
|
+
export declare function readLog(repoDir: string): string;
|
|
24
|
+
//# sourceMappingURL=log.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"log.d.ts","sourceRoot":"","sources":["../../../src/core/wiki/log.ts"],"names":[],"mappings":"AA+BA,MAAM,MAAM,SAAS,GAAG,QAAQ,GAAG,OAAO,GAAG,QAAQ,GAAG,MAAM,GAAG,MAAM,GAAG,KAAK,GAAG,QAAQ,CAAC;AAE3F,MAAM,WAAW,QAAQ;IACvB,MAAM,EAAE,SAAS,CAAC;IAClB,iBAAiB;IACjB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,+BAA+B;IAC/B,MAAM,EAAE,MAAM,CAAC;IACf,sBAAsB;IACtB,OAAO,EAAE,MAAM,CAAC;IAChB,0BAA0B;IAC1B,KAAK,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAC3B;AAED;;;;;GAKG;AACH,wBAAgB,SAAS,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,QAAQ,GAAG,IAAI,CAwChE;AAED;;GAEG;AACH,wBAAgB,OAAO,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAQ/C"}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module core/wiki/log
|
|
3
|
+
* @description Wiki .log.md:操作时间序列(per-instance,gitignored)
|
|
4
|
+
*
|
|
5
|
+
* @layer core
|
|
6
|
+
*
|
|
7
|
+
* 借鉴 claude-obsidian 的 wiki/log.md:每次 ingest / write / lint 在文件
|
|
8
|
+
* 顶部追加一条事件,便于 Worker / 用户回看 "最近 wiki 怎么变的"。
|
|
9
|
+
*
|
|
10
|
+
* 实现选择:
|
|
11
|
+
* - 新条目**顶部追加**(最新在最前),方便 Worker 默认读前几条就看到 latest
|
|
12
|
+
* - hard cap 500 条(~5000 行),超过截尾——log 不是 audit,旧条目用 git/hot.md 找
|
|
13
|
+
* - frontmatter 固定(type=meta),不需要解析
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import { dirname } from 'node:path';
|
|
17
|
+
import { wikiLogFile } from '../../shared/wikiPaths.js';
|
|
18
|
+
const HEADER = `---
|
|
19
|
+
type: meta
|
|
20
|
+
title: Wiki Operation Log
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
# Wiki Operation Log
|
|
24
|
+
|
|
25
|
+
`;
|
|
26
|
+
const MAX_ENTRIES = 500;
|
|
27
|
+
/**
|
|
28
|
+
* 在 log.md 顶部插入一条 entry。
|
|
29
|
+
*
|
|
30
|
+
* - 文件不存在:创建带 header
|
|
31
|
+
* - 已有内容:保留最近 MAX_ENTRIES 条(旧的截掉)
|
|
32
|
+
*/
|
|
33
|
+
export function appendLog(repoDir, entry) {
|
|
34
|
+
const path = wikiLogFile(repoDir);
|
|
35
|
+
const ts = entry.timestamp ?? new Date().toISOString();
|
|
36
|
+
const block = renderEntry({ ...entry, timestamp: ts });
|
|
37
|
+
let existing;
|
|
38
|
+
if (existsSync(path)) {
|
|
39
|
+
try {
|
|
40
|
+
existing = readFileSync(path, 'utf-8');
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
existing = HEADER;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
existing = HEADER;
|
|
48
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
49
|
+
}
|
|
50
|
+
// 拆 header + 旧 entries
|
|
51
|
+
const headerEnd = existing.indexOf('# Wiki Operation Log\n');
|
|
52
|
+
let header;
|
|
53
|
+
let entries;
|
|
54
|
+
if (headerEnd === -1) {
|
|
55
|
+
header = HEADER;
|
|
56
|
+
entries = '';
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
const afterHeader = existing.indexOf('\n', headerEnd) + 1;
|
|
60
|
+
header = existing.slice(0, afterHeader + 1);
|
|
61
|
+
entries = existing.slice(afterHeader + 1);
|
|
62
|
+
}
|
|
63
|
+
// 拆现有 entries(按 ## 开头分组),去除可能的 trailing whitespace
|
|
64
|
+
const oldBlocks = entries.split(/(?=^## )/m).filter((b) => b.trim().length > 0);
|
|
65
|
+
// 顶部插入新 block
|
|
66
|
+
const updatedBlocks = [block, ...oldBlocks];
|
|
67
|
+
// cap 数量
|
|
68
|
+
const truncated = updatedBlocks.slice(0, MAX_ENTRIES);
|
|
69
|
+
const final = header + truncated.join('') + (truncated.length === MAX_ENTRIES ? '\n…(older entries truncated)\n' : '');
|
|
70
|
+
writeFileSync(path, final, { encoding: 'utf-8', mode: 0o644 });
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* 读 log.md 全文。文件不存在返默认 header。
|
|
74
|
+
*/
|
|
75
|
+
export function readLog(repoDir) {
|
|
76
|
+
const path = wikiLogFile(repoDir);
|
|
77
|
+
if (!existsSync(path))
|
|
78
|
+
return HEADER;
|
|
79
|
+
try {
|
|
80
|
+
return readFileSync(path, 'utf-8');
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return HEADER;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// ─── Render ───────────────────────────────────────────────────────
|
|
87
|
+
/**
|
|
88
|
+
* 单条 log entry 渲染:
|
|
89
|
+
*
|
|
90
|
+
* ```md
|
|
91
|
+
* ## 2026-04-27T18:30:00Z · update · src/X.ts
|
|
92
|
+
* 增量 ingest:3 个文件改动
|
|
93
|
+
* - Pages: [[modules/X]], [[lessons/Stop Hook Race]]
|
|
94
|
+
* ```
|
|
95
|
+
*/
|
|
96
|
+
function renderEntry(entry) {
|
|
97
|
+
const lines = [];
|
|
98
|
+
lines.push(`## ${entry.timestamp} · ${entry.action} · ${entry.target}`);
|
|
99
|
+
lines.push(entry.message.trim());
|
|
100
|
+
if (entry.pages && entry.pages.length > 0) {
|
|
101
|
+
const wikilinks = entry.pages.map((p) => `[[${p}]]`).join(', ');
|
|
102
|
+
lines.push(`- Pages: ${wikilinks}`);
|
|
103
|
+
}
|
|
104
|
+
lines.push('');
|
|
105
|
+
return lines.join('\n');
|
|
106
|
+
}
|
|
107
|
+
//# sourceMappingURL=log.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"log.js","sourceRoot":"","sources":["../../../src/core/wiki/log.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { type Manifest, type ManifestEntry, type SourceDiff } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* 读 manifest 文件。
|
|
4
|
+
* 文件不存在 / 损坏 / schema 不合法 → 返 EMPTY_MANIFEST(同时调 onWarn 报告)。
|
|
5
|
+
*
|
|
6
|
+
* 不 throw —— manifest 只是性能优化,损坏时退化为全量重 ingest 即可。
|
|
7
|
+
*/
|
|
8
|
+
export declare function readManifest(manifestPath: string, onWarn?: (msg: string) => void): Manifest;
|
|
9
|
+
/**
|
|
10
|
+
* 写 manifest 文件。原子替换(temp + rename),避免读到半截。
|
|
11
|
+
*/
|
|
12
|
+
export declare function writeManifest(manifestPath: string, manifest: Manifest): void;
|
|
13
|
+
/**
|
|
14
|
+
* 计算文件 sha256 hex。文件不存在或读失败 → throw(调用方决定是否兜底)。
|
|
15
|
+
*
|
|
16
|
+
* 大文件友好:用 buffer reads 而不是一次性读到内存——但 wiki 源文件
|
|
17
|
+
* 一般 < 1 MB,readFileSync 已经够。如果将来要 ingest 大 PDF,再换 streaming。
|
|
18
|
+
*/
|
|
19
|
+
export declare function hashFile(filePath: string): string;
|
|
20
|
+
/**
|
|
21
|
+
* 同上但是宽松:失败时返 null(用于 lint / 增量扫描)。
|
|
22
|
+
*/
|
|
23
|
+
export declare function tryHashFile(filePath: string): string | null;
|
|
24
|
+
export interface DiffInputSource {
|
|
25
|
+
/** Path relative to repo root (e.g. "src/X.ts" 或 ".raw/y.pdf") */
|
|
26
|
+
readonly path: string;
|
|
27
|
+
/** Pre-computed hash (caller responsibility) */
|
|
28
|
+
readonly hash: string;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* 比较"当前发现的源 + 它们的 hash"和 manifest 里记录的状态。
|
|
32
|
+
*
|
|
33
|
+
* - added:current 里有,manifest 里没
|
|
34
|
+
* - changed:两边都有但 hash 不同
|
|
35
|
+
* - removed:manifest 里有,current 里没(源被删了)
|
|
36
|
+
* - unchanged:两边都有且 hash 相同
|
|
37
|
+
*
|
|
38
|
+
* 纯函数,无 I/O。
|
|
39
|
+
*/
|
|
40
|
+
export declare function diffSources(current: readonly DiffInputSource[], manifest: Manifest): SourceDiff;
|
|
41
|
+
/**
|
|
42
|
+
* 标记一个 source 已 ingest 过。返回新 manifest(不修改原对象)。
|
|
43
|
+
*
|
|
44
|
+
* 用途:每次 ingest 一个 source 后调一次。如果 path 已存在,覆盖;否则添加。
|
|
45
|
+
*/
|
|
46
|
+
export declare function recordIngest(manifest: Manifest, path: string, entry: ManifestEntry): Manifest;
|
|
47
|
+
/**
|
|
48
|
+
* 删除一个 source 的记录(被删除时用)。返回新 manifest。
|
|
49
|
+
*/
|
|
50
|
+
export declare function removeFromManifest(manifest: Manifest, path: string): Manifest;
|
|
51
|
+
/**
|
|
52
|
+
* 检查 source 文件 mtime > 它所派生的 page 中任何一个的 updated 字段。
|
|
53
|
+
* 用于 lint:标 stale page。
|
|
54
|
+
*
|
|
55
|
+
* 这是 best-effort —— 跨文件系统 mtime 精度不一致(macOS HFS+ 1s,
|
|
56
|
+
* Linux ext4 ns)。差几秒不算 stale。
|
|
57
|
+
*/
|
|
58
|
+
export declare function isSourceStale(sourcePath: string, pageMtime: Date | null): boolean;
|
|
59
|
+
//# sourceMappingURL=manifest.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"manifest.d.ts","sourceRoot":"","sources":["../../../src/core/wiki/manifest.ts"],"names":[],"mappings":"AAkBA,OAAO,EAEL,KAAK,QAAQ,EACb,KAAK,aAAa,EAElB,KAAK,UAAU,EAChB,MAAM,YAAY,CAAC;AAIpB;;;;;GAKG;AACH,wBAAgB,YAAY,CAC1B,YAAY,EAAE,MAAM,EACpB,MAAM,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,GAC7B,QAAQ,CA0BV;AAED;;GAEG;AACH,wBAAgB,aAAa,CAAC,YAAY,EAAE,MAAM,EAAE,QAAQ,EAAE,QAAQ,GAAG,IAAI,CAQ5E;AAID;;;;;GAKG;AACH,wBAAgB,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAGjD;AAED;;GAEG;AACH,wBAAgB,WAAW,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAM3D;AAID,MAAM,WAAW,eAAe;IAC9B,kEAAkE;IAClE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,gDAAgD;IAChD,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACvB;AAED;;;;;;;;;GASG;AACH,wBAAgB,WAAW,CACzB,OAAO,EAAE,SAAS,eAAe,EAAE,EACnC,QAAQ,EAAE,QAAQ,GACjB,UAAU,CA8BZ;AAID;;;;GAIG;AACH,wBAAgB,YAAY,CAC1B,QAAQ,EAAE,QAAQ,EAClB,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,aAAa,GACnB,QAAQ,CAOV;AAED;;GAEG;AACH,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,QAAQ,EAAE,IAAI,EAAE,MAAM,GAAG,QAAQ,CAS7E;AAED;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,UAAU,EAAE,MAAM,EAAE,SAAS,EAAE,IAAI,GAAG,IAAI,GAAG,OAAO,CASjF"}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module core/wiki/manifest
|
|
3
|
+
* @description Wiki 增量索引:源文件 hash 跟踪表(.manifest.json)
|
|
4
|
+
*
|
|
5
|
+
* @layer core
|
|
6
|
+
*
|
|
7
|
+
* 作用:每次 `sps wiki update` 时比 hash,决定哪些 source 需要重新 ingest——
|
|
8
|
+
* 避免每次都让 Worker 重读全部源文件。
|
|
9
|
+
*
|
|
10
|
+
* 设计要点(doc-28 §5 + §8):
|
|
11
|
+
* - 文件位置:`<repo>/wiki/.manifest.json`(gitignored)
|
|
12
|
+
* - 每台机一份;不同 dev 的 manifest 不互相覆盖
|
|
13
|
+
* - 源路径用相对 repo 根目录("src/X.ts" / ".raw/pdfs/y.pdf")
|
|
14
|
+
* - hash 用 sha256,文件级原子单位(不切分到行/段)
|
|
15
|
+
* - 跟 zod schema 校验过——损坏的 manifest 当作 EMPTY 重建,不阻塞 update
|
|
16
|
+
*/
|
|
17
|
+
import { createHash } from 'node:crypto';
|
|
18
|
+
import { existsSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
|
|
19
|
+
import { EMPTY_MANIFEST, ManifestSchema, } from './types.js';
|
|
20
|
+
// ─── 文件 I/O ─────────────────────────────────────────────────────
|
|
21
|
+
/**
|
|
22
|
+
* 读 manifest 文件。
|
|
23
|
+
* 文件不存在 / 损坏 / schema 不合法 → 返 EMPTY_MANIFEST(同时调 onWarn 报告)。
|
|
24
|
+
*
|
|
25
|
+
* 不 throw —— manifest 只是性能优化,损坏时退化为全量重 ingest 即可。
|
|
26
|
+
*/
|
|
27
|
+
export function readManifest(manifestPath, onWarn) {
|
|
28
|
+
if (!existsSync(manifestPath))
|
|
29
|
+
return EMPTY_MANIFEST;
|
|
30
|
+
let raw;
|
|
31
|
+
try {
|
|
32
|
+
raw = readFileSync(manifestPath, 'utf-8');
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
onWarn?.(`manifest read failed (${manifestPath}): ${errMsg(err)}`);
|
|
36
|
+
return EMPTY_MANIFEST;
|
|
37
|
+
}
|
|
38
|
+
let parsed;
|
|
39
|
+
try {
|
|
40
|
+
parsed = JSON.parse(raw);
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
onWarn?.(`manifest JSON parse failed (${manifestPath}): ${errMsg(err)} — treating as empty`);
|
|
44
|
+
return EMPTY_MANIFEST;
|
|
45
|
+
}
|
|
46
|
+
const result = ManifestSchema.safeParse(parsed);
|
|
47
|
+
if (!result.success) {
|
|
48
|
+
onWarn?.(`manifest schema invalid (${manifestPath}): ${result.error.issues
|
|
49
|
+
.map((i) => `${i.path.join('.')}:${i.message}`)
|
|
50
|
+
.join('; ')} — treating as empty`);
|
|
51
|
+
return EMPTY_MANIFEST;
|
|
52
|
+
}
|
|
53
|
+
return result.data;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* 写 manifest 文件。原子替换(temp + rename),避免读到半截。
|
|
57
|
+
*/
|
|
58
|
+
export function writeManifest(manifestPath, manifest) {
|
|
59
|
+
const validated = ManifestSchema.parse(manifest); // 写入前最后一道校验
|
|
60
|
+
const text = JSON.stringify(validated, null, 2) + '\n';
|
|
61
|
+
// node:fs writeFileSync 已经是原子的(POSIX rename semantics)—— 但为防止 partial
|
|
62
|
+
// write 在 crash 场景留半截文件,用 .tmp + rename。
|
|
63
|
+
const tmpPath = manifestPath + '.tmp';
|
|
64
|
+
writeFileSync(tmpPath, text, { encoding: 'utf-8', mode: 0o644 });
|
|
65
|
+
renameSync(tmpPath, manifestPath);
|
|
66
|
+
}
|
|
67
|
+
// ─── Hash 计算 ────────────────────────────────────────────────────
|
|
68
|
+
/**
|
|
69
|
+
* 计算文件 sha256 hex。文件不存在或读失败 → throw(调用方决定是否兜底)。
|
|
70
|
+
*
|
|
71
|
+
* 大文件友好:用 buffer reads 而不是一次性读到内存——但 wiki 源文件
|
|
72
|
+
* 一般 < 1 MB,readFileSync 已经够。如果将来要 ingest 大 PDF,再换 streaming。
|
|
73
|
+
*/
|
|
74
|
+
export function hashFile(filePath) {
|
|
75
|
+
const buf = readFileSync(filePath);
|
|
76
|
+
return createHash('sha256').update(buf).digest('hex');
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* 同上但是宽松:失败时返 null(用于 lint / 增量扫描)。
|
|
80
|
+
*/
|
|
81
|
+
export function tryHashFile(filePath) {
|
|
82
|
+
try {
|
|
83
|
+
return hashFile(filePath);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* 比较"当前发现的源 + 它们的 hash"和 manifest 里记录的状态。
|
|
91
|
+
*
|
|
92
|
+
* - added:current 里有,manifest 里没
|
|
93
|
+
* - changed:两边都有但 hash 不同
|
|
94
|
+
* - removed:manifest 里有,current 里没(源被删了)
|
|
95
|
+
* - unchanged:两边都有且 hash 相同
|
|
96
|
+
*
|
|
97
|
+
* 纯函数,无 I/O。
|
|
98
|
+
*/
|
|
99
|
+
export function diffSources(current, manifest) {
|
|
100
|
+
const currentMap = new Map(current.map((s) => [s.path, s.hash]));
|
|
101
|
+
const manifestPaths = new Set(Object.keys(manifest.sources));
|
|
102
|
+
const added = [];
|
|
103
|
+
const changed = [];
|
|
104
|
+
const unchanged = [];
|
|
105
|
+
for (const [path, hash] of currentMap) {
|
|
106
|
+
const entry = manifest.sources[path];
|
|
107
|
+
if (!entry) {
|
|
108
|
+
added.push(path);
|
|
109
|
+
}
|
|
110
|
+
else if (entry.sha256 !== hash) {
|
|
111
|
+
changed.push(path);
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
unchanged.push(path);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const removed = [];
|
|
118
|
+
for (const path of manifestPaths) {
|
|
119
|
+
if (!currentMap.has(path))
|
|
120
|
+
removed.push(path);
|
|
121
|
+
}
|
|
122
|
+
return {
|
|
123
|
+
added: added.sort(),
|
|
124
|
+
changed: changed.sort(),
|
|
125
|
+
removed: removed.sort(),
|
|
126
|
+
unchanged: unchanged.sort(),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
// ─── 更新 manifest(in-place pure,返回新 manifest) ───────────────
|
|
130
|
+
/**
|
|
131
|
+
* 标记一个 source 已 ingest 过。返回新 manifest(不修改原对象)。
|
|
132
|
+
*
|
|
133
|
+
* 用途:每次 ingest 一个 source 后调一次。如果 path 已存在,覆盖;否则添加。
|
|
134
|
+
*/
|
|
135
|
+
export function recordIngest(manifest, path, entry) {
|
|
136
|
+
const sources = { ...manifest.sources, [path]: entry };
|
|
137
|
+
return {
|
|
138
|
+
...manifest,
|
|
139
|
+
updated_at: new Date().toISOString(),
|
|
140
|
+
sources,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* 删除一个 source 的记录(被删除时用)。返回新 manifest。
|
|
145
|
+
*/
|
|
146
|
+
export function removeFromManifest(manifest, path) {
|
|
147
|
+
if (!(path in manifest.sources))
|
|
148
|
+
return manifest;
|
|
149
|
+
const { [path]: _removed, ...rest } = manifest.sources;
|
|
150
|
+
void _removed;
|
|
151
|
+
return {
|
|
152
|
+
...manifest,
|
|
153
|
+
updated_at: new Date().toISOString(),
|
|
154
|
+
sources: rest,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* 检查 source 文件 mtime > 它所派生的 page 中任何一个的 updated 字段。
|
|
159
|
+
* 用于 lint:标 stale page。
|
|
160
|
+
*
|
|
161
|
+
* 这是 best-effort —— 跨文件系统 mtime 精度不一致(macOS HFS+ 1s,
|
|
162
|
+
* Linux ext4 ns)。差几秒不算 stale。
|
|
163
|
+
*/
|
|
164
|
+
export function isSourceStale(sourcePath, pageMtime) {
|
|
165
|
+
if (!pageMtime)
|
|
166
|
+
return false;
|
|
167
|
+
try {
|
|
168
|
+
const sourceMtime = statSync(sourcePath).mtime;
|
|
169
|
+
// 超过 60s 才算改过——避免精度问题误报
|
|
170
|
+
return sourceMtime.getTime() - pageMtime.getTime() > 60_000;
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return false; // source 不存在 = 不算 stale,归 lint 别的检查
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
// ─── helpers ──────────────────────────────────────────────────────
|
|
177
|
+
function errMsg(err) {
|
|
178
|
+
return err instanceof Error ? err.message : String(err);
|
|
179
|
+
}
|
|
180
|
+
//# sourceMappingURL=manifest.js.map
|