stackmem 1.0.25 → 1.0.27
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/dist/cli.js +1431 -142
- package/dist/config-file.js +72 -0
- package/dist/index.js +8 -5
- package/dist/scoring.js +51 -6
- package/dist/storage.js +79 -1
- package/dist/tests/crud.test.js +93 -0
- package/dist/tests/hook.test.js +296 -0
- package/dist/tests/scoring.test.js +86 -0
- package/package.json +4 -1
- package/src/cli.ts +1612 -154
- package/src/config-file.ts +98 -0
- package/src/index.ts +10 -4
- package/src/scoring.ts +62 -6
- package/src/storage.ts +101 -1
- package/src/tests/crud.test.ts +110 -0
- package/src/tests/hook.test.ts +352 -0
- package/src/tests/scoring.test.ts +123 -0
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
// Hook integration tests against real temporary git repos. These hit the
|
|
2
|
+
// real Supabase backend (same convention as regression.test.ts) — each
|
|
3
|
+
// test uses its own throwaway project name, cleaned up in `after()`
|
|
4
|
+
// regardless of pass/fail. No MCP contract tests yet (Phase 2, per the
|
|
5
|
+
// user). Run with `npm test`.
|
|
6
|
+
import { test, after } from "node:test";
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { execFileSync } from "node:child_process";
|
|
9
|
+
import { mkdtempSync, rmSync, existsSync, writeFileSync, readFileSync } from "node:fs";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
|
|
13
|
+
import { supabase } from "../storage.js";
|
|
14
|
+
|
|
15
|
+
const CLI_PATH = path.join(import.meta.dirname, "..", "..", "dist", "cli.js");
|
|
16
|
+
const projectsToCleanUp: string[] = [];
|
|
17
|
+
let uniqueCounter = 0;
|
|
18
|
+
|
|
19
|
+
function uniqueProject(label: string): string {
|
|
20
|
+
uniqueCounter++;
|
|
21
|
+
const project = `hooktest-${label}-${Date.now()}-${uniqueCounter}`;
|
|
22
|
+
projectsToCleanUp.push(project);
|
|
23
|
+
return project;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function makeTempRepo(): string {
|
|
27
|
+
return mkdtempSync(path.join(tmpdir(), "stackmem-hooktest-repo-"));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function gitInit(dir: string): void {
|
|
31
|
+
execFileSync("git", ["init", "-q"], { cwd: dir });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// cm init also registers Claude Code hooks, the MCP server, and editor
|
|
35
|
+
// MCP configs into $HOME — scoped to a scratch HOME so none of that
|
|
36
|
+
// touches this machine's real config, same convention used throughout
|
|
37
|
+
// manual verification earlier in this phase.
|
|
38
|
+
function initStackmem(dir: string, project: string): void {
|
|
39
|
+
const fakeHome = mkdtempSync(path.join(tmpdir(), "stackmem-hooktest-home-"));
|
|
40
|
+
writeFileSync(path.join(fakeHome, ".claude.json"), JSON.stringify({ mcpServers: {} }), "utf-8");
|
|
41
|
+
execFileSync(process.execPath, [CLI_PATH, "init", project, "."], {
|
|
42
|
+
cwd: dir,
|
|
43
|
+
env: { ...process.env, HOME: fakeHome },
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function commit(dir: string, message: string, files: Record<string, string> = { "f.txt": "x" }): void {
|
|
48
|
+
for (const [name, content] of Object.entries(files)) {
|
|
49
|
+
writeFileSync(path.join(dir, name), content, "utf-8");
|
|
50
|
+
}
|
|
51
|
+
execFileSync("git", ["add", "."], { cwd: dir });
|
|
52
|
+
execFileSync(
|
|
53
|
+
"git",
|
|
54
|
+
["-c", "user.email=a@b.com", "-c", "user.name=t", "commit", "-q", "-m", message],
|
|
55
|
+
{ cwd: dir }
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function fetchMemories(project: string): Promise<Array<{ content: string; type: string }>> {
|
|
60
|
+
const { data, error } = await supabase
|
|
61
|
+
.from("memories")
|
|
62
|
+
.select("content, type")
|
|
63
|
+
.eq("project", project);
|
|
64
|
+
if (error) throw new Error(error.message);
|
|
65
|
+
return data ?? [];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
after(async () => {
|
|
69
|
+
for (const project of projectsToCleanUp) {
|
|
70
|
+
const { data: mem } = await supabase.from("memories").select("id").eq("project", project);
|
|
71
|
+
const ids = (mem ?? []).map((m: { id: string }) => m.id);
|
|
72
|
+
if (ids.length > 0) {
|
|
73
|
+
const idList = ids.join(",");
|
|
74
|
+
await supabase.from("memory_links").delete().or(`source_id.in.(${idList}),target_id.in.(${idList})`);
|
|
75
|
+
}
|
|
76
|
+
await supabase.from("memories").delete().eq("project", project);
|
|
77
|
+
await supabase.from("execution_log").delete().eq("project", project);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// 1. Fresh repo: cm init writes all expected files.
|
|
82
|
+
test("fresh repo: cm init writes all expected files", () => {
|
|
83
|
+
const dir = makeTempRepo();
|
|
84
|
+
try {
|
|
85
|
+
gitInit(dir);
|
|
86
|
+
const project = uniqueProject("init");
|
|
87
|
+
initStackmem(dir, project);
|
|
88
|
+
|
|
89
|
+
for (const file of [
|
|
90
|
+
".stackmem",
|
|
91
|
+
"CLAUDE.md",
|
|
92
|
+
"AGENTS.md",
|
|
93
|
+
"GEMINI.md",
|
|
94
|
+
".cursorrules",
|
|
95
|
+
".windsurfrules",
|
|
96
|
+
".gitignore",
|
|
97
|
+
path.join(".cursor", "rules", "stackmem.md"),
|
|
98
|
+
path.join(".coding-memory", "post-commit-hook.mjs"),
|
|
99
|
+
path.join(".git", "hooks", "post-commit"),
|
|
100
|
+
path.join(".git", "hooks", "post-rewrite"),
|
|
101
|
+
path.join(".claude", "settings.json"),
|
|
102
|
+
]) {
|
|
103
|
+
assert.ok(existsSync(path.join(dir, file)), `expected ${file} to exist`);
|
|
104
|
+
}
|
|
105
|
+
} finally {
|
|
106
|
+
rmSync(dir, { recursive: true, force: true });
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// 2. Commit with a "feat:" message saves a decision.
|
|
111
|
+
test('commit with a "feat:" message saves a decision', async () => {
|
|
112
|
+
const dir = makeTempRepo();
|
|
113
|
+
try {
|
|
114
|
+
gitInit(dir);
|
|
115
|
+
const project = uniqueProject("feat");
|
|
116
|
+
initStackmem(dir, project);
|
|
117
|
+
commit(dir, "feat: add stripe webhook signature verification logic today");
|
|
118
|
+
|
|
119
|
+
const memories = await fetchMemories(project);
|
|
120
|
+
assert.ok(
|
|
121
|
+
memories.some((m) => m.type === "decision" && m.content.includes("stripe webhook")),
|
|
122
|
+
`expected a decision memory, got: ${JSON.stringify(memories)}`
|
|
123
|
+
);
|
|
124
|
+
} finally {
|
|
125
|
+
rmSync(dir, { recursive: true, force: true });
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// 3. Commit with a "chore:" message saves nothing.
|
|
130
|
+
test('commit with a "chore:" message saves nothing', async () => {
|
|
131
|
+
const dir = makeTempRepo();
|
|
132
|
+
try {
|
|
133
|
+
gitInit(dir);
|
|
134
|
+
const project = uniqueProject("chore");
|
|
135
|
+
initStackmem(dir, project);
|
|
136
|
+
commit(dir, "chore: bump dependency versions across the board today");
|
|
137
|
+
|
|
138
|
+
const memories = await fetchMemories(project);
|
|
139
|
+
assert.equal(memories.length, 0, `expected no memories, got: ${JSON.stringify(memories)}`);
|
|
140
|
+
} finally {
|
|
141
|
+
rmSync(dir, { recursive: true, force: true });
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// 4. Commit message with an AWS key saves [REDACTED], not the raw key.
|
|
146
|
+
test("commit message with an AWS key saves [REDACTED], not the raw key", async () => {
|
|
147
|
+
const dir = makeTempRepo();
|
|
148
|
+
try {
|
|
149
|
+
gitInit(dir);
|
|
150
|
+
const project = uniqueProject("redact");
|
|
151
|
+
initStackmem(dir, project);
|
|
152
|
+
commit(dir, "feat: rotate credentials AKIAABCDEFGHIJ123456 for the deploy pipeline");
|
|
153
|
+
|
|
154
|
+
const memories = await fetchMemories(project);
|
|
155
|
+
const decision = memories.find((m) => m.type === "decision");
|
|
156
|
+
assert.ok(decision, `expected a decision memory, got: ${JSON.stringify(memories)}`);
|
|
157
|
+
assert.doesNotMatch(decision!.content, /AKIA[0-9A-Z]{16}/);
|
|
158
|
+
assert.match(decision!.content, /\[REDACTED\]/);
|
|
159
|
+
} finally {
|
|
160
|
+
rmSync(dir, { recursive: true, force: true });
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// 5. Amend doesn't create a duplicate memory for the same final commit
|
|
165
|
+
// sha (SHA idempotency via check-commit — see src/cli.ts cmdCheckCommit).
|
|
166
|
+
// Amending legitimately produces a NEW commit_sha, which legitimately
|
|
167
|
+
// gets its own new capture — that's 2 decisions total, correctly. What
|
|
168
|
+
// the idempotency guard actually prevents is post-commit AND
|
|
169
|
+
// post-rewrite both capturing that same amended sha a second time.
|
|
170
|
+
test("amend doesn't create a duplicate memory for the same commit_sha (SHA idempotency)", async () => {
|
|
171
|
+
const dir = makeTempRepo();
|
|
172
|
+
try {
|
|
173
|
+
gitInit(dir);
|
|
174
|
+
const project = uniqueProject("amend");
|
|
175
|
+
initStackmem(dir, project);
|
|
176
|
+
commit(dir, "feat: initial commit for amend idempotency test today");
|
|
177
|
+
|
|
178
|
+
execFileSync(
|
|
179
|
+
"git",
|
|
180
|
+
["-c", "user.email=a@b.com", "-c", "user.name=t", "commit", "--amend", "-q", "-m", "feat: amended commit for amend idempotency test today"],
|
|
181
|
+
{ cwd: dir }
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
const { data, error } = await supabase
|
|
185
|
+
.from("memories")
|
|
186
|
+
.select("content, commit_sha")
|
|
187
|
+
.eq("project", project)
|
|
188
|
+
.eq("type", "decision");
|
|
189
|
+
if (error) throw new Error(error.message);
|
|
190
|
+
|
|
191
|
+
const shas = (data ?? []).map((m) => m.commit_sha);
|
|
192
|
+
assert.equal(
|
|
193
|
+
shas.length,
|
|
194
|
+
new Set(shas).size,
|
|
195
|
+
`expected no two decisions sharing a commit_sha, got: ${JSON.stringify(data)}`
|
|
196
|
+
);
|
|
197
|
+
assert.ok((data ?? []).some((m) => m.content.includes("amended commit")));
|
|
198
|
+
} finally {
|
|
199
|
+
rmSync(dir, { recursive: true, force: true });
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// 6. Hook times out after 2s and exits 0 (never blocks the commit).
|
|
204
|
+
test("hook times out after 2s and exits 0", () => {
|
|
205
|
+
const dir = mkdtempSync(path.join(tmpdir(), "stackmem-hooktest-timeout-"));
|
|
206
|
+
const scriptPath = path.join(dir, "slow-hook.mjs");
|
|
207
|
+
// Mirrors buildHookLogicScript's main()/runHook() timeout wrapper
|
|
208
|
+
// (src/cli.ts) exactly, with runHook() replaced by an artificial 5s
|
|
209
|
+
// hang to deterministically force the timeout path — forcing the real
|
|
210
|
+
// hook's own body (git/network calls) to take >2s isn't something a
|
|
211
|
+
// test can reliably control.
|
|
212
|
+
writeFileSync(
|
|
213
|
+
scriptPath,
|
|
214
|
+
`
|
|
215
|
+
import { appendFileSync, mkdirSync } from "node:fs";
|
|
216
|
+
import { join, dirname } from "node:path";
|
|
217
|
+
|
|
218
|
+
const HOOK_TIMEOUT_MS = 2000;
|
|
219
|
+
|
|
220
|
+
function logHookError(error) {
|
|
221
|
+
try {
|
|
222
|
+
const logPath = join(process.cwd(), ".coding-memory", "hook.log");
|
|
223
|
+
mkdirSync(dirname(logPath), { recursive: true });
|
|
224
|
+
appendFileSync(logPath, \`[\${new Date().toISOString()}] \${error instanceof Error ? error.message : String(error)}\\n\`);
|
|
225
|
+
} catch {}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function runHook() {
|
|
229
|
+
await new Promise((resolve) => setTimeout(resolve, 5000));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function main() {
|
|
233
|
+
let timeoutHandle;
|
|
234
|
+
try {
|
|
235
|
+
await Promise.race([
|
|
236
|
+
runHook(),
|
|
237
|
+
new Promise((_resolve, reject) => {
|
|
238
|
+
timeoutHandle = setTimeout(
|
|
239
|
+
() => reject(new Error("stackmem post-commit hook exceeded " + HOOK_TIMEOUT_MS + "ms timeout")),
|
|
240
|
+
HOOK_TIMEOUT_MS
|
|
241
|
+
);
|
|
242
|
+
}),
|
|
243
|
+
]);
|
|
244
|
+
} catch (error) {
|
|
245
|
+
logHookError(error);
|
|
246
|
+
} finally {
|
|
247
|
+
clearTimeout(timeoutHandle);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
main().finally(() => process.exit(0));
|
|
252
|
+
`,
|
|
253
|
+
"utf-8"
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
try {
|
|
257
|
+
const start = Date.now();
|
|
258
|
+
execFileSync(process.execPath, [scriptPath], { cwd: dir, encoding: "utf-8" });
|
|
259
|
+
const elapsedMs = Date.now() - start;
|
|
260
|
+
|
|
261
|
+
assert.ok(elapsedMs < 4000, `expected timeout around 2s, took ${elapsedMs}ms`);
|
|
262
|
+
const log = readFileSync(path.join(dir, ".coding-memory", "hook.log"), "utf-8");
|
|
263
|
+
assert.match(log, /exceeded 2000ms timeout/);
|
|
264
|
+
} finally {
|
|
265
|
+
rmSync(dir, { recursive: true, force: true });
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// 7. An existing (foreign) post-commit hook is chained, not overwritten.
|
|
270
|
+
test("existing post-commit hook is chained, not overwritten", () => {
|
|
271
|
+
const dir = makeTempRepo();
|
|
272
|
+
try {
|
|
273
|
+
gitInit(dir);
|
|
274
|
+
const hooksDir = path.join(dir, ".git", "hooks");
|
|
275
|
+
const foreignHookPath = path.join(hooksDir, "post-commit");
|
|
276
|
+
const markerFile = path.join(dir, "foreign-hook-ran.log");
|
|
277
|
+
writeFileSync(foreignHookPath, `#!/bin/sh\necho ran >> "${markerFile}"\n`, "utf-8");
|
|
278
|
+
execFileSync("chmod", ["+x", foreignHookPath]);
|
|
279
|
+
|
|
280
|
+
const project = uniqueProject("chain");
|
|
281
|
+
initStackmem(dir, project);
|
|
282
|
+
|
|
283
|
+
const hookContent = readFileSync(foreignHookPath, "utf-8");
|
|
284
|
+
assert.match(hookContent, /stackmem-managed/);
|
|
285
|
+
assert.ok(
|
|
286
|
+
existsSync(path.join(hooksDir, "post-commit.pre-stackmem")),
|
|
287
|
+
"expected the foreign hook to be preserved as a backup"
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
commit(dir, "feat: verify the foreign hook actually still runs today");
|
|
291
|
+
assert.ok(existsSync(markerFile), "expected the chained foreign hook to have run");
|
|
292
|
+
} finally {
|
|
293
|
+
rmSync(dir, { recursive: true, force: true });
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
// 8. Lockfile changes don't create memories (Item 3's diff filtering).
|
|
298
|
+
test("lockfile changes don't create memories", async () => {
|
|
299
|
+
const dir = makeTempRepo();
|
|
300
|
+
try {
|
|
301
|
+
gitInit(dir);
|
|
302
|
+
const project = uniqueProject("lockfile");
|
|
303
|
+
initStackmem(dir, project);
|
|
304
|
+
commit(dir, "feat: baseline commit before lockfile-only change today");
|
|
305
|
+
|
|
306
|
+
commit(
|
|
307
|
+
dir,
|
|
308
|
+
"chore: update lockfile with an embedded todo marker test",
|
|
309
|
+
{ "package-lock.json": '{"name":"x"}\n// TODO: this should be ignored since it is a lockfile\n' }
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
const memories = await fetchMemories(project);
|
|
313
|
+
assert.ok(
|
|
314
|
+
!memories.some((m) => m.content.includes("TODO")),
|
|
315
|
+
`expected no TODO memory from the lockfile diff, got: ${JSON.stringify(memories)}`
|
|
316
|
+
);
|
|
317
|
+
} finally {
|
|
318
|
+
rmSync(dir, { recursive: true, force: true });
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
// 9. cm uninstall restores the chained (foreign) hook, not just deletes it.
|
|
323
|
+
test("cm uninstall restores a chained foreign hook", () => {
|
|
324
|
+
const dir = makeTempRepo();
|
|
325
|
+
try {
|
|
326
|
+
gitInit(dir);
|
|
327
|
+
const hooksDir = path.join(dir, ".git", "hooks");
|
|
328
|
+
const foreignHookPath = path.join(hooksDir, "post-commit");
|
|
329
|
+
const originalContent = "#!/bin/sh\necho original-foreign-hook\n";
|
|
330
|
+
writeFileSync(foreignHookPath, originalContent, "utf-8");
|
|
331
|
+
execFileSync("chmod", ["+x", foreignHookPath]);
|
|
332
|
+
|
|
333
|
+
const project = uniqueProject("uninstall-chain");
|
|
334
|
+
initStackmem(dir, project);
|
|
335
|
+
assert.match(readFileSync(foreignHookPath, "utf-8"), /stackmem-managed/);
|
|
336
|
+
|
|
337
|
+
const fakeHome = mkdtempSync(path.join(tmpdir(), "stackmem-hooktest-home-"));
|
|
338
|
+
writeFileSync(path.join(fakeHome, ".claude.json"), JSON.stringify({ mcpServers: { stackmem: {} } }), "utf-8");
|
|
339
|
+
execFileSync(process.execPath, [CLI_PATH, "uninstall", project, "."], {
|
|
340
|
+
cwd: dir,
|
|
341
|
+
env: { ...process.env, HOME: fakeHome },
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
assert.equal(readFileSync(foreignHookPath, "utf-8"), originalContent);
|
|
345
|
+
assert.ok(
|
|
346
|
+
!existsSync(path.join(hooksDir, "post-commit.pre-stackmem")),
|
|
347
|
+
"expected the backup file to be cleaned up after restoring"
|
|
348
|
+
);
|
|
349
|
+
} finally {
|
|
350
|
+
rmSync(dir, { recursive: true, force: true });
|
|
351
|
+
}
|
|
352
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// Unit tests for the hybrid scoring functions in src/scoring.ts. Pure
|
|
2
|
+
// functions only — no network, no Supabase — using Node's built-in test
|
|
3
|
+
// runner (no Jest). Run with `npm test`.
|
|
4
|
+
import { test } from "node:test";
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
containmentScore,
|
|
9
|
+
containsNegationWord,
|
|
10
|
+
decideSaveAction,
|
|
11
|
+
computeDecayScore,
|
|
12
|
+
extractEntities,
|
|
13
|
+
hybridScore,
|
|
14
|
+
ngramOverlap,
|
|
15
|
+
redactSecrets,
|
|
16
|
+
} from "../scoring.js";
|
|
17
|
+
|
|
18
|
+
// 1. containment(a,b) returns 1.0 for identical strings.
|
|
19
|
+
test("containmentScore returns 1.0 for identical strings", () => {
|
|
20
|
+
const entities = extractEntities("implement stripe webhook signature verification");
|
|
21
|
+
assert.equal(containmentScore(entities, entities), 1);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// 2. containment(a,b) returns 0.0 for disjoint strings.
|
|
25
|
+
test("containmentScore returns 0.0 for entity-disjoint strings", () => {
|
|
26
|
+
const a = extractEntities("implement stripe webhook signature verification");
|
|
27
|
+
const b = extractEntities("migrate postgres database connection pooling");
|
|
28
|
+
assert.equal(containmentScore(a, b), 0);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// 3. trigramJaccard (ngramOverlap) returns a value between 0 and 1.
|
|
32
|
+
test("ngramOverlap (character-trigram Jaccard) returns a value between 0 and 1", () => {
|
|
33
|
+
const overlapping = ngramOverlap("hello world", "hello there");
|
|
34
|
+
const identical = ngramOverlap("hello world", "hello world");
|
|
35
|
+
const disjoint = ngramOverlap("abc", "xyz123qwerty");
|
|
36
|
+
|
|
37
|
+
for (const score of [overlapping, identical, disjoint]) {
|
|
38
|
+
assert.ok(score >= 0 && score <= 1, `expected ${score} to be within [0, 1]`);
|
|
39
|
+
}
|
|
40
|
+
assert.equal(identical, 1);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// 4. hybridScore = 0.7*containment + 0.3*trigram.
|
|
44
|
+
test("hybridScore combines containment and trigram overlap with 0.7/0.3 weights", () => {
|
|
45
|
+
const a = "implement stripe webhook signature verification logic";
|
|
46
|
+
const b = "add stripe webhook handler with signature checks";
|
|
47
|
+
|
|
48
|
+
const containment = containmentScore(extractEntities(a), extractEntities(b));
|
|
49
|
+
const trigram = ngramOverlap(a, b);
|
|
50
|
+
const expected = containment * 0.7 + trigram * 0.3;
|
|
51
|
+
|
|
52
|
+
assert.ok(Math.abs(hybridScore(a, b) - expected) < 1e-9);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// 5. isDuplicate (decideSaveAction's "duplicate" branch) fires when
|
|
56
|
+
// hybridScore exceeds the 0.85 threshold.
|
|
57
|
+
test('decideSaveAction returns "duplicate" when content scores above 0.85 against an existing memory', () => {
|
|
58
|
+
const existing = [{ id: "mem-1", content: "implement stripe webhook signature verification logic" }];
|
|
59
|
+
const decision = decideSaveAction("implement stripe webhook signature verification logic", existing);
|
|
60
|
+
assert.equal(decision.action, "duplicate");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// 6. isContradiction: negation-word detection, and decideSaveAction's
|
|
64
|
+
// "superseded" branch when a negation word pairs with enough overlap.
|
|
65
|
+
test("containsNegationWord detects negation/contradiction language", () => {
|
|
66
|
+
assert.equal(containsNegationWord("we switched from REST to GraphQL"), true);
|
|
67
|
+
assert.equal(containsNegationWord("no longer using the old queue"), true);
|
|
68
|
+
assert.equal(containsNegationWord("a totally unrelated sentence about cats"), false);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('decideSaveAction returns "superseded" when new content contradicts an existing memory', () => {
|
|
72
|
+
const existing = [{ id: "mem-1", content: "use rest api for payment webhook processing" }];
|
|
73
|
+
const decision = decideSaveAction(
|
|
74
|
+
"we switched from rest api to graphql for payment webhook processing",
|
|
75
|
+
existing
|
|
76
|
+
);
|
|
77
|
+
assert.equal(decision.action, "superseded");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// 7. computeDecayScore returns a higher score for more recently created
|
|
81
|
+
// memories (same access count).
|
|
82
|
+
test("computeDecayScore returns a higher score for more recently created memories", () => {
|
|
83
|
+
const recent = computeDecayScore(new Date(Date.now() - 1000), 0);
|
|
84
|
+
const old = computeDecayScore(new Date(Date.now() - 30 * 24 * 60 * 60 * 1000), 0);
|
|
85
|
+
assert.ok(recent > old);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// 8. computeDecayScore returns a higher score for more frequently
|
|
89
|
+
// accessed memories (same creation time).
|
|
90
|
+
test("computeDecayScore returns a higher score for more frequently accessed memories", () => {
|
|
91
|
+
const createdAt = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000);
|
|
92
|
+
const lowAccess = computeDecayScore(createdAt, 0);
|
|
93
|
+
const highAccess = computeDecayScore(createdAt, 50);
|
|
94
|
+
assert.ok(highAccess > lowAccess);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// 9. Pinned memories always get score 999, regardless of age or access
|
|
98
|
+
// count.
|
|
99
|
+
test("computeDecayScore returns 999 for pinned memories regardless of age or access count", () => {
|
|
100
|
+
assert.equal(computeDecayScore(new Date(0), 0, true), 999);
|
|
101
|
+
assert.equal(computeDecayScore(new Date(), 10_000, true), 999);
|
|
102
|
+
assert.equal(computeDecayScore(new Date(Date.now() - 365 * 24 * 60 * 60 * 1000), 0, true), 999);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// 10. redactSecrets catches all 5 documented pattern types.
|
|
106
|
+
test("redactSecrets catches AWS keys, API keys, bearer tokens, private keys, and .env lines", () => {
|
|
107
|
+
assert.doesNotMatch(redactSecrets("deployed with AKIAABCDEFGHIJ123456 in prod"), /AKIA[0-9A-Z]{16}/);
|
|
108
|
+
assert.match(redactSecrets("deployed with AKIAABCDEFGHIJ123456 in prod"), /\[REDACTED\]/);
|
|
109
|
+
|
|
110
|
+
assert.match(redactSecrets("api_key: abcdefghijklmnopqrstuvwxyz1234567890"), /\[REDACTED\]/);
|
|
111
|
+
|
|
112
|
+
assert.match(
|
|
113
|
+
redactSecrets("Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.payload.signature"),
|
|
114
|
+
/\[REDACTED\]/
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
assert.match(
|
|
118
|
+
redactSecrets("-----BEGIN RSA PRIVATE KEY-----\nMIIBogIBAAJ...\n-----END RSA PRIVATE KEY-----"),
|
|
119
|
+
/\[REDACTED\]/
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
assert.match(redactSecrets("DATABASE_URL=postgres://user:pass@host:5432/db"), /\[REDACTED\]/);
|
|
123
|
+
});
|