@m16khb/llm-wiki 0.1.0 → 0.1.1
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 +196 -47
- package/cmd/llm-wiki/main.go +451 -11
- package/internal/mcpautostart/autostart.go +285 -0
- package/internal/mcpautostart/autostart_test.go +169 -0
- package/internal/mcpautostart/detach_unix.go +9 -0
- package/internal/mcpautostart/detach_windows.go +7 -0
- package/internal/mcpautostart/proxy.go +162 -0
- package/internal/sessionctx/sessionctx.go +649 -0
- package/internal/sessionctx/sessionctx_test.go +415 -0
- package/internal/wiki/cleanup.go +1189 -0
- package/internal/wiki/cleanup_plan.go +569 -0
- package/internal/wiki/cleanup_test.go +450 -0
- package/internal/wiki/init.go +77 -0
- package/internal/wiki/vault_test.go +41 -0
- package/package.json +1 -1
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
package wiki
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"os"
|
|
5
|
+
"path/filepath"
|
|
6
|
+
"strings"
|
|
7
|
+
"testing"
|
|
8
|
+
"time"
|
|
9
|
+
"unicode/utf8"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
func TestCleanupRepositoryReferencesDryRunDoesNotModifyVault(t *testing.T) {
|
|
13
|
+
root := t.TempDir()
|
|
14
|
+
writeRepositoryReferenceFixture(t, root)
|
|
15
|
+
v := newTestVault(t, root)
|
|
16
|
+
v.Now = func() time.Time { return time.Date(2026, 5, 23, 12, 0, 0, 0, time.UTC) }
|
|
17
|
+
|
|
18
|
+
result, err := v.CleanupRepositoryReferences(RepositoryReferenceCleanupOptions{})
|
|
19
|
+
if err != nil {
|
|
20
|
+
t.Fatalf("CleanupRepositoryReferences() dry-run error = %v", err)
|
|
21
|
+
}
|
|
22
|
+
if result.Applied {
|
|
23
|
+
t.Fatalf("dry-run should not be applied: %+v", result)
|
|
24
|
+
}
|
|
25
|
+
if len(result.Bundles) != 1 {
|
|
26
|
+
t.Fatalf("expected one planned bundle, got %+v", result.Bundles)
|
|
27
|
+
}
|
|
28
|
+
bundle := result.Bundles[0]
|
|
29
|
+
if bundle.Repository != "sample-repo" || bundle.BundlePath != "20-wiki/concepts/repository-references/sample-repo/sample-repo-reference-bundle-2026-05-23.md" {
|
|
30
|
+
t.Fatalf("unexpected bundle plan: %+v", bundle)
|
|
31
|
+
}
|
|
32
|
+
if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(bundle.BundlePath))); !os.IsNotExist(err) {
|
|
33
|
+
t.Fatalf("dry-run should not create bundle, stat err=%v", err)
|
|
34
|
+
}
|
|
35
|
+
if _, err := os.Stat(filepath.Join(root, "20-wiki/concepts/repository-references/sample-repo/sample-repo-go-rules-2026-05-22.md")); err != nil {
|
|
36
|
+
t.Fatalf("dry-run should leave fragment in place: %v", err)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
func TestCleanupRepositoryReferencesApplyBundlesArchivesAndRewritesLinks(t *testing.T) {
|
|
41
|
+
root := t.TempDir()
|
|
42
|
+
writeRepositoryReferenceFixture(t, root)
|
|
43
|
+
v := newTestVault(t, root)
|
|
44
|
+
v.Now = func() time.Time { return time.Date(2026, 5, 23, 12, 0, 0, 0, time.UTC) }
|
|
45
|
+
|
|
46
|
+
result, err := v.CleanupRepositoryReferences(RepositoryReferenceCleanupOptions{Apply: true})
|
|
47
|
+
if err != nil {
|
|
48
|
+
t.Fatalf("CleanupRepositoryReferences() apply error = %v", err)
|
|
49
|
+
}
|
|
50
|
+
if !result.Applied {
|
|
51
|
+
t.Fatalf("expected apply result: %+v", result)
|
|
52
|
+
}
|
|
53
|
+
if len(result.Bundles) != 1 {
|
|
54
|
+
t.Fatalf("expected one bundle, got %+v", result.Bundles)
|
|
55
|
+
}
|
|
56
|
+
bundle := result.Bundles[0]
|
|
57
|
+
if len(bundle.FragmentPaths) != 2 || len(bundle.ArchivedPaths) != 2 {
|
|
58
|
+
t.Fatalf("expected two fragments archived, got %+v", bundle)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
bundleData := readTestFile(t, root, bundle.BundlePath)
|
|
62
|
+
for _, want := range []string{
|
|
63
|
+
"title: \"Sample Repo Reference Bundle 2026-05-23\"",
|
|
64
|
+
"Consolidated on 2026-05-23 from 2 repository-reference fragments.",
|
|
65
|
+
"Go Rules",
|
|
66
|
+
"Swagger Rules",
|
|
67
|
+
"repo-source-card",
|
|
68
|
+
"_archive/repository-reference-fragments-2026-05-23/sample-repo/archived-sample-repo-go-rules-2026-05-22.md",
|
|
69
|
+
} {
|
|
70
|
+
if !strings.Contains(bundleData, want) {
|
|
71
|
+
t.Fatalf("bundle missing %q:\n%s", want, bundleData)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if strings.Contains(bundleData, "[[repo-source-card]]") {
|
|
75
|
+
t.Fatalf("bundle summary should sanitize wikilinks to plain text:\n%s", bundleData)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
for _, oldPath := range bundle.FragmentPaths {
|
|
79
|
+
if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(oldPath))); !os.IsNotExist(err) {
|
|
80
|
+
t.Fatalf("expected original fragment archived away %s, stat err=%v", oldPath, err)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
archiveData := readTestFile(t, root, "_archive/repository-reference-fragments-2026-05-23/sample-repo/archived-sample-repo-go-rules-2026-05-22.md")
|
|
84
|
+
if !strings.Contains(archiveData, "status: archived") || !strings.Contains(archiveData, "[[sample-repo-reference-bundle-2026-05-23]]") {
|
|
85
|
+
t.Fatalf("archived fragment should point at bundle:\n%s", archiveData)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
consumerData := readTestFile(t, root, "20-wiki/concepts/consumer.md")
|
|
89
|
+
if !strings.Contains(consumerData, "[[sample-repo-reference-bundle-2026-05-23]]") ||
|
|
90
|
+
!strings.Contains(consumerData, "[[sample-repo-reference-bundle-2026-05-23|swagger]]") ||
|
|
91
|
+
strings.Contains(consumerData, "sample-repo-go-rules-2026-05-22") {
|
|
92
|
+
t.Fatalf("consumer links were not rewritten:\n%s", consumerData)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
indexData := readTestFile(t, root, "00-meta/index.md")
|
|
96
|
+
if !strings.Contains(indexData, "[[sample-repo-reference-bundle-2026-05-23]]") ||
|
|
97
|
+
strings.Contains(indexData, "sample-repo-swagger-rules-2026-05-22") {
|
|
98
|
+
t.Fatalf("index was not collapsed to bundle:\n%s", indexData)
|
|
99
|
+
}
|
|
100
|
+
if strings.Contains(indexData, "[[sample-repo-reference-bundle-2026-05-23]], [[sample-repo-reference-bundle-2026-05-23]]") {
|
|
101
|
+
t.Fatalf("index should dedupe repeated bundle-only bullets:\n%s", indexData)
|
|
102
|
+
}
|
|
103
|
+
if strings.Count(indexData, "- [[sample-repo-reference-bundle-2026-05-23]]") != 3 {
|
|
104
|
+
t.Fatalf("expected canonical, bold-tech, and data index entries only:\n%s", indexData)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
sourceData := readTestFile(t, root, "10-sources/source-card.md")
|
|
108
|
+
if strings.Contains(sourceData, "sample-repo-reference-bundle") {
|
|
109
|
+
t.Fatalf("10-sources body must remain read-only, got:\n%s", sourceData)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
lint, err := v.Lint()
|
|
113
|
+
if err != nil {
|
|
114
|
+
t.Fatalf("Lint() after cleanup error = %v", err)
|
|
115
|
+
}
|
|
116
|
+
if !lint.OK {
|
|
117
|
+
t.Fatalf("expected clean lint after cleanup, got %+v", lint)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
func TestCleanupSummarySanitizesWikilinksAndTruncatesUTF8Safely(t *testing.T) {
|
|
122
|
+
summary := summarizeMarkdownBody(testFrontmatter("Korean", "reference") + `
|
|
123
|
+
# Korean
|
|
124
|
+
|
|
125
|
+
한글 문장을 충분히 길게 반복해서 byte 기준 절단이 UTF-8을 깨지 않게 만든다. [[old-fragment|표시명]] ` + strings.Repeat("가나다라마바사 ", 80) + `
|
|
126
|
+
`)
|
|
127
|
+
if !utf8.ValidString(summary) {
|
|
128
|
+
t.Fatalf("summary should remain valid UTF-8: %q", summary)
|
|
129
|
+
}
|
|
130
|
+
if strings.Contains(summary, "[[") || strings.Contains(summary, "]]") || strings.Contains(summary, "old-fragment") {
|
|
131
|
+
t.Fatalf("summary should sanitize wikilinks to labels/plain text: %s", summary)
|
|
132
|
+
}
|
|
133
|
+
if !strings.Contains(summary, "표시명") {
|
|
134
|
+
t.Fatalf("summary should keep wikilink label: %s", summary)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
func TestCleanupDuplicateSlugsDryRunDoesNotModifyVault(t *testing.T) {
|
|
139
|
+
root := t.TempDir()
|
|
140
|
+
writeDuplicateSlugFixture(t, root)
|
|
141
|
+
v := newTestVault(t, root)
|
|
142
|
+
v.Now = func() time.Time { return time.Date(2026, 5, 23, 12, 0, 0, 0, time.UTC) }
|
|
143
|
+
|
|
144
|
+
result, err := v.CleanupDuplicateSlugs(DuplicateSlugCleanupOptions{})
|
|
145
|
+
if err != nil {
|
|
146
|
+
t.Fatalf("CleanupDuplicateSlugs() dry-run error = %v", err)
|
|
147
|
+
}
|
|
148
|
+
if result.Applied {
|
|
149
|
+
t.Fatalf("dry-run should not be applied: %+v", result)
|
|
150
|
+
}
|
|
151
|
+
if len(result.Renames) != 2 {
|
|
152
|
+
t.Fatalf("expected two planned renames, got %+v", result.Renames)
|
|
153
|
+
}
|
|
154
|
+
if result.Renames[0].CanonicalPath != "00-meta/index.md" || result.Renames[0].FromPath != "index.md" || result.Renames[0].ToPath != "root-compatibility-index.md" {
|
|
155
|
+
t.Fatalf("unexpected first rename: %+v", result.Renames[0])
|
|
156
|
+
}
|
|
157
|
+
if _, err := os.Stat(filepath.Join(root, "index.md")); err != nil {
|
|
158
|
+
t.Fatalf("dry-run should leave root index in place: %v", err)
|
|
159
|
+
}
|
|
160
|
+
if _, err := os.Stat(filepath.Join(root, "root-compatibility-index.md")); !os.IsNotExist(err) {
|
|
161
|
+
t.Fatalf("dry-run should not create renamed file, stat err=%v", err)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
func TestCleanupDuplicateSlugsApplyRenamesCompatibilityPagesAndClearsLintWarnings(t *testing.T) {
|
|
166
|
+
root := t.TempDir()
|
|
167
|
+
writeDuplicateSlugFixture(t, root)
|
|
168
|
+
v := newTestVault(t, root)
|
|
169
|
+
v.Now = func() time.Time { return time.Date(2026, 5, 23, 12, 0, 0, 0, time.UTC) }
|
|
170
|
+
|
|
171
|
+
result, err := v.CleanupDuplicateSlugs(DuplicateSlugCleanupOptions{Apply: true})
|
|
172
|
+
if err != nil {
|
|
173
|
+
t.Fatalf("CleanupDuplicateSlugs() apply error = %v", err)
|
|
174
|
+
}
|
|
175
|
+
if !result.Applied {
|
|
176
|
+
t.Fatalf("expected apply result: %+v", result)
|
|
177
|
+
}
|
|
178
|
+
if len(result.Renames) != 2 {
|
|
179
|
+
t.Fatalf("expected two renames, got %+v", result.Renames)
|
|
180
|
+
}
|
|
181
|
+
for _, oldPath := range []string{"index.md", "log.md"} {
|
|
182
|
+
if _, err := os.Stat(filepath.Join(root, oldPath)); !os.IsNotExist(err) {
|
|
183
|
+
t.Fatalf("expected duplicate root file renamed away %s, stat err=%v", oldPath, err)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
renamedIndex := readTestFile(t, root, "root-compatibility-index.md")
|
|
187
|
+
if !strings.Contains(renamedIndex, "Renamed by duplicate-slug cleanup on 2026-05-23") ||
|
|
188
|
+
!strings.Contains(renamedIndex, "Canonical page: [[index]]") {
|
|
189
|
+
t.Fatalf("renamed index should preserve content with canonical notice:\n%s", renamedIndex)
|
|
190
|
+
}
|
|
191
|
+
if !strings.Contains(readTestFile(t, root, "root-compatibility-log.md"), "Canonical page: [[log]]") {
|
|
192
|
+
t.Fatalf("renamed log should point to canonical log")
|
|
193
|
+
}
|
|
194
|
+
if len(result.ReportPaths) != 1 {
|
|
195
|
+
t.Fatalf("expected report path, got %+v", result.ReportPaths)
|
|
196
|
+
}
|
|
197
|
+
report := readTestFile(t, root, result.ReportPaths[0])
|
|
198
|
+
if !strings.Contains(report, "`index.md` → `root-compatibility-index.md`") ||
|
|
199
|
+
!strings.Contains(report, "`log.md` → `root-compatibility-log.md`") {
|
|
200
|
+
t.Fatalf("report should list renames:\n%s", report)
|
|
201
|
+
}
|
|
202
|
+
lint, err := v.Lint()
|
|
203
|
+
if err != nil {
|
|
204
|
+
t.Fatalf("Lint() after duplicate cleanup error = %v", err)
|
|
205
|
+
}
|
|
206
|
+
if !lint.OK || len(lint.DuplicateSlugs) != 0 || len(lint.Warnings) != 0 {
|
|
207
|
+
t.Fatalf("expected clean lint after duplicate cleanup, got %+v", lint)
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
func TestAnalyzeCleanupRepositoryReferencesBuildsLLMReviewPack(t *testing.T) {
|
|
212
|
+
root := t.TempDir()
|
|
213
|
+
writeRepositoryReferenceFixture(t, root)
|
|
214
|
+
v := newTestVault(t, root)
|
|
215
|
+
v.Now = func() time.Time { return time.Date(2026, 5, 23, 12, 0, 0, 0, time.UTC) }
|
|
216
|
+
|
|
217
|
+
analysis, err := v.AnalyzeCleanup(CleanupAnalyzeOptions{Scope: "repository-references"})
|
|
218
|
+
if err != nil {
|
|
219
|
+
t.Fatalf("AnalyzeCleanup() error = %v", err)
|
|
220
|
+
}
|
|
221
|
+
if analysis.Scope != "repository-references" || analysis.Date != "2026-05-23" {
|
|
222
|
+
t.Fatalf("unexpected analysis header: %+v", analysis)
|
|
223
|
+
}
|
|
224
|
+
if len(analysis.Candidates) != 1 {
|
|
225
|
+
t.Fatalf("expected one cleanup candidate, got %+v", analysis.Candidates)
|
|
226
|
+
}
|
|
227
|
+
candidate := analysis.Candidates[0]
|
|
228
|
+
if candidate.ID != "repository-references:sample-repo" || candidate.Kind != "repository-reference-bundle" {
|
|
229
|
+
t.Fatalf("unexpected candidate: %+v", candidate)
|
|
230
|
+
}
|
|
231
|
+
if candidate.RecommendedAction.Type != "run_scope" || candidate.RecommendedAction.Scope != "repository-references" {
|
|
232
|
+
t.Fatalf("candidate should recommend a guarded scope action: %+v", candidate.RecommendedAction)
|
|
233
|
+
}
|
|
234
|
+
if len(candidate.Files) != 3 {
|
|
235
|
+
t.Fatalf("candidate should include bundle plus two fragments, got %+v", candidate.Files)
|
|
236
|
+
}
|
|
237
|
+
if len(analysis.LLMInstructions) == 0 || !strings.Contains(strings.Join(analysis.LLMInstructions, "\n"), "Do not edit 10-sources") {
|
|
238
|
+
t.Fatalf("analysis should include safety instructions: %+v", analysis.LLMInstructions)
|
|
239
|
+
}
|
|
240
|
+
if _, err := os.Stat(filepath.Join(root, "20-wiki/concepts/repository-references/sample-repo/sample-repo-reference-bundle-2026-05-23.md")); !os.IsNotExist(err) {
|
|
241
|
+
t.Fatalf("analysis should not write bundle, stat err=%v", err)
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
func TestApplyCleanupPlanRunScopeDryRunAndApply(t *testing.T) {
|
|
246
|
+
root := t.TempDir()
|
|
247
|
+
writeDuplicateSlugFixture(t, root)
|
|
248
|
+
v := newTestVault(t, root)
|
|
249
|
+
v.Now = func() time.Time { return time.Date(2026, 5, 23, 12, 0, 0, 0, time.UTC) }
|
|
250
|
+
plan := CleanupPlan{
|
|
251
|
+
Version: 1,
|
|
252
|
+
Scope: "duplicate-slugs",
|
|
253
|
+
Summary: "LLM reviewed duplicate slug candidates and chose the deterministic guarded scope.",
|
|
254
|
+
Actions: []CleanupPlanAction{{
|
|
255
|
+
Type: "run_scope",
|
|
256
|
+
Scope: "duplicate-slugs",
|
|
257
|
+
Reason: "root compatibility pages duplicate canonical meta slugs",
|
|
258
|
+
}},
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
dryRun, err := v.ApplyCleanupPlan(CleanupPlanApplyOptions{Plan: plan})
|
|
262
|
+
if err != nil {
|
|
263
|
+
t.Fatalf("ApplyCleanupPlan() dry-run error = %v", err)
|
|
264
|
+
}
|
|
265
|
+
if dryRun.Applied || len(dryRun.Actions) != 1 || dryRun.Actions[0].PlannedChanges != 2 {
|
|
266
|
+
t.Fatalf("unexpected dry-run result: %+v", dryRun)
|
|
267
|
+
}
|
|
268
|
+
if _, err := os.Stat(filepath.Join(root, "root-compatibility-index.md")); !os.IsNotExist(err) {
|
|
269
|
+
t.Fatalf("dry-run should not create renamed file, stat err=%v", err)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
applied, err := v.ApplyCleanupPlan(CleanupPlanApplyOptions{Plan: plan, Apply: true})
|
|
273
|
+
if err != nil {
|
|
274
|
+
t.Fatalf("ApplyCleanupPlan() apply error = %v", err)
|
|
275
|
+
}
|
|
276
|
+
if !applied.Applied || len(applied.Actions) != 1 || applied.Actions[0].PlannedChanges != 2 {
|
|
277
|
+
t.Fatalf("unexpected apply result: %+v", applied)
|
|
278
|
+
}
|
|
279
|
+
if _, err := os.Stat(filepath.Join(root, "root-compatibility-index.md")); err != nil {
|
|
280
|
+
t.Fatalf("apply should create renamed page: %v", err)
|
|
281
|
+
}
|
|
282
|
+
lint, err := v.Lint()
|
|
283
|
+
if err != nil {
|
|
284
|
+
t.Fatalf("Lint() after plan apply error = %v", err)
|
|
285
|
+
}
|
|
286
|
+
if !lint.OK || len(lint.DuplicateSlugs) != 0 {
|
|
287
|
+
t.Fatalf("expected clean lint after plan apply, got %+v", lint)
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
func TestApplyCleanupPlanRejectsUnsafeRename(t *testing.T) {
|
|
292
|
+
root := t.TempDir()
|
|
293
|
+
writeDuplicateSlugFixture(t, root)
|
|
294
|
+
v := newTestVault(t, root)
|
|
295
|
+
|
|
296
|
+
_, err := v.ApplyCleanupPlan(CleanupPlanApplyOptions{
|
|
297
|
+
Apply: true,
|
|
298
|
+
Plan: CleanupPlan{
|
|
299
|
+
Version: 1,
|
|
300
|
+
Scope: "manual",
|
|
301
|
+
Actions: []CleanupPlanAction{{
|
|
302
|
+
Type: "rename_page",
|
|
303
|
+
FromPath: "index.md",
|
|
304
|
+
ToPath: "../escape.md",
|
|
305
|
+
Reason: "bad LLM plan must be rejected by deterministic validation",
|
|
306
|
+
}},
|
|
307
|
+
},
|
|
308
|
+
})
|
|
309
|
+
if err == nil {
|
|
310
|
+
t.Fatalf("ApplyCleanupPlan() should reject path traversal target")
|
|
311
|
+
}
|
|
312
|
+
if _, statErr := os.Stat(filepath.Join(root, "index.md")); statErr != nil {
|
|
313
|
+
t.Fatalf("unsafe plan should leave source untouched: %v", statErr)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
_, err = v.ApplyCleanupPlan(CleanupPlanApplyOptions{
|
|
317
|
+
Apply: true,
|
|
318
|
+
Plan: CleanupPlan{
|
|
319
|
+
Version: 1,
|
|
320
|
+
Scope: "manual",
|
|
321
|
+
Actions: []CleanupPlanAction{{
|
|
322
|
+
Type: "rename_page",
|
|
323
|
+
FromPath: "index.md",
|
|
324
|
+
ToPath: "/tmp/escape.md",
|
|
325
|
+
Reason: "absolute targets must be rejected",
|
|
326
|
+
}},
|
|
327
|
+
},
|
|
328
|
+
})
|
|
329
|
+
if err == nil {
|
|
330
|
+
t.Fatalf("ApplyCleanupPlan() should reject absolute target")
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
_, err = v.ApplyCleanupPlan(CleanupPlanApplyOptions{
|
|
334
|
+
Apply: true,
|
|
335
|
+
Plan: CleanupPlan{
|
|
336
|
+
Version: 1,
|
|
337
|
+
Scope: "manual",
|
|
338
|
+
Actions: []CleanupPlanAction{{
|
|
339
|
+
Type: "rename_page",
|
|
340
|
+
FromPath: "10-sources/source.md",
|
|
341
|
+
ToPath: "20-wiki/concepts/source.md",
|
|
342
|
+
Reason: "protected source paths must not be moved",
|
|
343
|
+
}},
|
|
344
|
+
},
|
|
345
|
+
})
|
|
346
|
+
if err == nil {
|
|
347
|
+
t.Fatalf("ApplyCleanupPlan() should reject protected 10-sources source")
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
func writeRepositoryReferenceFixture(t *testing.T, root string) {
|
|
352
|
+
t.Helper()
|
|
353
|
+
writeTestPage(t, root, "20-wiki/concepts/repository-references/sample-repo/sample-repo-go-rules-2026-05-22.md", testFrontmatter("Sample Repo Go Rules", "reference")+`
|
|
354
|
+
# Go Rules
|
|
355
|
+
|
|
356
|
+
Go services in sample-repo use explicit context deadlines and table-driven tests.
|
|
357
|
+
Source grounding: [[repo-source-card]].
|
|
358
|
+
`)
|
|
359
|
+
writeTestPage(t, root, "20-wiki/concepts/repository-references/sample-repo/sample-repo-swagger-rules-2026-05-22.md", testFrontmatter("Sample Repo Swagger Rules", "reference")+`
|
|
360
|
+
# Swagger Rules
|
|
361
|
+
|
|
362
|
+
Swagger annotations must document every exported HTTP handler response shape.
|
|
363
|
+
`)
|
|
364
|
+
writeTestPage(t, root, "20-wiki/concepts/repository-references/sample-repo/sample-repo-reference-bundle-2026-05-22.md", testFrontmatter("Old Bundle", "reference")+`
|
|
365
|
+
# Old Bundle
|
|
366
|
+
|
|
367
|
+
Existing bundles are ignored as cleanup inputs.
|
|
368
|
+
`)
|
|
369
|
+
writeTestPage(t, root, "20-wiki/concepts/consumer.md", testFrontmatter("Consumer", "concept")+`
|
|
370
|
+
Use [[sample-repo-go-rules-2026-05-22]] and [[sample-repo-swagger-rules-2026-05-22|swagger]].
|
|
371
|
+
`)
|
|
372
|
+
writeTestPage(t, root, "20-wiki/concepts/repo-source-card.md", testFrontmatter("Repo Source Card", "concept")+`
|
|
373
|
+
# Repo Source Card
|
|
374
|
+
`)
|
|
375
|
+
writeTestPage(t, root, "10-sources/source-card.md", testFrontmatter("Source Card", "source")+`
|
|
376
|
+
This source mentions sample-repo-go-rules-2026-05-22 as plain text and must not be rewritten.
|
|
377
|
+
`)
|
|
378
|
+
writeTestPage(t, root, "00-meta/index.md", testFrontmatter("Index", "index")+`
|
|
379
|
+
# Index
|
|
380
|
+
|
|
381
|
+
### Repository-specific technical references
|
|
382
|
+
- [[sample-repo-go-rules-2026-05-22]]
|
|
383
|
+
- [[sample-repo-swagger-rules-2026-05-22]]
|
|
384
|
+
### Career / Resume
|
|
385
|
+
- [[consumer]]
|
|
386
|
+
|
|
387
|
+
**Repository-specific tech references**
|
|
388
|
+
- [[sample-repo-go-rules-2026-05-22]], [[sample-repo-swagger-rules-2026-05-22]]
|
|
389
|
+
- [[sample-repo-go-rules-2026-05-22]]
|
|
390
|
+
|
|
391
|
+
### Data
|
|
392
|
+
- [[sample-repo-go-rules-2026-05-22]], [[sample-repo-swagger-rules-2026-05-22]]
|
|
393
|
+
- [[sample-repo-go-rules-2026-05-22]], [[sample-repo-swagger-rules-2026-05-22]]
|
|
394
|
+
`)
|
|
395
|
+
writeTestPage(t, root, "00-meta/log.md", testFrontmatter("Log", "log")+`
|
|
396
|
+
# Log
|
|
397
|
+
`)
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
func writeDuplicateSlugFixture(t *testing.T, root string) {
|
|
401
|
+
t.Helper()
|
|
402
|
+
writeTestPage(t, root, "00-meta/index.md", testFrontmatter("Canonical Index", "index")+`
|
|
403
|
+
# Canonical Index
|
|
404
|
+
|
|
405
|
+
- [[canonical-page]]
|
|
406
|
+
`)
|
|
407
|
+
writeTestPage(t, root, "00-meta/log.md", testFrontmatter("Canonical Log", "log")+`
|
|
408
|
+
# Canonical Log
|
|
409
|
+
`)
|
|
410
|
+
writeTestPage(t, root, "index.md", `---
|
|
411
|
+
title: Root Compatibility Index
|
|
412
|
+
type: index
|
|
413
|
+
status: active
|
|
414
|
+
created: 2026-05-23
|
|
415
|
+
updated: 2026-05-23
|
|
416
|
+
tags: [meta, index, compatibility]
|
|
417
|
+
domain: meta
|
|
418
|
+
---
|
|
419
|
+
|
|
420
|
+
# Root Compatibility Index
|
|
421
|
+
|
|
422
|
+
Canonical catalog: [00-meta/index.md](00-meta/index.md)
|
|
423
|
+
`)
|
|
424
|
+
writeTestPage(t, root, "log.md", `---
|
|
425
|
+
title: Root Compatibility Log
|
|
426
|
+
type: log
|
|
427
|
+
status: active
|
|
428
|
+
created: 2026-05-23
|
|
429
|
+
updated: 2026-05-23
|
|
430
|
+
tags: [meta, log, compatibility]
|
|
431
|
+
domain: meta
|
|
432
|
+
---
|
|
433
|
+
|
|
434
|
+
# Root Compatibility Log
|
|
435
|
+
|
|
436
|
+
Canonical operation log: [00-meta/log.md](00-meta/log.md)
|
|
437
|
+
`)
|
|
438
|
+
writeTestPage(t, root, "20-wiki/concepts/canonical-page.md", testFrontmatter("Canonical Page", "concept")+`
|
|
439
|
+
# Canonical Page
|
|
440
|
+
`)
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
func readTestFile(t *testing.T, root, rel string) string {
|
|
444
|
+
t.Helper()
|
|
445
|
+
data, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(rel)))
|
|
446
|
+
if err != nil {
|
|
447
|
+
t.Fatalf("read %s: %v", rel, err)
|
|
448
|
+
}
|
|
449
|
+
return string(data)
|
|
450
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
package wiki
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"os"
|
|
6
|
+
"path/filepath"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
var standardVaultDirs = []string{
|
|
10
|
+
"00-meta",
|
|
11
|
+
"00-meta/reports",
|
|
12
|
+
"10-sources",
|
|
13
|
+
"20-wiki",
|
|
14
|
+
"20-wiki/concepts",
|
|
15
|
+
"20-wiki/entities",
|
|
16
|
+
"20-wiki/summaries",
|
|
17
|
+
"30-sessions",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// InitResult reports the non-destructive folder bootstrap performed for a vault.
|
|
21
|
+
type InitResult struct {
|
|
22
|
+
Root string `json:"root"`
|
|
23
|
+
CreatedRoot bool `json:"created_root"`
|
|
24
|
+
CreatedDirs []string `json:"created_dirs,omitempty"`
|
|
25
|
+
ExistingDirs []string `json:"existing_dirs,omitempty"`
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// StandardVaultDirs returns the fixed folder layout llm-wiki expects.
|
|
29
|
+
func StandardVaultDirs() []string {
|
|
30
|
+
out := make([]string, len(standardVaultDirs))
|
|
31
|
+
copy(out, standardVaultDirs)
|
|
32
|
+
return out
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// InitVault creates the standard llm-wiki vault folder structure. It is
|
|
36
|
+
// intentionally non-destructive and idempotent: existing directories are left as
|
|
37
|
+
// they are, files are never rewritten, and Obsidian configuration is not
|
|
38
|
+
// created or modified.
|
|
39
|
+
func InitVault(root string) (InitResult, error) {
|
|
40
|
+
if root == "" {
|
|
41
|
+
root = DefaultRoot()
|
|
42
|
+
}
|
|
43
|
+
abs, err := filepath.Abs(expandHome(root))
|
|
44
|
+
if err != nil {
|
|
45
|
+
return InitResult{}, err
|
|
46
|
+
}
|
|
47
|
+
result := InitResult{Root: abs}
|
|
48
|
+
if st, err := os.Stat(abs); err == nil {
|
|
49
|
+
if !st.IsDir() {
|
|
50
|
+
return InitResult{}, fmt.Errorf("vault root is not a directory: %s", abs)
|
|
51
|
+
}
|
|
52
|
+
} else if os.IsNotExist(err) {
|
|
53
|
+
if err := os.MkdirAll(abs, 0o755); err != nil {
|
|
54
|
+
return InitResult{}, fmt.Errorf("create vault root %s: %w", abs, err)
|
|
55
|
+
}
|
|
56
|
+
result.CreatedRoot = true
|
|
57
|
+
} else {
|
|
58
|
+
return InitResult{}, fmt.Errorf("stat vault root %s: %w", abs, err)
|
|
59
|
+
}
|
|
60
|
+
for _, rel := range standardVaultDirs {
|
|
61
|
+
path := filepath.Join(abs, filepath.FromSlash(rel))
|
|
62
|
+
if st, err := os.Stat(path); err == nil {
|
|
63
|
+
if !st.IsDir() {
|
|
64
|
+
return InitResult{}, fmt.Errorf("standard vault path is not a directory: %s", path)
|
|
65
|
+
}
|
|
66
|
+
result.ExistingDirs = append(result.ExistingDirs, rel)
|
|
67
|
+
continue
|
|
68
|
+
} else if !os.IsNotExist(err) {
|
|
69
|
+
return InitResult{}, fmt.Errorf("stat standard vault dir %s: %w", rel, err)
|
|
70
|
+
}
|
|
71
|
+
if err := os.MkdirAll(path, 0o755); err != nil {
|
|
72
|
+
return InitResult{}, fmt.Errorf("create standard vault dir %s: %w", rel, err)
|
|
73
|
+
}
|
|
74
|
+
result.CreatedDirs = append(result.CreatedDirs, rel)
|
|
75
|
+
}
|
|
76
|
+
return result, nil
|
|
77
|
+
}
|
|
@@ -116,6 +116,47 @@ func TestSafeJoinRejectsUnsafePaths(t *testing.T) {
|
|
|
116
116
|
}
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
func TestInitVaultCreatesStandardFoldersAndIsIdempotent(t *testing.T) {
|
|
120
|
+
root := filepath.Join(t.TempDir(), "missing", "llm-wiki")
|
|
121
|
+
|
|
122
|
+
result, err := InitVault(root)
|
|
123
|
+
if err != nil {
|
|
124
|
+
t.Fatalf("InitVault() error = %v", err)
|
|
125
|
+
}
|
|
126
|
+
if !result.CreatedRoot {
|
|
127
|
+
t.Fatalf("expected root to be created: %+v", result)
|
|
128
|
+
}
|
|
129
|
+
if result.Root != root {
|
|
130
|
+
t.Fatalf("unexpected root: got %q want %q", result.Root, root)
|
|
131
|
+
}
|
|
132
|
+
for _, rel := range StandardVaultDirs() {
|
|
133
|
+
if st, err := os.Stat(filepath.Join(root, filepath.FromSlash(rel))); err != nil || !st.IsDir() {
|
|
134
|
+
t.Fatalf("expected standard dir %s, stat=%v err=%v", rel, st, err)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
again, err := InitVault(root)
|
|
139
|
+
if err != nil {
|
|
140
|
+
t.Fatalf("InitVault() second call error = %v", err)
|
|
141
|
+
}
|
|
142
|
+
if again.CreatedRoot || len(again.CreatedDirs) != 0 {
|
|
143
|
+
t.Fatalf("expected idempotent init without new dirs, got %+v", again)
|
|
144
|
+
}
|
|
145
|
+
if len(again.ExistingDirs) != len(StandardVaultDirs()) {
|
|
146
|
+
t.Fatalf("expected all dirs reported existing, got %+v", again)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
func TestInitVaultRejectsFileRoot(t *testing.T) {
|
|
151
|
+
root := filepath.Join(t.TempDir(), "not-a-dir")
|
|
152
|
+
if err := os.WriteFile(root, []byte("x"), 0o644); err != nil {
|
|
153
|
+
t.Fatalf("write file root: %v", err)
|
|
154
|
+
}
|
|
155
|
+
if _, err := InitVault(root); err == nil {
|
|
156
|
+
t.Fatalf("expected InitVault to reject file root")
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
119
160
|
func TestCaptureWritesAllowedMarkdownAndRejectsProtectedFolders(t *testing.T) {
|
|
120
161
|
v := newTestVault(t, t.TempDir())
|
|
121
162
|
v.Now = func() time.Time { return time.Date(2026, 5, 23, 12, 0, 0, 0, time.UTC) }
|