@m16khb/llm-wiki 0.1.0 → 0.1.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.
@@ -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) }
package/npm/lib/runner.js CHANGED
@@ -9,6 +9,9 @@ const SIGNAL_EXIT_CODES = {
9
9
  SIGTERM: 143,
10
10
  };
11
11
 
12
+ const FINGERPRINT_INPUT_ENTRIES = ['go.mod', 'go.sum', 'package.json', 'cmd', 'internal'];
13
+ const BINARY_INPUT_ENTRIES = ['go.mod', 'go.sum', 'cmd', 'internal'];
14
+
12
15
  function executableName(platform = process.platform) {
13
16
  return platform === 'win32' ? 'llm-wiki.exe' : 'llm-wiki';
14
17
  }
@@ -25,7 +28,9 @@ function isExecutable(file) {
25
28
 
26
29
  function findBundledBinary(packageRoot, platform = process.platform) {
27
30
  const candidate = path.join(packageRoot, 'bin', executableName(platform));
28
- return isExecutable(candidate) ? candidate : '';
31
+ if (!isExecutable(candidate)) return '';
32
+ if (isBundledBinaryStale(packageRoot, candidate)) return '';
33
+ return candidate;
29
34
  }
30
35
 
31
36
  function readPackageVersion(packageRoot) {
@@ -38,7 +43,11 @@ function readPackageVersion(packageRoot) {
38
43
  }
39
44
  }
40
45
 
41
- function hashFile(hash, root, file) {
46
+ function isBuildInput(file) {
47
+ return file.endsWith('.go') || ['go.mod', 'go.sum', 'package.json'].includes(path.basename(file));
48
+ }
49
+
50
+ function walkBuildInputs(root, file, visitor) {
42
51
  let stat;
43
52
  try {
44
53
  stat = fs.statSync(file);
@@ -49,14 +58,23 @@ function hashFile(hash, root, file) {
49
58
  if (stat.isDirectory()) {
50
59
  const entries = fs.readdirSync(file).sort();
51
60
  for (const entry of entries) {
52
- hashFile(hash, root, path.join(file, entry));
61
+ walkBuildInputs(root, path.join(file, entry), visitor);
53
62
  }
54
63
  return;
55
64
  }
56
- const isBuildInput = file.endsWith('.go') || ['go.mod', 'go.sum', 'package.json'].includes(path.basename(file));
57
- if (!stat.isFile() || !isBuildInput) {
65
+ if (!stat.isFile() || !isBuildInput(file)) {
58
66
  return;
59
67
  }
68
+ visitor(file, stat);
69
+ }
70
+
71
+ function visitBuildInputs(packageRoot, entries, visitor) {
72
+ for (const entry of entries) {
73
+ walkBuildInputs(packageRoot, path.join(packageRoot, entry), visitor);
74
+ }
75
+ }
76
+
77
+ function hashFile(hash, root, file, stat) {
60
78
  hash.update(path.relative(root, file));
61
79
  hash.update('\0');
62
80
  hash.update(String(stat.size));
@@ -67,12 +85,30 @@ function hashFile(hash, root, file) {
67
85
 
68
86
  function sourceFingerprint(packageRoot) {
69
87
  const hash = crypto.createHash('sha256');
70
- for (const entry of ['go.mod', 'go.sum', 'package.json', 'cmd', 'internal']) {
71
- hashFile(hash, packageRoot, path.join(packageRoot, entry));
72
- }
88
+ visitBuildInputs(packageRoot, FINGERPRINT_INPUT_ENTRIES, (file, stat) => hashFile(hash, packageRoot, file, stat));
73
89
  return hash.digest('hex').slice(0, 16);
74
90
  }
75
91
 
92
+ function latestBuildInputMTime(packageRoot) {
93
+ let latest = 0;
94
+ visitBuildInputs(packageRoot, BINARY_INPUT_ENTRIES, (_file, stat) => {
95
+ if (stat.mtimeMs > latest) latest = stat.mtimeMs;
96
+ });
97
+ return latest;
98
+ }
99
+
100
+ function isBundledBinaryStale(packageRoot, binary) {
101
+ let binaryStat;
102
+ try {
103
+ binaryStat = fs.statSync(binary);
104
+ } catch (err) {
105
+ if (err && err.code === 'ENOENT') return true;
106
+ throw err;
107
+ }
108
+ const latestInput = latestBuildInputMTime(packageRoot);
109
+ return latestInput > binaryStat.mtimeMs;
110
+ }
111
+
76
112
  function defaultCacheRoot(env = process.env) {
77
113
  if (env.LLM_WIKI_NPM_CACHE_DIR) return env.LLM_WIKI_NPM_CACHE_DIR;
78
114
  if (env.XDG_CACHE_HOME) return path.join(env.XDG_CACHE_HOME, 'llm-wiki', 'npm-wrapper');
@@ -110,8 +146,10 @@ function buildBinary(packageRoot, output, env = process.env) {
110
146
  function ensureBinary(options = {}) {
111
147
  const env = options.env || process.env;
112
148
  const packageRoot = options.packageRoot || env.LLM_WIKI_NPM_PACKAGE_ROOT || path.resolve(__dirname, '..', '..');
113
- const bundled = findBundledBinary(packageRoot);
114
- if (bundled) return bundled;
149
+ if (env.LLM_WIKI_NPM_REBUILD !== '1') {
150
+ const bundled = findBundledBinary(packageRoot);
151
+ if (bundled) return bundled;
152
+ }
115
153
 
116
154
  const cached = cacheBinaryPath(packageRoot, env);
117
155
  if (isExecutable(cached) && env.LLM_WIKI_NPM_REBUILD !== '1') return cached;
@@ -162,6 +200,8 @@ module.exports = {
162
200
  ensureBinary,
163
201
  executableName,
164
202
  findBundledBinary,
203
+ isBundledBinaryStale,
204
+ latestBuildInputMTime,
165
205
  run,
166
206
  sourceFingerprint,
167
207
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m16khb/llm-wiki",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "npx wrapper for the llm-wiki Go MCP server and CLI",
5
5
  "bin": {
6
6
  "llm-wiki": "npm/bin/llm-wiki.js"