@malindar/whyline 0.1.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.
Files changed (164) hide show
  1. package/.claude/settings.local.json +33 -0
  2. package/.github/workflows/ci.yml +35 -0
  3. package/.github/workflows/publish.yml +37 -0
  4. package/.prettierrc.json +7 -0
  5. package/CLAUDE.md +74 -0
  6. package/LICENSE +21 -0
  7. package/README.md +359 -0
  8. package/dist/cli.d.ts +2 -0
  9. package/dist/cli.js +125 -0
  10. package/dist/cli.js.map +1 -0
  11. package/dist/commands/delete.d.ts +3 -0
  12. package/dist/commands/delete.js +42 -0
  13. package/dist/commands/delete.js.map +1 -0
  14. package/dist/commands/doctor.d.ts +1 -0
  15. package/dist/commands/doctor.js +111 -0
  16. package/dist/commands/doctor.js.map +1 -0
  17. package/dist/commands/edit.d.ts +1 -0
  18. package/dist/commands/edit.js +78 -0
  19. package/dist/commands/edit.js.map +1 -0
  20. package/dist/commands/export.d.ts +8 -0
  21. package/dist/commands/export.js +90 -0
  22. package/dist/commands/export.js.map +1 -0
  23. package/dist/commands/import.d.ts +1 -0
  24. package/dist/commands/import.js +110 -0
  25. package/dist/commands/import.js.map +1 -0
  26. package/dist/commands/init.d.ts +5 -0
  27. package/dist/commands/init.js +23 -0
  28. package/dist/commands/init.js.map +1 -0
  29. package/dist/commands/install-claude.d.ts +3 -0
  30. package/dist/commands/install-claude.js +180 -0
  31. package/dist/commands/install-claude.js.map +1 -0
  32. package/dist/commands/list.d.ts +4 -0
  33. package/dist/commands/list.js +35 -0
  34. package/dist/commands/list.js.map +1 -0
  35. package/dist/commands/mcp.d.ts +1 -0
  36. package/dist/commands/mcp.js +10 -0
  37. package/dist/commands/mcp.js.map +1 -0
  38. package/dist/commands/save.d.ts +4 -0
  39. package/dist/commands/save.js +74 -0
  40. package/dist/commands/save.js.map +1 -0
  41. package/dist/commands/search.d.ts +7 -0
  42. package/dist/commands/search.js +46 -0
  43. package/dist/commands/search.js.map +1 -0
  44. package/dist/commands/show.d.ts +3 -0
  45. package/dist/commands/show.js +30 -0
  46. package/dist/commands/show.js.map +1 -0
  47. package/dist/commands/stats.d.ts +1 -0
  48. package/dist/commands/stats.js +27 -0
  49. package/dist/commands/stats.js.map +1 -0
  50. package/dist/commands/summarize.d.ts +3 -0
  51. package/dist/commands/summarize.js +140 -0
  52. package/dist/commands/summarize.js.map +1 -0
  53. package/dist/config.d.ts +11 -0
  54. package/dist/config.js +17 -0
  55. package/dist/config.js.map +1 -0
  56. package/dist/db/connection.d.ts +2 -0
  57. package/dist/db/connection.js +8 -0
  58. package/dist/db/connection.js.map +1 -0
  59. package/dist/db/migrations.d.ts +2 -0
  60. package/dist/db/migrations.js +19 -0
  61. package/dist/db/migrations.js.map +1 -0
  62. package/dist/db/schema.d.ts +5 -0
  63. package/dist/db/schema.js +64 -0
  64. package/dist/db/schema.js.map +1 -0
  65. package/dist/git/diff.d.ts +2 -0
  66. package/dist/git/diff.js +45 -0
  67. package/dist/git/diff.js.map +1 -0
  68. package/dist/git/git.d.ts +3 -0
  69. package/dist/git/git.js +25 -0
  70. package/dist/git/git.js.map +1 -0
  71. package/dist/git/repoId.d.ts +3 -0
  72. package/dist/git/repoId.js +49 -0
  73. package/dist/git/repoId.js.map +1 -0
  74. package/dist/mcp/server.d.ts +1 -0
  75. package/dist/mcp/server.js +296 -0
  76. package/dist/mcp/server.js.map +1 -0
  77. package/dist/mcp/tools.d.ts +119 -0
  78. package/dist/mcp/tools.js +43 -0
  79. package/dist/mcp/tools.js.map +1 -0
  80. package/dist/memory/parseSummary.d.ts +14 -0
  81. package/dist/memory/parseSummary.js +53 -0
  82. package/dist/memory/parseSummary.js.map +1 -0
  83. package/dist/memory/qualityCheck.d.ts +13 -0
  84. package/dist/memory/qualityCheck.js +78 -0
  85. package/dist/memory/qualityCheck.js.map +1 -0
  86. package/dist/memory/redactSecrets.d.ts +7 -0
  87. package/dist/memory/redactSecrets.js +29 -0
  88. package/dist/memory/redactSecrets.js.map +1 -0
  89. package/dist/memory/repoContext.d.ts +2 -0
  90. package/dist/memory/repoContext.js +23 -0
  91. package/dist/memory/repoContext.js.map +1 -0
  92. package/dist/memory/saveMemory.d.ts +40 -0
  93. package/dist/memory/saveMemory.js +223 -0
  94. package/dist/memory/saveMemory.js.map +1 -0
  95. package/dist/memory/searchMemory.d.ts +17 -0
  96. package/dist/memory/searchMemory.js +122 -0
  97. package/dist/memory/searchMemory.js.map +1 -0
  98. package/dist/memory/types.d.ts +48 -0
  99. package/dist/memory/types.js +2 -0
  100. package/dist/memory/types.js.map +1 -0
  101. package/dist/output/format.d.ts +3 -0
  102. package/dist/output/format.js +43 -0
  103. package/dist/output/format.js.map +1 -0
  104. package/docs/architecture.md +387 -0
  105. package/docs/ec6ab3bf-60cf-4629-ad9e-3048e8e3c43a.png +0 -0
  106. package/docs/logo.png +0 -0
  107. package/eslint.config.js +16 -0
  108. package/how-to-run/01-install.md +69 -0
  109. package/how-to-run/02-wire-up-your-repo.md +80 -0
  110. package/how-to-run/03-test-it-manually.md +91 -0
  111. package/how-to-run/04-test-with-claude-code.md +70 -0
  112. package/how-to-run/CLAUDE.md.template +72 -0
  113. package/how-to-run/README.md +49 -0
  114. package/package.json +60 -0
  115. package/src/cli.ts +142 -0
  116. package/src/commands/delete.ts +47 -0
  117. package/src/commands/doctor.ts +128 -0
  118. package/src/commands/edit.ts +80 -0
  119. package/src/commands/export.ts +95 -0
  120. package/src/commands/import.ts +119 -0
  121. package/src/commands/init.ts +31 -0
  122. package/src/commands/install-claude.ts +203 -0
  123. package/src/commands/list.ts +41 -0
  124. package/src/commands/mcp.ts +12 -0
  125. package/src/commands/save.ts +85 -0
  126. package/src/commands/search.ts +56 -0
  127. package/src/commands/show.ts +37 -0
  128. package/src/commands/stats.ts +31 -0
  129. package/src/commands/summarize.ts +183 -0
  130. package/src/config.ts +26 -0
  131. package/src/db/connection.ts +8 -0
  132. package/src/db/migrations.ts +26 -0
  133. package/src/db/schema.ts +68 -0
  134. package/src/git/diff.ts +43 -0
  135. package/src/git/git.ts +25 -0
  136. package/src/git/repoId.ts +49 -0
  137. package/src/hooks/post-commit.sample.sh +9 -0
  138. package/src/mcp/server.ts +326 -0
  139. package/src/mcp/tools.ts +53 -0
  140. package/src/memory/parseSummary.ts +72 -0
  141. package/src/memory/qualityCheck.ts +102 -0
  142. package/src/memory/redactSecrets.ts +32 -0
  143. package/src/memory/repoContext.ts +25 -0
  144. package/src/memory/saveMemory.ts +369 -0
  145. package/src/memory/searchMemory.ts +153 -0
  146. package/src/memory/types.ts +57 -0
  147. package/src/output/format.ts +44 -0
  148. package/src/skill/SKILL.md +95 -0
  149. package/tests/cliV02.test.ts +213 -0
  150. package/tests/doctor.test.ts +253 -0
  151. package/tests/exportImport.test.ts +248 -0
  152. package/tests/fileRename.test.ts +156 -0
  153. package/tests/gitHelpers.test.ts +94 -0
  154. package/tests/init.test.ts +93 -0
  155. package/tests/installClaude.test.ts +157 -0
  156. package/tests/parseSummary.test.ts +111 -0
  157. package/tests/qualityCheck.test.ts +182 -0
  158. package/tests/redactSecrets.test.ts +75 -0
  159. package/tests/saveMemory.test.ts +196 -0
  160. package/tests/searchFilters.test.ts +139 -0
  161. package/tests/searchMemory.test.ts +273 -0
  162. package/tests/stale.test.ts +47 -0
  163. package/tsconfig.json +18 -0
  164. package/vitest.config.ts +8 -0
