stackmem 1.0.21 → 1.0.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +81 -155
- package/dist/cli.js +401 -63
- package/dist/scoring.js +74 -0
- package/dist/tests/regression.test.js +248 -0
- package/package.json +3 -2
- package/src/cli.ts +438 -60
- package/src/scoring.ts +88 -0
- package/src/tests/regression.test.ts +304 -0
package/dist/scoring.js
CHANGED
|
@@ -292,6 +292,37 @@ export function isQualityMessage(message) {
|
|
|
292
292
|
return false;
|
|
293
293
|
return true;
|
|
294
294
|
}
|
|
295
|
+
// --- conventional commit routing --------------------------------------------
|
|
296
|
+
// Canonical reference implementation of the hook's conventional-commit
|
|
297
|
+
// routing — the git hook (src/cli.ts buildHookLogicScript) keeps its own
|
|
298
|
+
// inline copy since it runs standalone with no access to this module (same
|
|
299
|
+
// reason isQualityMessage above is duplicated there). Keep the two in sync
|
|
300
|
+
// by hand if this changes.
|
|
301
|
+
const CONVENTIONAL_COMMIT_RE = /^([a-zA-Z]+)(\([^)]*\))?!?:\s*(.+)/;
|
|
302
|
+
const CONVENTIONAL_DECISION_TYPES = new Set(["feat", "fix", "perf", "refactor"]);
|
|
303
|
+
const CONVENTIONAL_SKIP_TYPES = new Set(["docs", "style", "test", "ci", "build", "chore"]);
|
|
304
|
+
// Classifies a commit's SUBJECT line only — "decision" for feat/fix/perf/
|
|
305
|
+
// refactor, "skip" for docs/style/test/ci/build/chore, or null if the
|
|
306
|
+
// subject doesn't start with a recognized conventional-commit type (in
|
|
307
|
+
// which case the caller falls back to isQualityMessage).
|
|
308
|
+
export function classifyConventionalCommit(message) {
|
|
309
|
+
const firstLine = message.trim().split("\n")[0];
|
|
310
|
+
const match = firstLine.match(CONVENTIONAL_COMMIT_RE);
|
|
311
|
+
if (!match)
|
|
312
|
+
return null;
|
|
313
|
+
const type = match[1].toLowerCase();
|
|
314
|
+
if (CONVENTIONAL_DECISION_TYPES.has(type))
|
|
315
|
+
return "decision";
|
|
316
|
+
if (CONVENTIONAL_SKIP_TYPES.has(type))
|
|
317
|
+
return "skip";
|
|
318
|
+
return null;
|
|
319
|
+
}
|
|
320
|
+
// Whether the commit message's footer declares a breaking change — this
|
|
321
|
+
// check is independent of (and additive to) classifyConventionalCommit's
|
|
322
|
+
// type-based routing above.
|
|
323
|
+
export function hasBreakingChangeFooter(message) {
|
|
324
|
+
return /BREAKING CHANGE:\s*[\s\S]+/.test(message.trim());
|
|
325
|
+
}
|
|
295
326
|
// --- session-end classification ---------------------------------------------
|
|
296
327
|
const FAILURE_PHRASES = ["failed", "didn't work", "reverted", "switched away"];
|
|
297
328
|
export function indicatesFailedApproach(text) {
|
|
@@ -301,6 +332,49 @@ export function indicatesFailedApproach(text) {
|
|
|
301
332
|
export function isAuthRelatedFile(filePath) {
|
|
302
333
|
return /(auth|login|session|token)/i.test(filePath);
|
|
303
334
|
}
|
|
335
|
+
// --- secret redaction ----------------------------------------------------
|
|
336
|
+
// Patterns whose entire match is replaced outright — each one is specific
|
|
337
|
+
// enough (AWS key prefix, a labeled api_key field, a Bearer auth header, a
|
|
338
|
+
// PEM private key header, a KEY=value .env line) that a false positive is
|
|
339
|
+
// unlikely enough to risk leaving the value in place.
|
|
340
|
+
const SECRET_PATTERNS = [
|
|
341
|
+
/AKIA[0-9A-Z]{16}/g,
|
|
342
|
+
/(api[_-]?key|apikey)\s*[:=]\s*["']?[\w-]{20,}["']?/gi,
|
|
343
|
+
/bearer\s+[a-zA-Z0-9\-._~+/]+=*/gi,
|
|
344
|
+
/-----BEGIN [A-Z ]+PRIVATE KEY-----/g,
|
|
345
|
+
/^[A-Z_]+=.{8,}/gm,
|
|
346
|
+
];
|
|
347
|
+
// A bare 40+ char base64-ish string is common enough in non-secret content
|
|
348
|
+
// (hashes, ids) that it needs a second signal before redacting: Shannon
|
|
349
|
+
// entropy per character. Random secrets sit well above ~4.5 bits/char;
|
|
350
|
+
// lower-entropy lookalikes (repeated or structured text) are left alone.
|
|
351
|
+
const HIGH_ENTROPY_RE = /[a-zA-Z0-9+/]{40,}={0,2}/g;
|
|
352
|
+
const HIGH_ENTROPY_MIN_BITS_PER_CHAR = 4.5;
|
|
353
|
+
function shannonEntropy(text) {
|
|
354
|
+
if (text.length === 0)
|
|
355
|
+
return 0;
|
|
356
|
+
const counts = new Map();
|
|
357
|
+
for (const char of text) {
|
|
358
|
+
counts.set(char, (counts.get(char) ?? 0) + 1);
|
|
359
|
+
}
|
|
360
|
+
let entropy = 0;
|
|
361
|
+
for (const count of counts.values()) {
|
|
362
|
+
const p = count / text.length;
|
|
363
|
+
entropy -= p * Math.log2(p);
|
|
364
|
+
}
|
|
365
|
+
return entropy;
|
|
366
|
+
}
|
|
367
|
+
// Strips likely secrets (AWS keys, API keys, bearer tokens, private key
|
|
368
|
+
// headers, .env assignments, and high-entropy strings) before content is
|
|
369
|
+
// saved anywhere — a memory, a fix, or the hook's own printed summary.
|
|
370
|
+
export function redactSecrets(content) {
|
|
371
|
+
let result = content;
|
|
372
|
+
for (const pattern of SECRET_PATTERNS) {
|
|
373
|
+
result = result.replace(pattern, "[REDACTED]");
|
|
374
|
+
}
|
|
375
|
+
result = result.replace(HIGH_ENTROPY_RE, (match) => shannonEntropy(match) > HIGH_ENTROPY_MIN_BITS_PER_CHAR ? "[REDACTED]" : match);
|
|
376
|
+
return result;
|
|
377
|
+
}
|
|
304
378
|
export function isCodeFile(filePath) {
|
|
305
379
|
return /\.(ts|js)$/i.test(filePath);
|
|
306
380
|
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
// Regression tests for bugs fixed during Phase 0 hardening. Uses Node's
|
|
2
|
+
// built-in test runner (no Jest) — run with `npm test`.
|
|
3
|
+
//
|
|
4
|
+
// Tests that exercise the CLI's observable contract (printed output, exit
|
|
5
|
+
// codes) spawn the compiled dist/cli.js as a subprocess rather than
|
|
6
|
+
// importing src/cli.ts directly — that's a closer match to how a real user
|
|
7
|
+
// (or the git hook) invokes it, and avoids coupling the test to internal
|
|
8
|
+
// function names. Tests of internal mechanisms (seeding dedup, redaction,
|
|
9
|
+
// commit routing, the hook timeout contract) import directly instead.
|
|
10
|
+
import { test } from "node:test";
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { execFileSync } from "node:child_process";
|
|
13
|
+
import { mkdtempSync, writeFileSync, readFileSync, rmSync } from "node:fs";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { trySeedMemory } from "../cli.js";
|
|
17
|
+
import { redactSecrets, classifyConventionalCommit, isQualityMessage, } from "../scoring.js";
|
|
18
|
+
const CLI_PATH = path.join(import.meta.dirname, "..", "..", "dist", "cli.js");
|
|
19
|
+
// Isolates a CLI subprocess from the real ~/.claude.json and any real
|
|
20
|
+
// .stackmem in the caller's cwd.
|
|
21
|
+
function runCli(args, cwd) {
|
|
22
|
+
const fakeHome = mkdtempSync(path.join(tmpdir(), "stackmem-fakehome-"));
|
|
23
|
+
try {
|
|
24
|
+
execFileSync(process.execPath, [CLI_PATH, ...args], {
|
|
25
|
+
cwd,
|
|
26
|
+
env: { ...process.env, HOME: fakeHome },
|
|
27
|
+
encoding: "utf-8",
|
|
28
|
+
});
|
|
29
|
+
return { stdout: "", stderr: "", status: 0 };
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
return {
|
|
33
|
+
stdout: error.stdout ?? "",
|
|
34
|
+
stderr: error.stderr ?? "",
|
|
35
|
+
status: error.status ?? 1,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
rmSync(fakeHome, { recursive: true, force: true });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
// Re-run successfully (for assertions that need stdout on the happy path,
|
|
43
|
+
// where execFileSync wouldn't throw and the catch branch above never fires).
|
|
44
|
+
function runCliOk(args, cwd) {
|
|
45
|
+
const fakeHome = mkdtempSync(path.join(tmpdir(), "stackmem-fakehome-"));
|
|
46
|
+
try {
|
|
47
|
+
return execFileSync(process.execPath, [CLI_PATH, ...args], {
|
|
48
|
+
cwd,
|
|
49
|
+
env: { ...process.env, HOME: fakeHome },
|
|
50
|
+
encoding: "utf-8",
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
finally {
|
|
54
|
+
rmSync(fakeHome, { recursive: true, force: true });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function makeTempRepo() {
|
|
58
|
+
const dir = mkdtempSync(path.join(tmpdir(), "stackmem-regression-"));
|
|
59
|
+
execFileSync("git", ["init", "-q"], { cwd: dir });
|
|
60
|
+
return dir;
|
|
61
|
+
}
|
|
62
|
+
// 1. cm init on a project without .gitignore creates one with node_modules/.
|
|
63
|
+
test("cm init creates .gitignore with node_modules/ when none exists", () => {
|
|
64
|
+
const dir = makeTempRepo();
|
|
65
|
+
try {
|
|
66
|
+
runCliOk(["init", "regression-test-init-gitignore", "."], dir);
|
|
67
|
+
const gitignore = readFileSync(path.join(dir, ".gitignore"), "utf-8");
|
|
68
|
+
assert.match(gitignore, /node_modules\//);
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
rmSync(dir, { recursive: true, force: true });
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
// 2. cm save with an invalid type prints a targeted error, not full usage.
|
|
75
|
+
test("cm save with invalid type prints a targeted error, not the usage dump", () => {
|
|
76
|
+
const dir = makeTempRepo();
|
|
77
|
+
try {
|
|
78
|
+
const result = runCli(["save", "regression-test-invalid-type", "bogus-type", "some memory content here"], dir);
|
|
79
|
+
assert.equal(result.status, 1);
|
|
80
|
+
assert.match(result.stderr, /Invalid memory type 'bogus-type'/);
|
|
81
|
+
assert.doesNotMatch(result.stderr, /Usage:/);
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
rmSync(dir, { recursive: true, force: true });
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
// 3. cm delete with a fake UUID prints "No memory found".
|
|
88
|
+
test('cm delete with a nonexistent UUID prints "No memory found"', () => {
|
|
89
|
+
const dir = makeTempRepo();
|
|
90
|
+
try {
|
|
91
|
+
const stdout = runCliOk(["delete", "regression-test-fake-uuid", "00000000-0000-0000-0000-000000000000"], dir);
|
|
92
|
+
assert.match(stdout, /No memory found/);
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
rmSync(dir, { recursive: true, force: true });
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
// 4. cm resolve with a fake UUID prints "No memory found".
|
|
99
|
+
test('cm resolve with a nonexistent UUID prints "No memory found"', () => {
|
|
100
|
+
const dir = makeTempRepo();
|
|
101
|
+
try {
|
|
102
|
+
const stdout = runCliOk(["resolve", "regression-test-fake-uuid", "00000000-0000-0000-0000-000000000000"], dir);
|
|
103
|
+
assert.match(stdout, /No memory found/);
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
rmSync(dir, { recursive: true, force: true });
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
// 5. cm fix with empty strings prints a validation error.
|
|
110
|
+
test("cm fix with empty problem/solution prints a validation error", () => {
|
|
111
|
+
const dir = makeTempRepo();
|
|
112
|
+
try {
|
|
113
|
+
const result = runCli(["fix", "regression-test-empty-fix", "", ""], dir);
|
|
114
|
+
assert.equal(result.status, 1);
|
|
115
|
+
assert.match(result.stderr, /cannot be empty/);
|
|
116
|
+
}
|
|
117
|
+
finally {
|
|
118
|
+
rmSync(dir, { recursive: true, force: true });
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
// 6. cm save with content over 500 chars prints a length error.
|
|
122
|
+
test("cm save with content over 500 chars prints a length error", () => {
|
|
123
|
+
const dir = makeTempRepo();
|
|
124
|
+
try {
|
|
125
|
+
const longContent = "x".repeat(501);
|
|
126
|
+
const result = runCli(["save", "regression-test-too-long", "decision", longContent], dir);
|
|
127
|
+
assert.equal(result.status, 1);
|
|
128
|
+
assert.match(result.stderr, /too long/);
|
|
129
|
+
}
|
|
130
|
+
finally {
|
|
131
|
+
rmSync(dir, { recursive: true, force: true });
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
// 7. cm init run twice does not duplicate seed memories (mocked Supabase
|
|
135
|
+
// client — verify insert called exactly once per type across both runs).
|
|
136
|
+
test("trySeedMemory does not insert the same seed twice across two init runs", async () => {
|
|
137
|
+
const insertCalls = [];
|
|
138
|
+
const mockClient = {
|
|
139
|
+
from(table) {
|
|
140
|
+
return {
|
|
141
|
+
insert: async (row) => {
|
|
142
|
+
insertCalls.push({ table, row });
|
|
143
|
+
return { error: null };
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
const project = "regression-test-seed-dedup";
|
|
149
|
+
const techStackContent = "tech stack: zod, tree-sitter";
|
|
150
|
+
const envVarsContent = "required env vars: DATABASE_URL, API_PORT";
|
|
151
|
+
// First `cm init`: nothing seeded yet.
|
|
152
|
+
let existingMemories = [];
|
|
153
|
+
const firstDiscoveryInserted = await trySeedMemory(project, existingMemories, "discovery", techStackContent, mockClient);
|
|
154
|
+
const firstConstraintInserted = await trySeedMemory(project, existingMemories, "constraint", envVarsContent, mockClient);
|
|
155
|
+
assert.equal(firstDiscoveryInserted, true);
|
|
156
|
+
assert.equal(firstConstraintInserted, true);
|
|
157
|
+
// Second `cm init`: existingMemories now reflects what the first run
|
|
158
|
+
// inserted — this is what actually happens between two real runs, since
|
|
159
|
+
// seedMemories re-fetches existing memories from Supabase each time.
|
|
160
|
+
existingMemories = [
|
|
161
|
+
{ id: "seed-1", content: techStackContent },
|
|
162
|
+
{ id: "seed-2", content: envVarsContent },
|
|
163
|
+
];
|
|
164
|
+
const secondDiscoveryInserted = await trySeedMemory(project, existingMemories, "discovery", techStackContent, mockClient);
|
|
165
|
+
const secondConstraintInserted = await trySeedMemory(project, existingMemories, "constraint", envVarsContent, mockClient);
|
|
166
|
+
assert.equal(secondDiscoveryInserted, false);
|
|
167
|
+
assert.equal(secondConstraintInserted, false);
|
|
168
|
+
const discoveryInsertCount = insertCalls.filter((call) => call.row.type === "discovery").length;
|
|
169
|
+
const constraintInsertCount = insertCalls.filter((call) => call.row.type === "constraint").length;
|
|
170
|
+
assert.equal(discoveryInsertCount, 1);
|
|
171
|
+
assert.equal(constraintInsertCount, 1);
|
|
172
|
+
});
|
|
173
|
+
// 8. Secret redaction: content with an AWS key gets [REDACTED].
|
|
174
|
+
test("redactSecrets replaces an AWS access key with [REDACTED]", () => {
|
|
175
|
+
const redacted = redactSecrets("deployed with AKIAABCDEFGHIJ123456 in the env");
|
|
176
|
+
assert.doesNotMatch(redacted, /AKIA[0-9A-Z]{16}/);
|
|
177
|
+
assert.match(redacted, /\[REDACTED\]/);
|
|
178
|
+
});
|
|
179
|
+
// 9. Conventional commit: "chore: update deps" routes to skip, not decision
|
|
180
|
+
// (the conventional-routing equivalent of the old isQualityMessage filter).
|
|
181
|
+
test('conventional commit "chore: update deps" is routed to skip, not decision', () => {
|
|
182
|
+
assert.equal(classifyConventionalCommit("chore: update deps"), "skip");
|
|
183
|
+
// Sanity check against the pre-conventional-routing filter it replaces
|
|
184
|
+
// for this case: a bare quality-filter reading would have let this
|
|
185
|
+
// through (it's a well-formed, >15-char message), which is exactly why
|
|
186
|
+
// conventional-commit routing has to run first and skip it outright.
|
|
187
|
+
assert.equal(isQualityMessage("chore: update deps"), true);
|
|
188
|
+
});
|
|
189
|
+
// 10. Hook timeout: if hook logic exceeds 2s, the process still exits 0
|
|
190
|
+
// (and logs the timeout) instead of hanging or failing the commit.
|
|
191
|
+
test("hook timeout wrapper exits 0 within ~2s when the hook body hangs", async () => {
|
|
192
|
+
const dir = mkdtempSync(path.join(tmpdir(), "stackmem-hook-timeout-"));
|
|
193
|
+
const scriptPath = path.join(dir, "slow-hook.mjs");
|
|
194
|
+
// Mirrors buildHookLogicScript's main()/runHook() timeout wrapper
|
|
195
|
+
// (src/cli.ts) exactly, with runHook() replaced by an artificial 5s hang
|
|
196
|
+
// to deterministically force the timeout path.
|
|
197
|
+
writeFileSync(scriptPath, `
|
|
198
|
+
import { appendFileSync, mkdirSync } from "node:fs";
|
|
199
|
+
import { join, dirname } from "node:path";
|
|
200
|
+
|
|
201
|
+
const HOOK_TIMEOUT_MS = 2000;
|
|
202
|
+
|
|
203
|
+
function logHookError(error) {
|
|
204
|
+
try {
|
|
205
|
+
const logPath = join(process.cwd(), ".coding-memory", "hook.log");
|
|
206
|
+
mkdirSync(dirname(logPath), { recursive: true });
|
|
207
|
+
const message = error instanceof Error ? (error.stack || error.message) : String(error);
|
|
208
|
+
appendFileSync(logPath, \`[\${new Date().toISOString()}] \${message}\\n\`);
|
|
209
|
+
} catch {}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function runHook() {
|
|
213
|
+
await new Promise((resolve) => setTimeout(resolve, 5000));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function main() {
|
|
217
|
+
let timeoutHandle;
|
|
218
|
+
try {
|
|
219
|
+
await Promise.race([
|
|
220
|
+
runHook(),
|
|
221
|
+
new Promise((_resolve, reject) => {
|
|
222
|
+
timeoutHandle = setTimeout(
|
|
223
|
+
() => reject(new Error("stackmem post-commit hook exceeded " + HOOK_TIMEOUT_MS + "ms timeout")),
|
|
224
|
+
HOOK_TIMEOUT_MS
|
|
225
|
+
);
|
|
226
|
+
}),
|
|
227
|
+
]);
|
|
228
|
+
} catch (error) {
|
|
229
|
+
logHookError(error);
|
|
230
|
+
} finally {
|
|
231
|
+
clearTimeout(timeoutHandle);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
main().finally(() => process.exit(0));
|
|
236
|
+
`, "utf-8");
|
|
237
|
+
try {
|
|
238
|
+
const start = Date.now();
|
|
239
|
+
execFileSync(process.execPath, [scriptPath], { cwd: dir, encoding: "utf-8" });
|
|
240
|
+
const elapsedMs = Date.now() - start;
|
|
241
|
+
assert.ok(elapsedMs < 4000, `expected timeout around 2s, took ${elapsedMs}ms`);
|
|
242
|
+
const log = readFileSync(path.join(dir, ".coding-memory", "hook.log"), "utf-8");
|
|
243
|
+
assert.match(log, /exceeded 2000ms timeout/);
|
|
244
|
+
}
|
|
245
|
+
finally {
|
|
246
|
+
rmSync(dir, { recursive: true, force: true });
|
|
247
|
+
}
|
|
248
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "stackmem",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.22",
|
|
4
4
|
"description": "",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
"scripts": {
|
|
15
15
|
"dev": "tsx src/index.ts",
|
|
16
16
|
"cli": "tsx src/cli.ts",
|
|
17
|
-
"build": "tsc"
|
|
17
|
+
"build": "tsc",
|
|
18
|
+
"test": "tsx --test src/tests/*.test.ts"
|
|
18
19
|
},
|
|
19
20
|
"keywords": [],
|
|
20
21
|
"author": "",
|