@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,396 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { fileExists, readJson } from "../lib/io.js";
4
+ import { hasGit, isGitRepo, listGitFiles } from "../lib/git.js";
5
+ import { walkFiles } from "../lib/walk.js";
6
+ import { rulesJsonPath } from "../lib/paths.js";
7
+ import { execFile } from "node:child_process";
8
+ import { promisify } from "node:util";
9
+
10
+ const execFileAsync = promisify(execFile);
11
+
12
+ const PATTERN_CACHE = new Map();
13
+ const CONTENT_MATCH_CACHE = new Map();
14
+
15
+ function toPosix(p) {
16
+ return p.split(path.sep).join("/");
17
+ }
18
+
19
+ function isProbablyBinary(buf) {
20
+ // NUL byte is a strong indicator.
21
+ for (let i = 0; i < buf.length; i++) {
22
+ if (buf[i] === 0) return true;
23
+ }
24
+ return false;
25
+ }
26
+
27
+ async function readFileHeadBytes(absPath, maxBytes) {
28
+ const fh = await fs.open(absPath, "r");
29
+ try {
30
+ const st = await fh.stat();
31
+ const n = Math.max(0, Math.min(Number(maxBytes) || 0, st.size));
32
+ const buf = Buffer.allocUnsafe(n);
33
+ const { bytesRead } = await fh.read(buf, 0, n, 0);
34
+ return bytesRead === n ? buf : buf.subarray(0, bytesRead);
35
+ } finally {
36
+ await fh.close();
37
+ }
38
+ }
39
+
40
+ function globToRegExp(glob) {
41
+ // Minimal glob matcher:
42
+ // - ** matches across path segments
43
+ // - **/ matches zero or more directories (so "**/*.txt" matches "a.txt" too)
44
+ // - * matches within a segment
45
+ // - ? matches a single char within a segment
46
+ const s = String(glob);
47
+ let out = "^";
48
+ for (let i = 0; i < s.length; ) {
49
+ const ch = s[i];
50
+ if (ch === "*") {
51
+ if (s[i + 1] === "*") {
52
+ if (s[i + 2] === "/") {
53
+ out += "(?:.*/)?";
54
+ i += 3;
55
+ } else {
56
+ out += ".*";
57
+ i += 2;
58
+ }
59
+ continue;
60
+ }
61
+ out += "[^/]*";
62
+ i += 1;
63
+ continue;
64
+ }
65
+ if (ch === "?") {
66
+ out += "[^/]";
67
+ i += 1;
68
+ continue;
69
+ }
70
+ // Escape regex special chars, but keep "/" literal.
71
+ if ("\\.[]{}()+-^$|".includes(ch)) out += "\\" + ch;
72
+ else out += ch;
73
+ i += 1;
74
+ }
75
+ out += "$";
76
+ return new RegExp(out);
77
+ }
78
+
79
+ function compilePattern(pat) {
80
+ const s = String(pat);
81
+ const cached = PATTERN_CACHE.get(s);
82
+ if (cached) return cached;
83
+ if (s.startsWith("re:")) {
84
+ const re = new RegExp(s.slice(3));
85
+ PATTERN_CACHE.set(s, re);
86
+ return re;
87
+ }
88
+ if (s.startsWith("/") && s.lastIndexOf("/") > 0) {
89
+ // Support "/.../i" style
90
+ const last = s.lastIndexOf("/");
91
+ const body = s.slice(1, last);
92
+ const flags = s.slice(last + 1);
93
+ try {
94
+ const re = new RegExp(body, flags);
95
+ PATTERN_CACHE.set(s, re);
96
+ return re;
97
+ } catch {
98
+ // fallthrough to glob
99
+ }
100
+ }
101
+ const re = globToRegExp(s);
102
+ PATTERN_CACHE.set(s, re);
103
+ return re;
104
+ }
105
+
106
+ function escapeRegExpLiteral(s) {
107
+ return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
108
+ }
109
+
110
+ function compileContentMatcher(match) {
111
+ const s = String(match ?? "");
112
+ if (!s) return null;
113
+ const cached = CONTENT_MATCH_CACHE.get(s);
114
+ if (cached) return cached;
115
+ if (s.startsWith("re:")) {
116
+ const re = new RegExp(s.slice(3));
117
+ CONTENT_MATCH_CACHE.set(s, re);
118
+ return re;
119
+ }
120
+ if (s.startsWith("/") && s.lastIndexOf("/") > 0) {
121
+ const last = s.lastIndexOf("/");
122
+ const body = s.slice(1, last);
123
+ const flags = s.slice(last + 1);
124
+ const re = new RegExp(body, flags);
125
+ CONTENT_MATCH_CACHE.set(s, re);
126
+ return re;
127
+ }
128
+ // Default: treat as literal substring.
129
+ const re = new RegExp(escapeRegExpLiteral(s), "g");
130
+ CONTENT_MATCH_CACHE.set(s, re);
131
+ return re;
132
+ }
133
+
134
+ function matchAny(str, patterns) {
135
+ if (!patterns || !patterns.length) return false;
136
+ for (const p of patterns) {
137
+ const re = compilePattern(p);
138
+ if (re.test(str)) return true;
139
+ }
140
+ return false;
141
+ }
142
+
143
+ async function getFileList(root, { preferGit = true, maxFiles = 4000 } = {}) {
144
+ // Do not rely on `.repo-memory/index.json` here: it can be stale if files changed
145
+ // after the last `scan`. A check must reflect the current working tree.
146
+ const gitOk = preferGit && (await hasGit()) && (await isGitRepo(root));
147
+ if (gitOk) {
148
+ const files = await listGitFiles(root);
149
+ return files.slice(0, maxFiles);
150
+ }
151
+ const files = await walkFiles(root, { maxFiles });
152
+ return files.slice(0, maxFiles);
153
+ }
154
+
155
+ async function getStagedFiles(root, { maxFiles = 4000 } = {}) {
156
+ // Requires git repo.
157
+ const { stdout } = await execFileAsync("git", ["diff", "--cached", "--name-only", "-z"], {
158
+ cwd: root,
159
+ maxBuffer: 1024 * 1024 * 20
160
+ });
161
+ const files = stdout.split("\0").filter(Boolean);
162
+ // git returns posix separators already.
163
+ return files.slice(0, maxFiles);
164
+ }
165
+
166
+ async function readStagedFileHeadBytes(root, relPosixPath, maxBytes) {
167
+ // Reads file content from the git index (staged version), not from the working tree.
168
+ // Note: git doesn't support partial reads here; we cap via maxBuffer and then slice.
169
+ const mb = Number(maxBytes) || 0;
170
+ const maxBuffer = Math.max(1024 * 1024, mb + 64 * 1024);
171
+ const { stdout } = await execFileAsync("git", ["show", `:${relPosixPath}`], {
172
+ cwd: root,
173
+ encoding: "buffer",
174
+ maxBuffer
175
+ });
176
+ const buf = Buffer.isBuffer(stdout) ? stdout : Buffer.from(stdout);
177
+ return mb > 0 && buf.byteLength > mb ? buf.subarray(0, mb) : buf;
178
+ }
179
+
180
+ async function existsRel(root, rel) {
181
+ try {
182
+ await fs.access(path.join(root, rel));
183
+ return true;
184
+ } catch {
185
+ return false;
186
+ }
187
+ }
188
+
189
+ function validateRulesShape(rules) {
190
+ if (!rules || typeof rules !== "object") return "rules.json must be an object";
191
+ if (rules.schema !== 1) return "rules.json schema must be 1";
192
+ if (rules.forbiddenPaths && !Array.isArray(rules.forbiddenPaths)) return "forbiddenPaths must be an array";
193
+ if (rules.requiredPaths && !Array.isArray(rules.requiredPaths)) return "requiredPaths must be an array";
194
+ if (rules.forbiddenContent && !Array.isArray(rules.forbiddenContent)) return "forbiddenContent must be an array";
195
+ if (rules.namingRules && !Array.isArray(rules.namingRules)) return "namingRules must be an array";
196
+ return null;
197
+ }
198
+
199
+ export async function runCheck(root, { maxFiles = 4000, preferGit = true, stagedOnly = false } = {}) {
200
+ if (!(await fileExists(rulesJsonPath(root)))) {
201
+ return {
202
+ ok: false,
203
+ exitCode: 2,
204
+ errors: [`Missing ${toPosix(rulesJsonPath(root))}. Run: rmemo init (or create rules.json manually).`],
205
+ violations: []
206
+ };
207
+ }
208
+
209
+ let rules;
210
+ try {
211
+ rules = await readJson(rulesJsonPath(root));
212
+ } catch (e) {
213
+ return {
214
+ ok: false,
215
+ exitCode: 2,
216
+ errors: [`Failed to parse rules.json: ${e?.message || String(e)}`],
217
+ violations: []
218
+ };
219
+ }
220
+
221
+ const shapeErr = validateRulesShape(rules);
222
+ if (shapeErr) {
223
+ return { ok: false, exitCode: 2, errors: [shapeErr], violations: [] };
224
+ }
225
+
226
+ let files = [];
227
+ if (stagedOnly) {
228
+ const gitOk = (await hasGit()) && (await isGitRepo(root));
229
+ if (!gitOk) {
230
+ return {
231
+ ok: false,
232
+ exitCode: 2,
233
+ errors: ["--staged requires a git repository"],
234
+ violations: []
235
+ };
236
+ }
237
+ files = (await getStagedFiles(root, { maxFiles })).map(toPosix);
238
+ } else {
239
+ files = (await getFileList(root, { preferGit, maxFiles })).map(toPosix);
240
+ }
241
+
242
+ const violations = [];
243
+ const errors = [];
244
+
245
+ // requiredPaths
246
+ if (rules.requiredPaths?.length) {
247
+ // requiredPaths should validate the repo, not just staged files.
248
+ // Even in stagedOnly mode, we check existence on disk.
249
+ for (const rel of rules.requiredPaths) {
250
+ const relPosix = toPosix(rel);
251
+ // Allow glob in required paths.
252
+ if (String(relPosix).includes("*") || String(relPosix).includes("?") || String(relPosix).startsWith("re:") || String(relPosix).startsWith("/")) {
253
+ // In stagedOnly mode we still want to check against the whole repo file list
254
+ // to avoid false failures.
255
+ const universe = stagedOnly ? (await getFileList(root, { preferGit, maxFiles })).map(toPosix) : files;
256
+ const ok = universe.some((f) => compilePattern(relPosix).test(f));
257
+ if (!ok) violations.push({ type: "required", pattern: relPosix, message: `Missing required path match: ${relPosix}` });
258
+ } else {
259
+ const ok = await existsRel(root, relPosix);
260
+ if (!ok) violations.push({ type: "required", path: relPosix, message: `Missing required path: ${relPosix}` });
261
+ }
262
+ }
263
+ }
264
+
265
+ // forbiddenPaths
266
+ if (rules.forbiddenPaths?.length) {
267
+ for (const pat of rules.forbiddenPaths) {
268
+ const p = toPosix(pat);
269
+ const re = compilePattern(p);
270
+ const hit = files.find((f) => re.test(f) || (p.endsWith("/") && f.startsWith(p)));
271
+ if (hit) violations.push({ type: "forbidden", pattern: p, file: hit, message: `Forbidden path matched: ${p} (hit: ${hit})` });
272
+ }
273
+ }
274
+
275
+ // namingRules
276
+ if (rules.namingRules?.length) {
277
+ for (const rule of rules.namingRules) {
278
+ if (!rule || typeof rule !== "object") {
279
+ errors.push("namingRules entries must be objects");
280
+ continue;
281
+ }
282
+ const include = Array.isArray(rule.include) ? rule.include.map(toPosix) : rule.include ? [toPosix(rule.include)] : [];
283
+ const exclude = Array.isArray(rule.exclude) ? rule.exclude.map(toPosix) : rule.exclude ? [toPosix(rule.exclude)] : [];
284
+ const target = rule.target === "path" ? "path" : "basename";
285
+ const match = rule.match ? String(rule.match) : null;
286
+ if (!include.length || !match) {
287
+ errors.push("namingRules entries must have include and match");
288
+ continue;
289
+ }
290
+ const matchRe = compilePattern(match.startsWith("re:") || (match.startsWith("/") && match.lastIndexOf("/") > 0) ? match : "re:" + match);
291
+
292
+ for (const f of files) {
293
+ if (!matchAny(f, include)) continue;
294
+ if (exclude.length && matchAny(f, exclude)) continue;
295
+
296
+ const v = target === "path" ? f : path.posix.basename(f);
297
+ if (!matchRe.test(v)) {
298
+ violations.push({
299
+ type: "naming",
300
+ file: f,
301
+ rule: { include, exclude, target, match },
302
+ message: rule.message || `Naming rule violated for ${f} (target: ${target}, match: ${match})`
303
+ });
304
+ }
305
+ }
306
+ }
307
+ }
308
+
309
+ // forbiddenContent
310
+ if (rules.forbiddenContent?.length) {
311
+ const SKIP_EXT = new Set([
312
+ "png",
313
+ "jpg",
314
+ "jpeg",
315
+ "gif",
316
+ "webp",
317
+ "ico",
318
+ "svg",
319
+ "pdf",
320
+ "zip",
321
+ "gz",
322
+ "tgz",
323
+ "bz2",
324
+ "7z",
325
+ "rar",
326
+ "dmg",
327
+ "exe",
328
+ "bin",
329
+ "woff",
330
+ "woff2",
331
+ "ttf",
332
+ "otf",
333
+ "mp3",
334
+ "mp4",
335
+ "mov",
336
+ "avi"
337
+ ]);
338
+
339
+ for (const rule of rules.forbiddenContent) {
340
+ if (!rule || typeof rule !== "object") {
341
+ errors.push("forbiddenContent entries must be objects");
342
+ continue;
343
+ }
344
+ const include = Array.isArray(rule.include) ? rule.include.map(toPosix) : rule.include ? [toPosix(rule.include)] : ["**/*"];
345
+ const exclude = Array.isArray(rule.exclude) ? rule.exclude.map(toPosix) : rule.exclude ? [toPosix(rule.exclude)] : [];
346
+ const maxBytes = rule.maxBytes ? Number(rule.maxBytes) : 1_000_000;
347
+ const match = rule.match ? String(rule.match) : "";
348
+ const message = rule.message ? String(rule.message) : null;
349
+
350
+ const re = compileContentMatcher(match);
351
+ if (!re) {
352
+ errors.push("forbiddenContent entries must have match");
353
+ continue;
354
+ }
355
+
356
+ for (const f of files) {
357
+ if (!matchAny(f, include)) continue;
358
+ if (exclude.length && matchAny(f, exclude)) continue;
359
+
360
+ const ext = path.posix.extname(f).slice(1).toLowerCase();
361
+ if (ext && SKIP_EXT.has(ext)) continue;
362
+
363
+ let buf = null;
364
+ try {
365
+ buf = stagedOnly
366
+ ? await readStagedFileHeadBytes(root, f, maxBytes)
367
+ : await readFileHeadBytes(path.join(root, f), maxBytes);
368
+ } catch {
369
+ // Staged file might be deleted/renamed; ignore.
370
+ continue;
371
+ }
372
+ if (!buf || buf.byteLength === 0) continue;
373
+ if (isProbablyBinary(buf.subarray(0, Math.min(buf.byteLength, 4096)))) continue;
374
+
375
+ const text = buf.toString("utf8");
376
+ re.lastIndex = 0;
377
+ if (re.test(text)) {
378
+ // Don't print the matched content, only the file and rule.
379
+ violations.push({
380
+ type: "forbidden-content",
381
+ file: f,
382
+ rule: { include, exclude, match },
383
+ message: message || `Forbidden content matched in ${f} (match: ${match})`
384
+ });
385
+ }
386
+ }
387
+ }
388
+ }
389
+
390
+ if (errors.length) {
391
+ return { ok: false, exitCode: 2, errors, violations };
392
+ }
393
+
394
+ const ok = violations.length === 0;
395
+ return { ok, exitCode: ok ? 0 : 1, errors: [], violations };
396
+ }
@@ -0,0 +1,114 @@
1
+ import path from "node:path";
2
+ import fs from "node:fs/promises";
3
+ import { fileExists, readText } from "../lib/io.js";
4
+ import { contextPath, indexPath, journalDir, manifestPath, rulesPath, todosPath } from "../lib/paths.js";
5
+ import { todayYmd } from "../lib/time.js";
6
+
7
+ function clampLines(s, maxLines) {
8
+ const lines = s.split("\n");
9
+ if (lines.length <= maxLines) return s.trimEnd();
10
+ return lines.slice(0, maxLines).join("\n").trimEnd() + "\n[...truncated]";
11
+ }
12
+
13
+ async function readMaybe(p, maxBytes) {
14
+ try {
15
+ if (!(await fileExists(p))) return null;
16
+ return await readText(p, maxBytes);
17
+ } catch {
18
+ return null;
19
+ }
20
+ }
21
+
22
+ async function listRecentJournals(root, recentDays) {
23
+ const dir = journalDir(root);
24
+ try {
25
+ const ents = await fs.readdir(dir, { withFileTypes: true });
26
+ const files = ents
27
+ .filter((e) => e.isFile() && e.name.endsWith(".md"))
28
+ .map((e) => e.name)
29
+ .sort()
30
+ .reverse();
31
+ return files.slice(0, Math.max(0, recentDays));
32
+ } catch {
33
+ return [];
34
+ }
35
+ }
36
+
37
+ function formatJsonBlock(title, obj) {
38
+ return `## ${title}\n\n\`\`\`json\n${JSON.stringify(obj, null, 2)}\n\`\`\`\n`;
39
+ }
40
+
41
+ export async function generateContext(root, { snipLines = 120, recentDays = 7 } = {}) {
42
+ const manifest = await readMaybe(manifestPath(root), 2_000_000);
43
+ const rules = await readMaybe(rulesPath(root), 512_000);
44
+ const todos = await readMaybe(todosPath(root), 256_000);
45
+ const index = await readMaybe(indexPath(root), 5_000_000);
46
+
47
+ const ctxParts = [];
48
+ ctxParts.push(`# Repo Context Pack\n`);
49
+ ctxParts.push(`Generated: ${new Date().toISOString()}\n`);
50
+ ctxParts.push(`Root: ${root}\n`);
51
+ ctxParts.push(`Today: ${todayYmd()}\n`);
52
+
53
+ ctxParts.push(`## How To Use This\n`);
54
+ ctxParts.push(
55
+ [
56
+ "- Read Rules first. Follow them strictly.",
57
+ "- Then read Manifest and Key Files list to understand structure.",
58
+ "- Then read Recent Journal to continue work without re-discovery."
59
+ ].join("\n") + "\n"
60
+ );
61
+
62
+ if (rules) {
63
+ ctxParts.push(`## Rules\n\n` + clampLines(rules, snipLines) + "\n");
64
+ } else {
65
+ ctxParts.push(`## Rules\n\n(No rules yet. Run: rmemo init)\n`);
66
+ }
67
+
68
+ if (manifest) {
69
+ try {
70
+ ctxParts.push(formatJsonBlock("Manifest", JSON.parse(manifest)));
71
+ } catch {
72
+ ctxParts.push(`## Manifest\n\n` + clampLines(manifest, snipLines) + "\n");
73
+ }
74
+ }
75
+
76
+ if (todos) {
77
+ ctxParts.push(`## Todos\n\n` + clampLines(todos, snipLines) + "\n");
78
+ }
79
+
80
+ if (index) {
81
+ try {
82
+ const idx = JSON.parse(index);
83
+ const files = Array.isArray(idx.files) ? idx.files : [];
84
+ ctxParts.push(`## File Index (Top)\n\n`);
85
+ ctxParts.push("```text\n" + files.slice(0, 300).join("\n") + "\n```" + "\n");
86
+ if (files.length > 300) ctxParts.push(`(Index truncated. Total files in index: ${files.length})\n`);
87
+ } catch {
88
+ // ignore
89
+ }
90
+ }
91
+
92
+ const recent = await listRecentJournals(root, recentDays);
93
+ if (recent.length) {
94
+ ctxParts.push(`## Recent Journal\n`);
95
+ for (const fn of recent) {
96
+ const jp = path.join(journalDir(root), fn);
97
+ const s = await readMaybe(jp, 512_000);
98
+ if (!s) continue;
99
+ ctxParts.push(`### ${fn}\n\n` + clampLines(s, snipLines) + "\n");
100
+ }
101
+ }
102
+
103
+ return ctxParts.join("\n").trimEnd() + "\n";
104
+ }
105
+
106
+ export async function ensureContextFile(root, opts) {
107
+ const p = contextPath(root);
108
+ if (await fileExists(p)) return p;
109
+ const s = await generateContext(root, opts);
110
+ await fs.mkdir(path.dirname(p), { recursive: true });
111
+ await fs.writeFile(p, s, "utf8");
112
+ return p;
113
+ }
114
+
@@ -0,0 +1,31 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { ensureDir, fileExists } from "../lib/io.js";
4
+ import { journalDir, memDir } from "../lib/paths.js";
5
+ import { nowHm, todayYmd } from "../lib/time.js";
6
+
7
+ function formatEntry({ kind, text, stamp }) {
8
+ const body = String(text || "").trimEnd();
9
+ const normalized = body.includes("\n") ? `\n\n${body}\n` : `${body}\n`;
10
+ return `\n## ${stamp} ${kind}\n${normalized}`;
11
+ }
12
+
13
+ export async function appendJournalEntry(root, { kind, text, date = todayYmd(), stamp = nowHm() }) {
14
+ await ensureDir(memDir(root));
15
+ await ensureDir(journalDir(root));
16
+
17
+ const fn = `${date}.md`;
18
+ const p = path.join(journalDir(root), fn);
19
+
20
+ const entry = formatEntry({ kind, text, stamp });
21
+
22
+ if (await fileExists(p)) {
23
+ await fs.appendFile(p, entry, "utf8");
24
+ } else {
25
+ const head = `# Journal ${date}\n`;
26
+ await fs.writeFile(p, head + entry, "utf8");
27
+ }
28
+
29
+ return p;
30
+ }
31
+