@@ -0,0 +1,248 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import fs from "fs";
3
+ import os from "os";
4
+ import path from "path";
5
+ import { openDb } from "../src/db/connection.js";
6
+ import { runMigrations } from "../src/db/migrations.js";
7
+ import { saveMemory, generateMemoryId, getAllMemories, getMemoryById } from "../src/memory/saveMemory.js";
8
+ import { runExport } from "../src/commands/export.js";
9
+ import { runImport } from "../src/commands/import.js";
10
+ import type { CodingMemory } from "../src/memory/types.js";
11
+
12
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "whyline-exportimport-"));
13
+ const dbPath = path.join(tmpDir, "memory.db");
14
+
15
+ vi.mock("../src/config.js", () => ({
16
+ isInitialized: () => true,
17
+ resolveConfig: () => ({ storage: { dbPath } }),
18
+ }));
19
+
20
+ import { vi } from "vitest";
21
+
22
+ type ExportEnvelope = { schemaVersion: number; memories: CodingMemory[] };
23
+
24
+ function readExport(file: string): ExportEnvelope {
25
+ return JSON.parse(fs.readFileSync(file, "utf-8")) as ExportEnvelope;
26
+ }
27
+
28
+ function makeMemory(overrides: Partial<CodingMemory> = {}): CodingMemory {
29
+ return {
30
+ id: generateMemoryId(),
31
+ createdAt: new Date().toISOString(),
32
+ updatedAt: new Date().toISOString(),
33
+ repoId: "repo-abc",
34
+ repoPath: "/home/user/my-app",
35
+ repoName: "my-app",
36
+ branch: "main",
37
+ commitSha: "abc123",
38
+ files: ["src/auth/session.ts"],
39
+ tags: ["auth"],
40
+ intent: "Add refresh token rotation to prevent replay attacks",
41
+ summary: "Implemented refresh token rotation",
42
+ decision: "Rotate tokens on every request",
43
+ why: "One-time-use tokens prevent replay attacks",
44
+ alternativesRejected: ["Use long-lived tokens"],
45
+ risks: ["Requires client to handle new token on every response"],
46
+ followUps: ["Monitor token churn rate"],
47
+ source: "cli",
48
+ embeddingText: "Add refresh token rotation to prevent replay attacks",
49
+ ...overrides,
50
+ };
51
+ }
52
+
53
+ describe("export", () => {
54
+ let outFile: string;
55
+
56
+ beforeEach(() => {
57
+ if (fs.existsSync(dbPath)) fs.unlinkSync(dbPath);
58
+ const setupDb = openDb(dbPath);
59
+ runMigrations(setupDb);
60
+ setupDb.close();
61
+ outFile = path.join(tmpDir, `export-${Date.now()}.json`);
62
+ });
63
+
64
+ it("exports JSON with schemaVersion envelope", async () => {
65
+ const realDb = openDb(dbPath);
66
+ saveMemory(realDb, makeMemory({ id: "mem_a" }));
67
+ saveMemory(realDb, makeMemory({ id: "mem_b", repoId: "repo-xyz" }));
68
+ realDb.close();
69
+
70
+ await runExport({ format: "json", output: outFile, repo: false, tag: [], since: undefined, before: undefined });
71
+
72
+ const envelope = readExport(outFile);
73
+ expect(envelope.schemaVersion).toBe(1);
74
+ expect(envelope.memories.length).toBe(2);
75
+ const ids = envelope.memories.map((m) => m.id);
76
+ expect(ids).toContain("mem_a");
77
+ expect(ids).toContain("mem_b");
78
+ });
79
+
80
+ it("exports as markdown with --format md", async () => {
81
+ const realDb = openDb(dbPath);
82
+ saveMemory(realDb, makeMemory({ id: "mem_md" }));
83
+ realDb.close();
84
+
85
+ const mdOut = path.join(tmpDir, "export.md");
86
+ await runExport({ format: "md", output: mdOut, repo: false, tag: [], since: undefined, before: undefined });
87
+
88
+ const text = fs.readFileSync(mdOut, "utf-8");
89
+ expect(text).toContain("# mem_md");
90
+ expect(text).toContain("Intent:");
91
+ expect(text).toContain("Decision:");
92
+ });
93
+
94
+ it("--since filters out old memories", async () => {
95
+ const realDb = openDb(dbPath);
96
+ saveMemory(realDb, makeMemory({ id: "mem_old", createdAt: "2024-01-15T00:00:00.000Z" }));
97
+ saveMemory(realDb, makeMemory({ id: "mem_new", createdAt: "2025-06-15T00:00:00.000Z" }));
98
+ realDb.close();
99
+
100
+ await runExport({ format: "json", output: outFile, repo: false, tag: [], since: "2025-01-01", before: undefined });
101
+
102
+ const { memories } = readExport(outFile);
103
+ expect(memories.length).toBe(1);
104
+ expect(memories[0].id).toBe("mem_new");
105
+ });
106
+
107
+ it("--before filters out new memories", async () => {
108
+ const realDb = openDb(dbPath);
109
+ saveMemory(realDb, makeMemory({ id: "mem_old2", createdAt: "2024-01-15T00:00:00.000Z" }));
110
+ saveMemory(realDb, makeMemory({ id: "mem_new2", createdAt: "2025-06-15T00:00:00.000Z" }));
111
+ realDb.close();
112
+
113
+ await runExport({ format: "json", output: outFile, repo: false, tag: [], since: undefined, before: "2024-12-31" });
114
+
115
+ const { memories } = readExport(outFile);
116
+ expect(memories.length).toBe(1);
117
+ expect(memories[0].id).toBe("mem_old2");
118
+ });
119
+
120
+ it("--tag filters by tag", async () => {
121
+ const realDb = openDb(dbPath);
122
+ saveMemory(realDb, makeMemory({ id: "mem_auth", tags: ["auth"] }));
123
+ saveMemory(realDb, makeMemory({ id: "mem_pay", tags: ["payments"], intent: "Add payment retry logic for failed gateway calls", decision: "Retry with backoff", why: "Transient failures should not block checkout" }));
124
+ realDb.close();
125
+
126
+ await runExport({ format: "json", output: outFile, repo: false, tag: ["auth"], since: undefined, before: undefined });
127
+
128
+ const { memories } = readExport(outFile);
129
+ expect(memories.length).toBe(1);
130
+ expect(memories[0].id).toBe("mem_auth");
131
+ });
132
+
133
+ it("exports zero memories when no match", async () => {
134
+ await runExport({ format: "json", output: outFile, repo: false, tag: ["nope"], since: undefined, before: undefined });
135
+ const { schemaVersion, memories } = readExport(outFile);
136
+ expect(schemaVersion).toBe(1);
137
+ expect(memories.length).toBe(0);
138
+ });
139
+ });
140
+
141
+ describe("import", () => {
142
+ let importFile: string;
143
+
144
+ beforeEach(() => {
145
+ if (fs.existsSync(dbPath)) fs.unlinkSync(dbPath);
146
+ const setupDb = openDb(dbPath);
147
+ runMigrations(setupDb);
148
+ setupDb.close();
149
+ importFile = path.join(tmpDir, `import-${Date.now()}.json`);
150
+ });
151
+
152
+ it("imports from v1 envelope format", async () => {
153
+ const envelope = { schemaVersion: 1, memories: [makeMemory({ id: "mem_imp1" }), makeMemory({ id: "mem_imp2" })] };
154
+ fs.writeFileSync(importFile, JSON.stringify(envelope));
155
+
156
+ await runImport(importFile);
157
+
158
+ const db = openDb(dbPath);
159
+ const all = getAllMemories(db);
160
+ db.close();
161
+ expect(all.length).toBe(2);
162
+ });
163
+
164
+ it("imports from legacy bare array (backward compat)", async () => {
165
+ const memories = [makeMemory({ id: "mem_legacy1" }), makeMemory({ id: "mem_legacy2" })];
166
+ fs.writeFileSync(importFile, JSON.stringify(memories));
167
+
168
+ await runImport(importFile);
169
+
170
+ const db = openDb(dbPath);
171
+ const all = getAllMemories(db);
172
+ db.close();
173
+ expect(all.length).toBe(2);
174
+ });
175
+
176
+ it("skips memories that already exist", async () => {
177
+ const memory = makeMemory({ id: "mem_dup" });
178
+ const realDb = openDb(dbPath);
179
+ saveMemory(realDb, memory);
180
+ realDb.close();
181
+
182
+ fs.writeFileSync(importFile, JSON.stringify({ schemaVersion: 1, memories: [memory] }));
183
+ await runImport(importFile);
184
+
185
+ const db = openDb(dbPath);
186
+ const all = getAllMemories(db);
187
+ db.close();
188
+ expect(all.length).toBe(1);
189
+ });
190
+
191
+ it("skips invalid entries and imports valid ones", async () => {
192
+ const valid = makeMemory({ id: "mem_valid" });
193
+ const invalid = { id: "bad", notAMemory: true };
194
+ fs.writeFileSync(importFile, JSON.stringify({ schemaVersion: 1, memories: [valid, invalid] }));
195
+
196
+ await runImport(importFile);
197
+
198
+ const db = openDb(dbPath);
199
+ const m = getMemoryById(db, "mem_valid");
200
+ db.close();
201
+ expect(m).not.toBeNull();
202
+ });
203
+
204
+ it("redacts secrets on import", async () => {
205
+ const memory = makeMemory({
206
+ id: "mem_secret",
207
+ intent: "Add auth using token ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdef1234",
208
+ });
209
+ fs.writeFileSync(importFile, JSON.stringify({ schemaVersion: 1, memories: [memory] }));
210
+
211
+ await runImport(importFile);
212
+
213
+ const db = openDb(dbPath);
214
+ const m = getMemoryById(db, "mem_secret");
215
+ db.close();
216
+ expect(m?.intent).toContain("[REDACTED_SECRET]");
217
+ expect(m?.intent).not.toContain("ghp_");
218
+ });
219
+
220
+ it("rejects non-JSON files", async () => {
221
+ fs.writeFileSync(importFile, "# Not JSON\n## Intent:\nSomething");
222
+ await expect(runImport(importFile)).rejects.toThrow();
223
+ });
224
+
225
+ it("round-trips: export then import", async () => {
226
+ const realDb = openDb(dbPath);
227
+ saveMemory(realDb, makeMemory({ id: "mem_rt1" }));
228
+ saveMemory(realDb, makeMemory({ id: "mem_rt2", repoId: "repo-xyz" }));
229
+ realDb.close();
230
+
231
+ await runExport({ format: "json", output: importFile, repo: false, tag: [], since: undefined, before: undefined });
232
+
233
+ fs.unlinkSync(dbPath);
234
+ const freshDb = openDb(dbPath);
235
+ runMigrations(freshDb);
236
+ freshDb.close();
237
+
238
+ await runImport(importFile);
239
+
240
+ const db = openDb(dbPath);
241
+ const all = getAllMemories(db);
242
+ db.close();
243
+ expect(all.length).toBe(2);
244
+ const ids = all.map((m) => m.id);
245
+ expect(ids).toContain("mem_rt1");
246
+ expect(ids).toContain("mem_rt2");
247
+ });
248
+ });
@@ -0,0 +1,156 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { openDb } from "../src/db/connection.js";
3
+ import { runMigrations } from "../src/db/migrations.js";
4
+ import { saveMemory, generateMemoryId, getMemoriesByFile } from "../src/memory/saveMemory.js";
5
+ import type { CodingMemory } from "../src/memory/types.js";
6
+ import type Database from "better-sqlite3";
7
+
8
+ vi.mock("child_process", () => ({
9
+ execSync: vi.fn(),
10
+ }));
11
+
12
+ import { execSync } from "child_process";
13
+ const mockExecSync = vi.mocked(execSync);
14
+
15
+ function makeTestDb(): Database.Database {
16
+ const db = openDb(":memory:");
17
+ runMigrations(db);
18
+ return db;
19
+ }
20
+
21
+ function makeMemory(overrides: Partial<CodingMemory> = {}): CodingMemory {
22
+ return {
23
+ id: generateMemoryId(),
24
+ createdAt: new Date().toISOString(),
25
+ updatedAt: new Date().toISOString(),
26
+ repoId: "repo-abc",
27
+ repoPath: "/home/user/my-app",
28
+ repoName: "my-app",
29
+ branch: "main",
30
+ commitSha: "abc123",
31
+ files: ["src/auth/session.ts"],
32
+ tags: ["auth"],
33
+ intent: "Add refresh token rotation to prevent replay attacks",
34
+ summary: "Implemented refresh token rotation on every use",
35
+ decision: "Rotate on every use",
36
+ why: "One-time-use tokens prevent replay attacks",
37
+ alternativesRejected: [],
38
+ risks: [],
39
+ followUps: [],
40
+ source: "cli",
41
+ embeddingText: "Add refresh token rotation",
42
+ ...overrides,
43
+ };
44
+ }
45
+
46
+ describe("getChangedFilesForCommit — rename detection", () => {
47
+ beforeEach(() => { mockExecSync.mockReset(); });
48
+
49
+ it("includes both old and new paths for renamed files", async () => {
50
+ mockExecSync.mockReturnValue(
51
+ "R100\tsrc/auth/session.ts\tsrc/authentication/session.ts\n" as unknown as Buffer
52
+ );
53
+ const { getChangedFilesForCommit } = await import("../src/git/diff.js");
54
+ const files = getChangedFilesForCommit("/repo", "abc123");
55
+ expect(files).toContain("src/auth/session.ts");
56
+ expect(files).toContain("src/authentication/session.ts");
57
+ });
58
+
59
+ it("includes both old and new paths for copied files", async () => {
60
+ mockExecSync.mockReturnValue(
61
+ "C080\tsrc/utils/helper.ts\tsrc/shared/helper.ts\n" as unknown as Buffer
62
+ );
63
+ const { getChangedFilesForCommit } = await import("../src/git/diff.js");
64
+ const files = getChangedFilesForCommit("/repo", "abc123");
65
+ expect(files).toContain("src/utils/helper.ts");
66
+ expect(files).toContain("src/shared/helper.ts");
67
+ });
68
+
69
+ it("handles regular modified files normally", async () => {
70
+ mockExecSync.mockReturnValue(
71
+ "M\tsrc/auth/login.ts\n" as unknown as Buffer
72
+ );
73
+ const { getChangedFilesForCommit } = await import("../src/git/diff.js");
74
+ const files = getChangedFilesForCommit("/repo", "abc123");
75
+ expect(files).toEqual(["src/auth/login.ts"]);
76
+ });
77
+
78
+ it("deduplicates paths when same file appears multiple times", async () => {
79
+ mockExecSync.mockReturnValue(
80
+ "M\tsrc/auth/session.ts\nR100\tsrc/auth/session.ts\tsrc/authentication/session.ts\n" as unknown as Buffer
81
+ );
82
+ const { getChangedFilesForCommit } = await import("../src/git/diff.js");
83
+ const files = getChangedFilesForCommit("/repo", "abc123");
84
+ const unique = new Set(files);
85
+ expect(unique.size).toBe(files.length);
86
+ });
87
+ });
88
+
89
+ describe("getFileRenameHistory", () => {
90
+ beforeEach(() => { mockExecSync.mockReset(); });
91
+
92
+ it("returns current path and historical paths", async () => {
93
+ mockExecSync.mockReturnValue(
94
+ "src/auth/session.ts\n\nsrc/authentication/session.ts\n" as unknown as Buffer
95
+ );
96
+ const { getFileRenameHistory } = await import("../src/git/diff.js");
97
+ const history = getFileRenameHistory("/repo", "src/authentication/session.ts");
98
+ expect(history).toContain("src/authentication/session.ts");
99
+ expect(history).toContain("src/auth/session.ts");
100
+ });
101
+
102
+ it("always includes the requested file even if git returns nothing", async () => {
103
+ mockExecSync.mockImplementation(() => { throw new Error("git error"); });
104
+ const { getFileRenameHistory } = await import("../src/git/diff.js");
105
+ const history = getFileRenameHistory("/repo", "src/auth/session.ts");
106
+ expect(history).toEqual(["src/auth/session.ts"]);
107
+ });
108
+
109
+ it("deduplicates paths", async () => {
110
+ mockExecSync.mockReturnValue(
111
+ "src/auth/session.ts\nsrc/auth/session.ts\n" as unknown as Buffer
112
+ );
113
+ const { getFileRenameHistory } = await import("../src/git/diff.js");
114
+ const history = getFileRenameHistory("/repo", "src/auth/session.ts");
115
+ const unique = new Set(history);
116
+ expect(unique.size).toBe(history.length);
117
+ });
118
+ });
119
+
120
+ describe("getMemoriesByFile — multiple paths", () => {
121
+ let db: Database.Database;
122
+
123
+ beforeEach(() => { db = makeTestDb(); });
124
+
125
+ it("finds memory by old path after rename", () => {
126
+ const m = makeMemory({ files: ["src/auth/session.ts"] });
127
+ saveMemory(db, m);
128
+ // Search with both old and new path (simulating rename history lookup)
129
+ const results = getMemoriesByFile(db, "repo-abc", ["src/authentication/session.ts", "src/auth/session.ts"], 10);
130
+ expect(results.length).toBe(1);
131
+ expect(results[0].id).toBe(m.id);
132
+ });
133
+
134
+ it("finds memory by new path stored at save time", () => {
135
+ const m = makeMemory({ files: ["src/authentication/session.ts"] });
136
+ saveMemory(db, m);
137
+ const results = getMemoriesByFile(db, "repo-abc", ["src/authentication/session.ts", "src/auth/session.ts"], 10);
138
+ expect(results.length).toBe(1);
139
+ expect(results[0].id).toBe(m.id);
140
+ });
141
+
142
+ it("deduplicates results when memory matches multiple paths in the list", () => {
143
+ // Memory stored with both old and new path (as happens at commit time)
144
+ const m = makeMemory({ files: ["src/auth/session.ts", "src/authentication/session.ts"] });
145
+ saveMemory(db, m);
146
+ const results = getMemoriesByFile(db, "repo-abc", ["src/auth/session.ts", "src/authentication/session.ts"], 10);
147
+ expect(results.length).toBe(1);
148
+ });
149
+
150
+ it("still works with a single path string", () => {
151
+ const m = makeMemory({ files: ["src/auth/session.ts"] });
152
+ saveMemory(db, m);
153
+ const results = getMemoriesByFile(db, "repo-abc", "src/auth/session.ts", 10);
154
+ expect(results.length).toBe(1);
155
+ });
156
+ });
@@ -0,0 +1,94 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { normalizeRemoteUrl, getRepoId, getRepoName } from "../src/git/repoId.js";
3
+
4
+ // Mock child_process for getRepoId/getRepoName tests that use execSync
5
+ vi.mock("child_process", () => ({
6
+ execSync: vi.fn(),
7
+ }));
8
+
9
+ import { execSync } from "child_process";
10
+ const mockExecSync = vi.mocked(execSync);
11
+
12
+ describe("normalizeRemoteUrl", () => {
13
+ it("strips .git suffix", () => {
14
+ expect(normalizeRemoteUrl("https://github.com/user/repo.git")).toBe(
15
+ "https://github.com/user/repo"
16
+ );
17
+ });
18
+
19
+ it("converts git@ SSH URL to https", () => {
20
+ expect(normalizeRemoteUrl("git@github.com:user/repo.git")).toBe(
21
+ "https://github.com/user/repo"
22
+ );
23
+ });
24
+
25
+ it("lowercases the URL", () => {
26
+ expect(normalizeRemoteUrl("https://GitHub.com/User/Repo")).toBe(
27
+ "https://github.com/user/repo"
28
+ );
29
+ });
30
+
31
+ it("handles URLs without .git suffix", () => {
32
+ expect(normalizeRemoteUrl("https://github.com/user/repo")).toBe(
33
+ "https://github.com/user/repo"
34
+ );
35
+ });
36
+ });
37
+
38
+ describe("getRepoId", () => {
39
+ beforeEach(() => {
40
+ mockExecSync.mockReset();
41
+ });
42
+
43
+ afterEach(() => {
44
+ vi.restoreAllMocks();
45
+ });
46
+
47
+ it("returns a 32-char hex string when remote exists", () => {
48
+ mockExecSync.mockReturnValue("git@github.com:user/repo.git\n" as unknown as Buffer);
49
+ const id = getRepoId("/some/path");
50
+ expect(id).toMatch(/^[0-9a-f]{32}$/);
51
+ });
52
+
53
+ it("produces the same ID for the same remote URL regardless of path", () => {
54
+ mockExecSync.mockReturnValue("git@github.com:user/repo.git\n" as unknown as Buffer);
55
+ const id1 = getRepoId("/path/one");
56
+ const id2 = getRepoId("/path/two");
57
+ expect(id1).toBe(id2);
58
+ });
59
+
60
+ it("falls back to path hash when execSync throws (no remote)", () => {
61
+ mockExecSync.mockImplementation(() => {
62
+ throw new Error("no remote");
63
+ });
64
+ const id = getRepoId("/some/repo/path");
65
+ expect(id).toMatch(/^[0-9a-f]{32}$/);
66
+ });
67
+
68
+ it("produces different IDs for different repos", () => {
69
+ mockExecSync
70
+ .mockReturnValueOnce("git@github.com:user/repo-a.git\n" as unknown as Buffer)
71
+ .mockReturnValueOnce("git@github.com:user/repo-b.git\n" as unknown as Buffer);
72
+ const id1 = getRepoId("/path");
73
+ const id2 = getRepoId("/path");
74
+ expect(id1).not.toBe(id2);
75
+ });
76
+ });
77
+
78
+ describe("getRepoName", () => {
79
+ beforeEach(() => {
80
+ mockExecSync.mockReset();
81
+ });
82
+
83
+ it("extracts repo name from remote URL", () => {
84
+ mockExecSync.mockReturnValue("git@github.com:user/my-project.git\n" as unknown as Buffer);
85
+ expect(getRepoName("/some/path")).toBe("my-project");
86
+ });
87
+
88
+ it("falls back to directory name when no remote", () => {
89
+ mockExecSync.mockImplementation(() => {
90
+ throw new Error("no remote");
91
+ });
92
+ expect(getRepoName("/home/user/my-app")).toBe("my-app");
93
+ });
94
+ });
@@ -0,0 +1,93 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import os from "os";
5
+ import { runInit } from "../src/commands/init.js";
6
+ import { openDb } from "../src/db/connection.js";
7
+
8
+ function makeTempDir(): string {
9
+ return fs.mkdtempSync(path.join(os.tmpdir(), "whyline-test-"));
10
+ }
11
+
12
+ describe("init command", () => {
13
+ let tmpDir: string;
14
+
15
+ beforeEach(() => {
16
+ tmpDir = makeTempDir();
17
+ });
18
+
19
+ afterEach(() => {
20
+ fs.rmSync(tmpDir, { recursive: true, force: true });
21
+ });
22
+
23
+ it("creates the data directory", () => {
24
+ const dataDir = path.join(tmpDir, "data");
25
+ runInit({ dataDir });
26
+ expect(fs.existsSync(dataDir)).toBe(true);
27
+ });
28
+
29
+ it("creates config.json with correct structure", () => {
30
+ const dataDir = path.join(tmpDir, "data");
31
+ runInit({ dataDir });
32
+ const configPath = path.join(dataDir, "config.json");
33
+ expect(fs.existsSync(configPath)).toBe(true);
34
+ const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
35
+ expect(config.version).toBe(1);
36
+ expect(config.storage.dbPath).toContain("memory.db");
37
+ });
38
+
39
+ it("creates memory.db", () => {
40
+ const dataDir = path.join(tmpDir, "data");
41
+ runInit({ dataDir });
42
+ expect(fs.existsSync(path.join(dataDir, "memory.db"))).toBe(true);
43
+ });
44
+
45
+ it("creates a valid SQLite database with all tables", () => {
46
+ const dataDir = path.join(tmpDir, "data");
47
+ runInit({ dataDir });
48
+ const dbPath = path.join(dataDir, "memory.db");
49
+ const db = openDb(dbPath);
50
+
51
+ const tables = db
52
+ .prepare<[], { name: string }>(
53
+ "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
54
+ )
55
+ .all()
56
+ .map((r) => r.name);
57
+
58
+ db.close();
59
+
60
+ expect(tables).toContain("memories");
61
+ expect(tables).toContain("memory_files");
62
+ expect(tables).toContain("memory_tags");
63
+ expect(tables).toContain("memory_alternatives");
64
+ expect(tables).toContain("memory_risks");
65
+ expect(tables).toContain("memory_followups");
66
+ expect(tables).toContain("migrations");
67
+ });
68
+
69
+ it("records migration version in migrations table", () => {
70
+ const dataDir = path.join(tmpDir, "data");
71
+ runInit({ dataDir });
72
+ const db = openDb(path.join(dataDir, "memory.db"));
73
+ const rows = db
74
+ .prepare<[], { version: number }>("SELECT version FROM migrations")
75
+ .all();
76
+ db.close();
77
+ expect(rows.map((r) => r.version)).toContain(1);
78
+ });
79
+
80
+ it("is idempotent — second init does not throw or duplicate migrations", () => {
81
+ const dataDir = path.join(tmpDir, "data");
82
+ runInit({ dataDir });
83
+ expect(() => runInit({ dataDir })).not.toThrow();
84
+
85
+ const db = openDb(path.join(dataDir, "memory.db"));
86
+ const rows = db
87
+ .prepare<[], { version: number }>("SELECT version FROM migrations")
88
+ .all();
89
+ db.close();
90
+ const versions = rows.map((r) => r.version);
91
+ expect(versions.filter((v) => v === 1).length).toBe(1);
92
+ });
93
+ });