@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,157 @@
1
+ import { describe, it, expect, beforeEach, vi } from "vitest";
2
+ import fs from "fs";
3
+ import os from "os";
4
+ import path from "path";
5
+ import { runInstallClaude } from "../src/commands/install-claude.js";
6
+
7
+ function makeGitDir(): string {
8
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "whyline-install-"));
9
+ // minimal .git so getRepoRoot succeeds
10
+ fs.mkdirSync(path.join(dir, ".git"));
11
+ return dir;
12
+ }
13
+
14
+ vi.mock("../src/git/git.js", () => ({
15
+ getRepoRoot: (cwd: string) => {
16
+ // return cwd if it contains a .git dir
17
+ if (fs.existsSync(path.join(cwd, ".git"))) return cwd;
18
+ return null;
19
+ },
20
+ }));
21
+
22
+ vi.mock("../src/git/repoId.js", () => ({
23
+ getRepoName: () => "my-test-repo",
24
+ }));
25
+
26
+ describe("install-claude — .mcp.json", () => {
27
+ let repoDir: string;
28
+
29
+ beforeEach(() => { repoDir = makeGitDir(); });
30
+
31
+ it("creates .mcp.json when absent", async () => {
32
+ await runInstallClaude({ repoPath: repoDir });
33
+ const mcp = JSON.parse(fs.readFileSync(path.join(repoDir, ".mcp.json"), "utf-8"));
34
+ expect(mcp.mcpServers.whyline).toEqual({ command: "whyline", args: ["mcp"] });
35
+ });
36
+
37
+ it("merges into existing .mcp.json without removing other servers", async () => {
38
+ const existing = { mcpServers: { "other-tool": { command: "other", args: [] } } };
39
+ fs.writeFileSync(path.join(repoDir, ".mcp.json"), JSON.stringify(existing));
40
+
41
+ await runInstallClaude({ repoPath: repoDir });
42
+
43
+ const mcp = JSON.parse(fs.readFileSync(path.join(repoDir, ".mcp.json"), "utf-8"));
44
+ expect(mcp.mcpServers["other-tool"]).toBeDefined();
45
+ expect(mcp.mcpServers.whyline).toEqual({ command: "whyline", args: ["mcp"] });
46
+ });
47
+
48
+ it("is idempotent — running twice does not duplicate or change .mcp.json", async () => {
49
+ await runInstallClaude({ repoPath: repoDir });
50
+ const after1 = fs.readFileSync(path.join(repoDir, ".mcp.json"), "utf-8");
51
+ await runInstallClaude({ repoPath: repoDir });
52
+ const after2 = fs.readFileSync(path.join(repoDir, ".mcp.json"), "utf-8");
53
+ expect(after1).toBe(after2);
54
+ });
55
+ });
56
+
57
+ describe("install-claude — CLAUDE.md", () => {
58
+ let repoDir: string;
59
+
60
+ beforeEach(() => { repoDir = makeGitDir(); });
61
+
62
+ it("creates CLAUDE.md with repo name header and Whyline section when absent", async () => {
63
+ await runInstallClaude({ repoPath: repoDir });
64
+ const content = fs.readFileSync(path.join(repoDir, "CLAUDE.md"), "utf-8");
65
+ expect(content).toContain("my-test-repo");
66
+ expect(content).toContain("## Whyline Memory");
67
+ expect(content).toContain(repoDir);
68
+ });
69
+
70
+ it("appends Whyline section to an existing CLAUDE.md that lacks it", async () => {
71
+ fs.writeFileSync(path.join(repoDir, "CLAUDE.md"), "# Existing project\n\nSome instructions.\n");
72
+ await runInstallClaude({ repoPath: repoDir });
73
+ const content = fs.readFileSync(path.join(repoDir, "CLAUDE.md"), "utf-8");
74
+ expect(content).toContain("Existing project");
75
+ expect(content).toContain("## Whyline Memory");
76
+ });
77
+
78
+ it("updates repoPath in existing CLAUDE.md that already has Whyline section", async () => {
79
+ const oldPath = "/old/path/to/repo";
80
+ const section = `# My repo\n\n## Whyline Memory\n\n- \`repoPath\`: \`${oldPath}\`\n`;
81
+ fs.writeFileSync(path.join(repoDir, "CLAUDE.md"), section);
82
+
83
+ await runInstallClaude({ repoPath: repoDir });
84
+
85
+ const content = fs.readFileSync(path.join(repoDir, "CLAUDE.md"), "utf-8");
86
+ expect(content).not.toContain(oldPath);
87
+ expect(content).toContain(repoDir);
88
+ });
89
+
90
+ it("is idempotent — running twice does not duplicate the section", async () => {
91
+ await runInstallClaude({ repoPath: repoDir });
92
+ const after1 = fs.readFileSync(path.join(repoDir, "CLAUDE.md"), "utf-8");
93
+ await runInstallClaude({ repoPath: repoDir });
94
+ const after2 = fs.readFileSync(path.join(repoDir, "CLAUDE.md"), "utf-8");
95
+ expect(after1).toBe(after2);
96
+ // should only appear once
97
+ expect(after2.split("## Whyline Memory").length - 1).toBe(1);
98
+ });
99
+ });
100
+
101
+ describe("install-claude — .claude/settings.local.json", () => {
102
+ let repoDir: string;
103
+
104
+ beforeEach(() => { repoDir = makeGitDir(); });
105
+
106
+ it("creates settings.local.json with all tool permissions", async () => {
107
+ await runInstallClaude({ repoPath: repoDir });
108
+ const settings = JSON.parse(
109
+ fs.readFileSync(path.join(repoDir, ".claude", "settings.local.json"), "utf-8")
110
+ );
111
+ expect(settings.permissions.allow).toContain("mcp__whyline__save_coding_memory");
112
+ expect(settings.permissions.allow).toContain("mcp__whyline__search_coding_memory");
113
+ expect(settings.permissions.allow).toContain("mcp__whyline__get_recent_memories");
114
+ expect(settings.enabledMcpjsonServers).toContain("whyline");
115
+ });
116
+
117
+ it("merges into existing settings.local.json without removing existing permissions", async () => {
118
+ const settingsDir = path.join(repoDir, ".claude");
119
+ fs.mkdirSync(settingsDir);
120
+ const existing = { permissions: { allow: ["some__other__tool"] }, enabledMcpjsonServers: ["other"] };
121
+ fs.writeFileSync(path.join(settingsDir, "settings.local.json"), JSON.stringify(existing));
122
+
123
+ await runInstallClaude({ repoPath: repoDir });
124
+
125
+ const settings = JSON.parse(
126
+ fs.readFileSync(path.join(settingsDir, "settings.local.json"), "utf-8")
127
+ );
128
+ expect(settings.permissions.allow).toContain("some__other__tool");
129
+ expect(settings.permissions.allow).toContain("mcp__whyline__save_coding_memory");
130
+ expect(settings.enabledMcpjsonServers).toContain("other");
131
+ expect(settings.enabledMcpjsonServers).toContain("whyline");
132
+ });
133
+
134
+ it("is idempotent — running twice does not duplicate permissions", async () => {
135
+ await runInstallClaude({ repoPath: repoDir });
136
+ const after1 = fs.readFileSync(path.join(repoDir, ".claude", "settings.local.json"), "utf-8");
137
+ await runInstallClaude({ repoPath: repoDir });
138
+ const after2 = fs.readFileSync(path.join(repoDir, ".claude", "settings.local.json"), "utf-8");
139
+ expect(after1).toBe(after2);
140
+ const parsed = JSON.parse(after2);
141
+ const saveCount = (parsed.permissions.allow as string[]).filter(
142
+ (p: string) => p === "mcp__whyline__save_coding_memory"
143
+ ).length;
144
+ expect(saveCount).toBe(1);
145
+ });
146
+ });
147
+
148
+ describe("install-claude — error handling", () => {
149
+ it("exits when not inside a git repo", async () => {
150
+ const notARepo = fs.mkdtempSync(path.join(os.tmpdir(), "whyline-nogit-"));
151
+ const exitSpy = vi.spyOn(process, "exit").mockImplementation((code?: number) => {
152
+ throw new Error(`exit:${code}`);
153
+ });
154
+ await expect(runInstallClaude({ repoPath: notARepo })).rejects.toThrow("exit:1");
155
+ exitSpy.mockRestore();
156
+ });
157
+ });
@@ -0,0 +1,111 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { parseSummary } from "../src/memory/parseSummary.js";
3
+
4
+ const FULL_EXAMPLE = `# Memory
5
+
6
+ Intent:
7
+ Add optimistic comment rendering.
8
+
9
+ Summary:
10
+ Implemented optimistic rendering for new comments so they appear immediately.
11
+
12
+ Decision:
13
+ Render comments immediately and reconcile after server ack.
14
+
15
+ Why:
16
+ Waiting for server confirmation made comment creation feel slow.
17
+
18
+ Alternatives rejected:
19
+ - Server-confirmed rendering only.
20
+ - Polling for new comments after submit.
21
+
22
+ Risks:
23
+ - Duplicate comments during reconnect.
24
+ - Failed server ack needs rollback UI.
25
+
26
+ Follow-ups:
27
+ - Add dedupe reconciliation tests.
28
+ - Add failed-send retry state.
29
+
30
+ Tags:
31
+ - comments
32
+ - sync
33
+ - optimistic-ui
34
+ `;
35
+
36
+ describe("parseSummary", () => {
37
+ it("parses all standard fields from a full example", () => {
38
+ const result = parseSummary(FULL_EXAMPLE);
39
+ expect(result.intent).toBe("Add optimistic comment rendering.");
40
+ expect(result.summary).toBe(
41
+ "Implemented optimistic rendering for new comments so they appear immediately."
42
+ );
43
+ expect(result.decision).toBe("Render comments immediately and reconcile after server ack.");
44
+ expect(result.why).toBe("Waiting for server confirmation made comment creation feel slow.");
45
+ expect(result.alternativesRejected).toEqual([
46
+ "Server-confirmed rendering only.",
47
+ "Polling for new comments after submit.",
48
+ ]);
49
+ expect(result.risks).toEqual([
50
+ "Duplicate comments during reconnect.",
51
+ "Failed server ack needs rollback UI.",
52
+ ]);
53
+ expect(result.followUps).toEqual([
54
+ "Add dedupe reconciliation tests.",
55
+ "Add failed-send retry state.",
56
+ ]);
57
+ expect(result.tags).toEqual(["comments", "sync", "optimistic-ui"]);
58
+ });
59
+
60
+ it("parses optional Task: heading", () => {
61
+ const input = `Task:\nFix comment sync bug.\n\nIntent:\nFix the sync.\n`;
62
+ const result = parseSummary(input);
63
+ expect(result.task).toBe("Fix comment sync bug.");
64
+ });
65
+
66
+ it("returns undefined for task when heading is absent", () => {
67
+ const result = parseSummary(FULL_EXAMPLE);
68
+ expect(result.task).toBeUndefined();
69
+ });
70
+
71
+ it("uses safe defaults for missing fields", () => {
72
+ const result = parseSummary("Some random text with no headings.");
73
+ expect(result.intent).toBe("Unspecified intent");
74
+ expect(result.decision).toBe("Unspecified decision");
75
+ expect(result.why).toBe("Unspecified rationale");
76
+ expect(result.alternativesRejected).toEqual([]);
77
+ expect(result.risks).toEqual([]);
78
+ expect(result.followUps).toEqual([]);
79
+ expect(result.tags).toEqual([]);
80
+ });
81
+
82
+ it("uses full file content as summary fallback when Summary: heading is missing", () => {
83
+ const input = "Intent:\nDo something.\n";
84
+ const result = parseSummary(input);
85
+ expect(result.summary).toBeTruthy();
86
+ });
87
+
88
+ it("handles asterisk bullets as well as dash bullets", () => {
89
+ const input = `Risks:\n* Risk one\n* Risk two\n`;
90
+ const result = parseSummary(input);
91
+ expect(result.risks).toEqual(["Risk one", "Risk two"]);
92
+ });
93
+
94
+ it("strips the markdown h1 title line", () => {
95
+ const input = `# My Session Memory\n\nIntent:\nDo something useful.\n`;
96
+ const result = parseSummary(input);
97
+ expect(result.intent).toBe("Do something useful.");
98
+ });
99
+
100
+ it("handles Follow ups (without hyphen) as synonym for Follow-ups", () => {
101
+ const input = `Follow ups:\n- Test A\n- Test B\n`;
102
+ const result = parseSummary(input);
103
+ expect(result.followUps).toEqual(["Test A", "Test B"]);
104
+ });
105
+
106
+ it("trims whitespace from field values", () => {
107
+ const input = `Intent:\n Add something. \n`;
108
+ const result = parseSummary(input);
109
+ expect(result.intent).toBe("Add something.");
110
+ });
111
+ });
@@ -0,0 +1,182 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import { checkQuality, checkDuplicates } from "../src/memory/qualityCheck.js";
3
+ import { openDb } from "../src/db/connection.js";
4
+ import { runMigrations } from "../src/db/migrations.js";
5
+ import { saveMemory, generateMemoryId } from "../src/memory/saveMemory.js";
6
+ import type { CodingMemory } from "../src/memory/types.js";
7
+ import type Database from "better-sqlite3";
8
+
9
+ function makeTestDb(): Database.Database {
10
+ const db = openDb(":memory:");
11
+ runMigrations(db);
12
+ return db;
13
+ }
14
+
15
+ function makeMemory(overrides: Partial<CodingMemory> = {}): CodingMemory {
16
+ return {
17
+ id: generateMemoryId(),
18
+ createdAt: new Date().toISOString(),
19
+ updatedAt: new Date().toISOString(),
20
+ repoId: "repo-abc",
21
+ repoPath: "/home/user/my-app",
22
+ repoName: "my-app",
23
+ branch: "main",
24
+ commitSha: "abc12345",
25
+ files: ["src/auth/session.ts"],
26
+ tags: ["auth"],
27
+ intent: "Add refresh token rotation to prevent replay attacks",
28
+ summary: "Implemented token rotation on every use",
29
+ decision: "Rotate tokens on every request",
30
+ why: "One-time-use tokens prevent replay attacks in auth flow",
31
+ alternativesRejected: ["Long-lived tokens"],
32
+ risks: ["Token invalidation race condition"],
33
+ followUps: ["Add rotation tests"],
34
+ source: "cli",
35
+ embeddingText: "Add refresh token rotation",
36
+ ...overrides,
37
+ };
38
+ }
39
+
40
+ describe("checkQuality", () => {
41
+ it("returns no warnings for good fields", () => {
42
+ const warnings = checkQuality({
43
+ intent: "Add refresh token rotation to prevent replay attacks",
44
+ decision: "Rotate tokens on every request to invalidate old ones",
45
+ why: "One-time-use tokens prevent replay attacks in the auth flow",
46
+ });
47
+ expect(warnings).toHaveLength(0);
48
+ });
49
+
50
+ it("warns when intent is too short", () => {
51
+ const warnings = checkQuality({
52
+ intent: "fix",
53
+ decision: "Rotate tokens on every request to prevent replay attacks",
54
+ why: "One-time-use tokens prevent replay attacks in auth flow",
55
+ });
56
+ expect(warnings.some((w) => w.field === "intent")).toBe(true);
57
+ });
58
+
59
+ it("warns when decision is too short", () => {
60
+ const warnings = checkQuality({
61
+ intent: "Add refresh token rotation to prevent replay attacks",
62
+ decision: "done",
63
+ why: "One-time-use tokens prevent replay attacks in auth flow",
64
+ });
65
+ expect(warnings.some((w) => w.field === "decision")).toBe(true);
66
+ });
67
+
68
+ it("warns when why is too short", () => {
69
+ const warnings = checkQuality({
70
+ intent: "Add refresh token rotation to prevent replay attacks",
71
+ decision: "Rotate tokens on every request to prevent replay attacks",
72
+ why: "wip",
73
+ });
74
+ expect(warnings.some((w) => w.field === "why")).toBe(true);
75
+ });
76
+
77
+ it("warns on filler phrases", () => {
78
+ const warnings = checkQuality({
79
+ intent: "updated the auth logic for session handling",
80
+ decision: "done with the implementation details of token refresh",
81
+ why: "fixed the issue that was causing problems in production",
82
+ });
83
+ const fillerWarnings = warnings.filter((w) => w.message.includes("filler"));
84
+ expect(fillerWarnings.length).toBeGreaterThan(0);
85
+ });
86
+
87
+ it("warns on exact filler match", () => {
88
+ const warnings = checkQuality({
89
+ intent: "done",
90
+ decision: "Rotate tokens on every request to prevent replay attacks",
91
+ why: "One-time-use tokens prevent replay attacks in auth flow",
92
+ });
93
+ expect(warnings.some((w) => w.field === "intent")).toBe(true);
94
+ });
95
+
96
+ it("returns multiple warnings for multiple bad fields", () => {
97
+ const warnings = checkQuality({
98
+ intent: "fix",
99
+ decision: "done",
100
+ why: "wip",
101
+ });
102
+ expect(warnings.length).toBe(3);
103
+ });
104
+ });
105
+
106
+ describe("checkDuplicates", () => {
107
+ let db: Database.Database;
108
+
109
+ beforeEach(() => { db = makeTestDb(); });
110
+
111
+ it("returns no warnings when no memories exist", () => {
112
+ const warnings = checkDuplicates(db, makeMemory());
113
+ expect(warnings).toHaveLength(0);
114
+ });
115
+
116
+ it("warns when a memory with the same commit SHA already exists", () => {
117
+ const existing = makeMemory({ commitSha: "abc12345" });
118
+ saveMemory(db, existing);
119
+
120
+ const warnings = checkDuplicates(db, makeMemory({ commitSha: "abc12345" }));
121
+ expect(warnings.some((w) => w.type === "same-commit")).toBe(true);
122
+ expect(warnings[0].existingId).toBe(existing.id);
123
+ });
124
+
125
+ it("warns when a similar intent exists with overlapping files", () => {
126
+ const existing = makeMemory({
127
+ commitSha: "old123",
128
+ intent: "Add refresh token rotation to prevent replay attacks",
129
+ files: ["src/auth/session.ts"],
130
+ });
131
+ saveMemory(db, existing);
132
+
133
+ const incoming = makeMemory({
134
+ commitSha: "new456",
135
+ intent: "Add refresh token rotation for auth security",
136
+ files: ["src/auth/session.ts"],
137
+ });
138
+ const warnings = checkDuplicates(db, incoming);
139
+ expect(warnings.some((w) => w.type === "similar-intent")).toBe(true);
140
+ });
141
+
142
+ it("does not warn for similar intent with no file overlap", () => {
143
+ const existing = makeMemory({
144
+ intent: "Add refresh token rotation to prevent replay attacks",
145
+ files: ["src/auth/session.ts"],
146
+ });
147
+ saveMemory(db, existing);
148
+
149
+ const incoming = makeMemory({
150
+ commitSha: "new456",
151
+ intent: "Add refresh token rotation for auth security",
152
+ files: ["src/payments/checkout.ts"],
153
+ });
154
+ const warnings = checkDuplicates(db, incoming);
155
+ expect(warnings.some((w) => w.type === "similar-intent")).toBe(false);
156
+ });
157
+
158
+ it("does not warn for different intents on the same files", () => {
159
+ const existing = makeMemory({
160
+ intent: "Add refresh token rotation to prevent replay attacks",
161
+ files: ["src/auth/session.ts"],
162
+ });
163
+ saveMemory(db, existing);
164
+
165
+ const incoming = makeMemory({
166
+ commitSha: "new456",
167
+ intent: "Fix payment gateway timeout on slow network connections",
168
+ files: ["src/auth/session.ts"],
169
+ });
170
+ const warnings = checkDuplicates(db, incoming);
171
+ expect(warnings.some((w) => w.type === "similar-intent")).toBe(false);
172
+ });
173
+
174
+ it("does not warn when repo IDs differ", () => {
175
+ const existing = makeMemory({ repoId: "repo-abc", commitSha: "abc12345" });
176
+ saveMemory(db, existing);
177
+
178
+ const incoming = makeMemory({ repoId: "repo-xyz", commitSha: "abc12345" });
179
+ const warnings = checkDuplicates(db, incoming);
180
+ expect(warnings).toHaveLength(0);
181
+ });
182
+ });
@@ -0,0 +1,75 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { redactSecrets, SECRET_PATTERNS } from "../src/memory/redactSecrets.js";
3
+
4
+ describe("redactSecrets", () => {
5
+ it("redacts GitHub personal access tokens (ghp_)", () => {
6
+ const input = "token: ghp_abcdefghijklmnopqrstuvwxyz1234567890";
7
+ expect(redactSecrets(input)).toContain("[REDACTED_SECRET]");
8
+ expect(redactSecrets(input)).not.toMatch(/ghp_[A-Za-z0-9]{36}/);
9
+ });
10
+
11
+ it("redacts GitHub OAuth tokens (gho_)", () => {
12
+ const input = "gho_abcdefghijklmnopqrstuvwxyz1234567890";
13
+ expect(redactSecrets(input)).toBe("[REDACTED_SECRET]");
14
+ });
15
+
16
+ it("redacts GitHub App tokens (ghs_)", () => {
17
+ const input = "ghs_abcdefghijklmnopqrstuvwxyz1234567890";
18
+ expect(redactSecrets(input)).toBe("[REDACTED_SECRET]");
19
+ });
20
+
21
+ it("redacts npm tokens", () => {
22
+ const input = "npm_abcdefghijklmnopqrstuvwxyz1234567890";
23
+ expect(redactSecrets(input)).toBe("[REDACTED_SECRET]");
24
+ });
25
+
26
+ it("redacts AWS access keys", () => {
27
+ const input = "AKIAIOSFODNN7EXAMPLE";
28
+ expect(redactSecrets(input)).toBe("[REDACTED_SECRET]");
29
+ });
30
+
31
+ it("redacts Bearer tokens", () => {
32
+ const input = "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.abc";
33
+ expect(redactSecrets(input)).toContain("[REDACTED_SECRET]");
34
+ expect(redactSecrets(input)).not.toContain("eyJhbGciOiJIUzI1NiJ9");
35
+ });
36
+
37
+ it("redacts .env-style secrets", () => {
38
+ expect(redactSecrets("API_KEY=abc123secret")).toContain("[REDACTED_SECRET]");
39
+ expect(redactSecrets("SECRET=mysecretvalue")).toContain("[REDACTED_SECRET]");
40
+ expect(redactSecrets("PASSWORD=hunter2")).toContain("[REDACTED_SECRET]");
41
+ expect(redactSecrets("TOKEN=abcdef")).toContain("[REDACTED_SECRET]");
42
+ });
43
+
44
+ it("redacts PEM private key blocks", () => {
45
+ const input = `-----BEGIN RSA PRIVATE KEY-----
46
+ MIIEowIBAAKCAQEA1234567890abcdef
47
+ -----END RSA PRIVATE KEY-----`;
48
+ expect(redactSecrets(input)).toBe("[REDACTED_SECRET]");
49
+ });
50
+
51
+ it("does not redact normal text", () => {
52
+ const input = "We decided to use optimistic rendering for better UX.";
53
+ expect(redactSecrets(input)).toBe(input);
54
+ });
55
+
56
+ it("does not redact short random strings that look like keys but aren't", () => {
57
+ const input = "The AKID prefix is used for something else here";
58
+ expect(redactSecrets(input)).toBe(input);
59
+ });
60
+
61
+ it("redacts multiple secrets in one string", () => {
62
+ const input = `ghp_abcdefghijklmnopqrstuvwxyz1234567890 and npm_abcdefghijklmnopqrstuvwxyz1234567890`;
63
+ const result = redactSecrets(input);
64
+ expect(result).not.toMatch(/ghp_/);
65
+ expect(result).not.toMatch(/npm_/);
66
+ expect(result.split("[REDACTED_SECRET]").length - 1).toBe(2);
67
+ });
68
+
69
+ it("all SECRET_PATTERNS have a name and a RegExp", () => {
70
+ for (const p of SECRET_PATTERNS) {
71
+ expect(p.name).toBeTruthy();
72
+ expect(p.pattern).toBeInstanceOf(RegExp);
73
+ }
74
+ });
75
+ });