@xiaofandegeng/rmemo 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,395 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import fs from "node:fs/promises";
4
+ import path from "node:path";
5
+ import os from "node:os";
6
+ import { spawn } from "node:child_process";
7
+
8
+ function runNode(args, { cwd } = {}) {
9
+ return new Promise((resolve, reject) => {
10
+ const p = spawn(process.execPath, args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
11
+ let out = "";
12
+ let err = "";
13
+ p.stdout.on("data", (d) => (out += d.toString("utf8")));
14
+ p.stderr.on("data", (d) => (err += d.toString("utf8")));
15
+ p.on("error", reject);
16
+ p.on("close", (code) => resolve({ code, out, err }));
17
+ });
18
+ }
19
+
20
+ function runNodeWithStdin(args, stdinText, { cwd } = {}) {
21
+ return new Promise((resolve, reject) => {
22
+ const p = spawn(process.execPath, args, { cwd, stdio: ["pipe", "pipe", "pipe"] });
23
+ let out = "";
24
+ let err = "";
25
+ p.stdout.on("data", (d) => (out += d.toString("utf8")));
26
+ p.stderr.on("data", (d) => (err += d.toString("utf8")));
27
+ p.on("error", reject);
28
+ p.on("close", (code) => resolve({ code, out, err }));
29
+ p.stdin.end(stdinText, "utf8");
30
+ });
31
+ }
32
+
33
+ function runCmd(bin, args, { cwd } = {}) {
34
+ return new Promise((resolve, reject) => {
35
+ const p = spawn(bin, args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
36
+ let out = "";
37
+ let err = "";
38
+ p.stdout.on("data", (d) => (out += d.toString("utf8")));
39
+ p.stderr.on("data", (d) => (err += d.toString("utf8")));
40
+ p.on("error", reject);
41
+ p.on("close", (code) => resolve({ code, out, err }));
42
+ });
43
+ }
44
+
45
+ async function exists(p) {
46
+ try {
47
+ await fs.access(p);
48
+ return true;
49
+ } catch {
50
+ return false;
51
+ }
52
+ }
53
+
54
+ test("rmemo init/log/context works on a generic repo (no git)", async () => {
55
+ const rmemoBin = path.resolve("bin/rmemo.js");
56
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "rmemo-smoke-"));
57
+
58
+ // A fake repo with a couple files, to exercise scanning heuristics.
59
+ await fs.writeFile(path.join(tmp, "README.md"), "# Demo\n", "utf8");
60
+ await fs.writeFile(
61
+ path.join(tmp, "package.json"),
62
+ JSON.stringify({ name: "demo-repo", private: true, scripts: { dev: "node index.js" } }, null, 2) + "\n",
63
+ "utf8"
64
+ );
65
+ await fs.mkdir(path.join(tmp, "src"), { recursive: true });
66
+ await fs.writeFile(path.join(tmp, "src", "index.js"), "console.log('hi')\n", "utf8");
67
+
68
+ {
69
+ const r = await runNode([rmemoBin, "--root", tmp, "--no-git", "init"]);
70
+ assert.equal(r.code, 0, r.err || r.out);
71
+ }
72
+ {
73
+ const r = await runNode([rmemoBin, "--root", tmp, "log", "did x; next y"]);
74
+ assert.equal(r.code, 0, r.err || r.out);
75
+ }
76
+ {
77
+ const r = await runNode([rmemoBin, "--root", tmp, "context"]);
78
+ assert.equal(r.code, 0, r.err || r.out);
79
+ }
80
+ {
81
+ const r = await runNode([rmemoBin, "--root", tmp, "--format", "md", "--mode", "brief", "status"]);
82
+ assert.equal(r.code, 0, r.err || r.out);
83
+ assert.ok(r.out.includes("# Status"), "status should output markdown");
84
+ assert.ok(!r.out.includes("Rules (Excerpt)"), "brief status should not include rules excerpt");
85
+ }
86
+
87
+ assert.equal(await exists(path.join(tmp, ".repo-memory", "manifest.json")), true);
88
+ assert.equal(await exists(path.join(tmp, ".repo-memory", "index.json")), true);
89
+ assert.equal(await exists(path.join(tmp, ".repo-memory", "rules.md")), true);
90
+ assert.equal(await exists(path.join(tmp, ".repo-memory", "todos.md")), true);
91
+ assert.equal(await exists(path.join(tmp, ".repo-memory", "context.md")), true);
92
+
93
+ const journalDir = path.join(tmp, ".repo-memory", "journal");
94
+ const ents = await fs.readdir(journalDir);
95
+ assert.ok(ents.some((n) => n.endsWith(".md")), "journal file should exist");
96
+ });
97
+
98
+ test("rmemo check enforces forbidden/required/naming rules", async () => {
99
+ const rmemoBin = path.resolve("bin/rmemo.js");
100
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "rmemo-check-"));
101
+
102
+ await fs.mkdir(path.join(tmp, "src", "pages"), { recursive: true });
103
+ await fs.writeFile(path.join(tmp, "src", "pages", "BadName.vue"), "<template />\n", "utf8");
104
+ await fs.writeFile(path.join(tmp, ".env"), "SECRET=1\n", "utf8");
105
+ await fs.writeFile(path.join(tmp, "secrets.txt"), "-----BEGIN PRIVATE KEY-----\n", "utf8");
106
+
107
+ // init will create rules.json; overwrite to include our checks.
108
+ {
109
+ const r = await runNode([rmemoBin, "--root", tmp, "--no-git", "init"]);
110
+ assert.equal(r.code, 0, r.err || r.out);
111
+ }
112
+
113
+ const rulesPath = path.join(tmp, ".repo-memory", "rules.json");
114
+ const rules = {
115
+ schema: 1,
116
+ requiredPaths: ["README.md"],
117
+ forbiddenPaths: [".env", ".env.*"],
118
+ forbiddenContent: [
119
+ {
120
+ include: ["**/*.txt"],
121
+ match: "BEGIN PRIVATE KEY",
122
+ message: "Do not commit private keys."
123
+ }
124
+ ],
125
+ namingRules: [
126
+ {
127
+ include: ["src/pages/**"],
128
+ target: "basename",
129
+ match: "^[a-z0-9-]+\\.vue$",
130
+ message: "Page filenames must be kebab-case."
131
+ }
132
+ ]
133
+ };
134
+ await fs.writeFile(rulesPath, JSON.stringify(rules, null, 2) + "\n", "utf8");
135
+
136
+ {
137
+ const r = await runNode([rmemoBin, "--root", tmp, "--no-git", "check"]);
138
+ assert.equal(r.code, 1, "check should fail with violations");
139
+ assert.ok(r.err.includes("VIOLATION:"), "stderr should include violations");
140
+ }
141
+
142
+ // Fix violations
143
+ await fs.writeFile(path.join(tmp, "README.md"), "# ok\n", "utf8");
144
+ await fs.unlink(path.join(tmp, ".env"));
145
+ await fs.writeFile(path.join(tmp, "secrets.txt"), "ok\n", "utf8");
146
+ await fs.rename(path.join(tmp, "src", "pages", "BadName.vue"), path.join(tmp, "src", "pages", "bad-name.vue"));
147
+
148
+ {
149
+ const r = await runNode([rmemoBin, "--root", tmp, "--no-git", "check"]);
150
+ assert.equal(r.code, 0, r.err || r.out);
151
+ assert.ok(r.out.includes("OK:"), "stdout should confirm OK");
152
+ }
153
+ });
154
+
155
+ test("rmemo hook install writes pre-commit hook (and respects --force)", async () => {
156
+ const rmemoBin = path.resolve("bin/rmemo.js");
157
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "rmemo-hook-"));
158
+
159
+ // init a git repo
160
+ {
161
+ const r = await runCmd("git", ["init"], { cwd: tmp });
162
+ assert.equal(r.code, 0, r.err || r.out);
163
+ }
164
+
165
+ // install hook
166
+ {
167
+ const r = await runNode([rmemoBin, "--root", tmp, "hook", "install"]);
168
+ assert.equal(r.code, 0, r.err || r.out);
169
+ }
170
+
171
+ const hookPath = path.join(tmp, ".git", "hooks", "pre-commit");
172
+ assert.equal(await exists(hookPath), true);
173
+ const hook = await fs.readFile(hookPath, "utf8");
174
+ assert.ok(hook.includes("rmemo pre-commit hook"), "hook should include marker");
175
+ assert.ok(hook.includes(" check"), "hook should call check");
176
+ assert.ok(hook.includes("--staged"), "hook should use --staged");
177
+
178
+ // existing non-rmemo hook should block unless --force
179
+ await fs.writeFile(hookPath, "#!/usr/bin/env bash\necho custom\n", "utf8");
180
+ {
181
+ const r = await runNode([rmemoBin, "--root", tmp, "hook", "install"]);
182
+ assert.equal(r.code, 2, "should refuse to overwrite");
183
+ }
184
+ {
185
+ const r = await runNode([rmemoBin, "--root", tmp, "--force", "hook", "install"]);
186
+ assert.equal(r.code, 0, r.err || r.out);
187
+ }
188
+ const hook2 = await fs.readFile(hookPath, "utf8");
189
+ assert.ok(hook2.includes("rmemo pre-commit hook"));
190
+ });
191
+
192
+ test("rmemo check --staged validates staged changes only", async () => {
193
+ const rmemoBin = path.resolve("bin/rmemo.js");
194
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "rmemo-staged-"));
195
+
196
+ // init a git repo
197
+ {
198
+ const r = await runCmd("git", ["init"], { cwd: tmp });
199
+ assert.equal(r.code, 0, r.err || r.out);
200
+ }
201
+
202
+ // init rmemo memory and commit it (so requiredPaths can be checked against repo files)
203
+ {
204
+ const r = await runNode([rmemoBin, "--root", tmp, "init"]);
205
+ assert.equal(r.code, 0, r.err || r.out);
206
+ }
207
+ await fs.writeFile(
208
+ path.join(tmp, ".repo-memory", "rules.json"),
209
+ JSON.stringify(
210
+ {
211
+ schema: 1,
212
+ requiredPaths: [],
213
+ forbiddenPaths: [],
214
+ forbiddenContent: [{ include: ["**/*.txt"], match: "BEGIN PRIVATE KEY" }],
215
+ namingRules: []
216
+ },
217
+ null,
218
+ 2
219
+ ) + "\n",
220
+ "utf8"
221
+ );
222
+
223
+ // Commit baseline
224
+ await runCmd("git", ["add", "-A"], { cwd: tmp });
225
+ await runCmd("git", ["commit", "-m", "init"], { cwd: tmp });
226
+
227
+ // Create a secret file but do not stage it yet
228
+ await fs.writeFile(path.join(tmp, "secret.txt"), "BEGIN PRIVATE KEY\n", "utf8");
229
+
230
+ {
231
+ const r = await runNode([rmemoBin, "--root", tmp, "--staged", "check"]);
232
+ assert.equal(r.code, 0, r.err || r.out);
233
+ }
234
+
235
+ // Stage it => should fail
236
+ await runCmd("git", ["add", "secret.txt"], { cwd: tmp });
237
+
238
+ // Make working tree clean-looking, but keep the staged content secret.
239
+ await fs.writeFile(path.join(tmp, "secret.txt"), "ok\n", "utf8");
240
+
241
+ {
242
+ const r = await runNode([rmemoBin, "--root", tmp, "--staged", "check"]);
243
+ assert.equal(r.code, 1, "staged secret should fail");
244
+ }
245
+ });
246
+
247
+ test("scan detects monorepo signals and subprojects", async () => {
248
+ const rmemoBin = path.resolve("bin/rmemo.js");
249
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "rmemo-scan-"));
250
+
251
+ // root monorepo
252
+ await fs.writeFile(
253
+ path.join(tmp, "package.json"),
254
+ JSON.stringify(
255
+ {
256
+ name: "root",
257
+ private: true,
258
+ workspaces: ["apps/*", "packages/*"]
259
+ },
260
+ null,
261
+ 2
262
+ ) + "\n",
263
+ "utf8"
264
+ );
265
+ await fs.writeFile(path.join(tmp, "pnpm-workspace.yaml"), "packages:\n - 'apps/*'\n", "utf8");
266
+
267
+ // subprojects
268
+ await fs.mkdir(path.join(tmp, "apps", "admin-web"), { recursive: true });
269
+ await fs.writeFile(
270
+ path.join(tmp, "apps", "admin-web", "package.json"),
271
+ JSON.stringify({ name: "admin-web", dependencies: { vue: "^3.0.0" } }, null, 2) + "\n",
272
+ "utf8"
273
+ );
274
+
275
+ await fs.mkdir(path.join(tmp, "apps", "miniapp"), { recursive: true });
276
+ await fs.writeFile(path.join(tmp, "apps", "miniapp", "project.config.json"), "{\n}\n", "utf8");
277
+
278
+ const r = await runNode([rmemoBin, "--root", tmp, "--no-git", "scan"]);
279
+ assert.equal(r.code, 0, r.err || r.out);
280
+
281
+ const manifestPath = path.join(tmp, ".repo-memory", "manifest.json");
282
+ const manifest = JSON.parse(await fs.readFile(manifestPath, "utf8"));
283
+ assert.ok(manifest.monorepo, "manifest should include monorepo");
284
+ assert.ok(Array.isArray(manifest.monorepo.signals), "monorepo.signals should be array");
285
+ assert.ok(manifest.monorepo.signals.includes("pnpm-workspace"), "should detect pnpm-workspace.yaml");
286
+ assert.ok(Array.isArray(manifest.subprojects), "manifest should include subprojects");
287
+ assert.ok(manifest.subprojects.some((p) => p.dir === "apps/admin-web"), "should detect apps/admin-web subproject");
288
+ assert.ok(manifest.subprojects.some((p) => p.dir === "apps/miniapp"), "should detect apps/miniapp subproject");
289
+ });
290
+
291
+ test("rmemo start runs scan+context and prints status", async () => {
292
+ const rmemoBin = path.resolve("bin/rmemo.js");
293
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "rmemo-start-"));
294
+
295
+ await fs.writeFile(path.join(tmp, "README.md"), "# Demo\n", "utf8");
296
+ await fs.writeFile(path.join(tmp, "package.json"), JSON.stringify({ name: "demo" }, null, 2) + "\n", "utf8");
297
+
298
+ const r = await runNode([rmemoBin, "--root", tmp, "--no-git", "start"]);
299
+ assert.equal(r.code, 0, r.err || r.out);
300
+ assert.ok(r.out.includes("# Status"), "start should print status");
301
+ assert.ok(await exists(path.join(tmp, ".repo-memory", "manifest.json")), true);
302
+ assert.ok(await exists(path.join(tmp, ".repo-memory", "context.md")), true);
303
+ });
304
+
305
+ test("rmemo done appends journal and can update todos (args and stdin)", async () => {
306
+ const rmemoBin = path.resolve("bin/rmemo.js");
307
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "rmemo-done-"));
308
+
309
+ {
310
+ const r = await runNode([rmemoBin, "--root", tmp, "--no-git", "init"]);
311
+ assert.equal(r.code, 0, r.err || r.out);
312
+ }
313
+
314
+ {
315
+ const r = await runNode([
316
+ rmemoBin,
317
+ "--root",
318
+ tmp,
319
+ "done",
320
+ "--next",
321
+ "Tomorrow: do Z",
322
+ "--blocker",
323
+ "Waiting for API",
324
+ "Today: did X"
325
+ ]);
326
+ assert.equal(r.code, 0, r.err || r.out);
327
+ }
328
+
329
+ const journalDir = path.join(tmp, ".repo-memory", "journal");
330
+ const jf = (await fs.readdir(journalDir)).sort().pop();
331
+ const jText = await fs.readFile(path.join(journalDir, jf), "utf8");
332
+ assert.ok(jText.includes("Done"), "journal should include Done section");
333
+ assert.ok(jText.includes("Today: did X"), "journal should include note");
334
+
335
+ const todos = await fs.readFile(path.join(tmp, ".repo-memory", "todos.md"), "utf8");
336
+ assert.ok(todos.includes("Tomorrow: do Z"), "todos should include next bullet");
337
+ assert.ok(todos.includes("Waiting for API"), "todos should include blocker bullet");
338
+
339
+ // stdin mode
340
+ {
341
+ const r = await runNodeWithStdin([rmemoBin, "--root", tmp, "done"], "stdin note\n");
342
+ assert.equal(r.code, 0, r.err || r.out);
343
+ }
344
+ });
345
+
346
+ test("rmemo todo add/block/ls updates todos file", async () => {
347
+ const rmemoBin = path.resolve("bin/rmemo.js");
348
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "rmemo-todo-"));
349
+
350
+ // Create without init: todo commands should still create todos.md.
351
+ {
352
+ const r = await runNode([rmemoBin, "--root", tmp, "todo", "add", "Do A"]);
353
+ assert.equal(r.code, 0, r.err || r.out);
354
+ }
355
+ {
356
+ const r = await runNode([rmemoBin, "--root", tmp, "todo", "add", "Do C"]);
357
+ assert.equal(r.code, 0, r.err || r.out);
358
+ }
359
+ {
360
+ const r = await runNode([rmemoBin, "--root", tmp, "todo", "block", "Blocked on B"]);
361
+ assert.equal(r.code, 0, r.err || r.out);
362
+ }
363
+ {
364
+ const r = await runNode([rmemoBin, "--root", tmp, "todo", "block", "Blocked on D"]);
365
+ assert.equal(r.code, 0, r.err || r.out);
366
+ }
367
+ {
368
+ const r = await runNode([rmemoBin, "--root", tmp, "todo", "ls"]);
369
+ assert.equal(r.code, 0, r.err || r.out);
370
+ assert.ok(r.out.includes("## Next"));
371
+ assert.ok(r.out.includes("Do A"));
372
+ assert.ok(r.out.includes("Do C"));
373
+ assert.ok(r.out.includes("## Blockers"));
374
+ assert.ok(r.out.includes("Blocked on B"));
375
+ assert.ok(r.out.includes("Blocked on D"));
376
+ }
377
+
378
+ // Remove items by index
379
+ {
380
+ const r = await runNode([rmemoBin, "--root", tmp, "todo", "done", "1"]);
381
+ assert.equal(r.code, 0, r.err || r.out);
382
+ }
383
+ {
384
+ const r = await runNode([rmemoBin, "--root", tmp, "todo", "unblock", "2"]);
385
+ assert.equal(r.code, 0, r.err || r.out);
386
+ }
387
+ {
388
+ const r = await runNode([rmemoBin, "--root", tmp, "todo", "ls"]);
389
+ assert.equal(r.code, 0, r.err || r.out);
390
+ assert.ok(!r.out.includes("Do A"), "removed next item should be gone");
391
+ assert.ok(r.out.includes("Do C"), "remaining next item should exist");
392
+ assert.ok(r.out.includes("Blocked on B"), "remaining blocker should exist");
393
+ assert.ok(!r.out.includes("Blocked on D"), "removed blocker should be gone");
394
+ }
395
+ });