ralph-hero-knowledge-index 0.1.21 → 0.1.23

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 (49) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.mcp.json +1 -1
  3. package/README.md +109 -0
  4. package/dist/config.d.ts +32 -0
  5. package/dist/config.js +75 -0
  6. package/dist/config.js.map +1 -0
  7. package/dist/db.d.ts +7 -0
  8. package/dist/db.js +17 -0
  9. package/dist/db.js.map +1 -1
  10. package/dist/file-scanner.d.ts +13 -1
  11. package/dist/file-scanner.js +30 -3
  12. package/dist/file-scanner.js.map +1 -1
  13. package/dist/hybrid-search.d.ts +12 -0
  14. package/dist/hybrid-search.js +74 -5
  15. package/dist/hybrid-search.js.map +1 -1
  16. package/dist/ignore.d.ts +29 -0
  17. package/dist/ignore.js +65 -0
  18. package/dist/ignore.js.map +1 -0
  19. package/dist/index.d.ts +9 -1
  20. package/dist/index.js +166 -6
  21. package/dist/index.js.map +1 -1
  22. package/dist/llm-client.d.ts +41 -0
  23. package/dist/llm-client.js +98 -0
  24. package/dist/llm-client.js.map +1 -0
  25. package/dist/reindex.d.ts +22 -3
  26. package/dist/reindex.js +60 -8
  27. package/dist/reindex.js.map +1 -1
  28. package/dist/search.d.ts +12 -0
  29. package/dist/search.js +15 -1
  30. package/dist/search.js.map +1 -1
  31. package/package.json +2 -1
  32. package/src/__tests__/config.test.ts +173 -0
  33. package/src/__tests__/file-scanner.test.ts +88 -0
  34. package/src/__tests__/hybrid-search.test.ts +107 -0
  35. package/src/__tests__/ignore.test.ts +86 -0
  36. package/src/__tests__/index.test.ts +450 -0
  37. package/src/__tests__/llm-client.test.ts +349 -0
  38. package/src/__tests__/memory-stats.test.ts +204 -0
  39. package/src/__tests__/reindex.test.ts +148 -2
  40. package/src/__tests__/search.test.ts +37 -0
  41. package/src/config.ts +105 -0
  42. package/src/db.ts +17 -0
  43. package/src/file-scanner.ts +28 -3
  44. package/src/hybrid-search.ts +88 -5
  45. package/src/ignore.ts +82 -0
  46. package/src/index.ts +202 -7
  47. package/src/llm-client.ts +136 -0
  48. package/src/reindex.ts +80 -9
  49. package/src/search.ts +27 -1
package/dist/search.js CHANGED
@@ -66,8 +66,18 @@ export class FtsSearch {
66
66
  return '""';
67
67
  return tokens.map(t => '"' + t.replace(/"/g, '""') + '"').join(" ");
68
68
  }
69
+ /**
70
+ * Returns true when the `documents.memory_tier` column exists (schema v3+).
71
+ * On v2 schemas this is false and the memoryTier filter is silently ignored.
72
+ */
73
+ memoryTierColumnExists() {
74
+ const rows = this.db.db
75
+ .prepare("PRAGMA table_info(documents)")
76
+ .all();
77
+ return rows.some((r) => r.name === "memory_tier");
78
+ }
69
79
  search(query, options = {}) {
70
- const { type, tags, includeSuperseded = false, limit = 20 } = options;
80
+ const { type, tags, includeSuperseded = false, limit = 20, memoryTier } = options;
71
81
  const conditions = ["documents_fts MATCH @query"];
72
82
  const params = { query: this.escapeFts5Query(query), limit };
73
83
  if (!includeSuperseded) {
@@ -77,6 +87,10 @@ export class FtsSearch {
77
87
  conditions.push("d.type = @type");
78
88
  params.type = type;
79
89
  }
90
+ if (memoryTier && memoryTier !== "any" && this.memoryTierColumnExists()) {
91
+ conditions.push("d.memory_tier = @memoryTier");
92
+ params.memoryTier = memoryTier;
93
+ }
80
94
  let joinClause = "";
81
95
  if (tags && tags.length > 0) {
82
96
  joinClause = "JOIN tags t ON t.doc_id = d.id";
@@ -1 +1 @@
1
- {"version":3,"file":"search.js","sourceRoot":"","sources":["../src/search.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"search.js","sourceRoot":"","sources":["../src/search.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ralph-hero-knowledge-index",
3
- "version": "0.1.21",
3
+ "version": "0.1.23",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -31,6 +31,7 @@
31
31
  "graphology-simple-path": "^0.2.0",
32
32
  "graphology-traversal": "^0.3.1",
33
33
  "graphology-types": "^0.24.8",
34
+ "ignore": "^5.3.2",
34
35
  "sqlite-vec": "^0.1.7-alpha.10",
35
36
  "yaml": "^2.7.0",
36
37
  "zod": "^3.25.0"
@@ -0,0 +1,173 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import { mkdtempSync, writeFileSync, mkdirSync, existsSync, rmSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir, homedir } from "node:os";
5
+ import { loadConfig, expandHome, resolveConfigPath } from "../config.js";
6
+
7
+ describe("expandHome", () => {
8
+ it("returns input unchanged when it does not start with ~", () => {
9
+ expect(expandHome("/absolute/path")).toBe("/absolute/path");
10
+ expect(expandHome("relative/path")).toBe("relative/path");
11
+ expect(expandHome("")).toBe("");
12
+ });
13
+
14
+ it("expands a lone ~", () => {
15
+ expect(expandHome("~")).toBe(homedir());
16
+ });
17
+
18
+ it("expands ~/ prefix to homedir/rest", () => {
19
+ expect(expandHome("~/thoughts")).toBe(join(homedir(), "thoughts"));
20
+ expect(expandHome("~/foo/bar")).toBe(join(homedir(), "foo/bar"));
21
+ });
22
+ });
23
+
24
+ describe("loadConfig", () => {
25
+ let originalEnv: string | undefined;
26
+ let tmpDir: string;
27
+
28
+ beforeEach(() => {
29
+ originalEnv = process.env.RALPH_KNOWLEDGE_CONFIG;
30
+ tmpDir = mkdtempSync(join(tmpdir(), "ralph-config-"));
31
+ });
32
+
33
+ afterEach(() => {
34
+ if (originalEnv === undefined) {
35
+ delete process.env.RALPH_KNOWLEDGE_CONFIG;
36
+ } else {
37
+ process.env.RALPH_KNOWLEDGE_CONFIG = originalEnv;
38
+ }
39
+ if (existsSync(tmpDir)) {
40
+ rmSync(tmpDir, { recursive: true, force: true });
41
+ }
42
+ });
43
+
44
+ it("returns {} when the config file is missing", () => {
45
+ // Point env var at a nonexistent path so we don't read the real ~/.ralph file.
46
+ process.env.RALPH_KNOWLEDGE_CONFIG = join(tmpDir, "nope.json");
47
+ expect(loadConfig()).toEqual({});
48
+ });
49
+
50
+ it("returns {} and warns on malformed JSON", () => {
51
+ const configPath = join(tmpDir, "broken.json");
52
+ writeFileSync(configPath, "{ not: valid json");
53
+ process.env.RALPH_KNOWLEDGE_CONFIG = configPath;
54
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
55
+ const result = loadConfig();
56
+ expect(result).toEqual({});
57
+ expect(warn).toHaveBeenCalledTimes(1);
58
+ const msg = warn.mock.calls[0][0] as string;
59
+ expect(msg).toContain("Malformed JSON");
60
+ warn.mockRestore();
61
+ });
62
+
63
+ it("returns {} and warns when top-level is not an object", () => {
64
+ const configPath = join(tmpDir, "array.json");
65
+ writeFileSync(configPath, JSON.stringify(["not", "an", "object"]));
66
+ process.env.RALPH_KNOWLEDGE_CONFIG = configPath;
67
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
68
+ expect(loadConfig()).toEqual({});
69
+ expect(warn).toHaveBeenCalledTimes(1);
70
+ warn.mockRestore();
71
+ });
72
+
73
+ it("expands ~ prefixes in roots[] to absolute paths", () => {
74
+ const configPath = join(tmpDir, "tilde.json");
75
+ writeFileSync(
76
+ configPath,
77
+ JSON.stringify({ roots: ["~/thoughts", "/absolute/dir"] }),
78
+ );
79
+ process.env.RALPH_KNOWLEDGE_CONFIG = configPath;
80
+ const cfg = loadConfig();
81
+ expect(cfg.roots).toEqual([
82
+ join(homedir(), "thoughts"),
83
+ "/absolute/dir",
84
+ ]);
85
+ });
86
+
87
+ it("expands ~ in dbPath", () => {
88
+ const configPath = join(tmpDir, "db.json");
89
+ writeFileSync(
90
+ configPath,
91
+ JSON.stringify({ dbPath: "~/.ralph-hero/knowledge.db" }),
92
+ );
93
+ process.env.RALPH_KNOWLEDGE_CONFIG = configPath;
94
+ const cfg = loadConfig();
95
+ expect(cfg.dbPath).toBe(join(homedir(), ".ralph-hero/knowledge.db"));
96
+ });
97
+
98
+ it("loads ignorePatterns as provided (no expansion)", () => {
99
+ const configPath = join(tmpDir, "ignore.json");
100
+ writeFileSync(
101
+ configPath,
102
+ JSON.stringify({ ignorePatterns: ["**/drafts/**", "*.bak"] }),
103
+ );
104
+ process.env.RALPH_KNOWLEDGE_CONFIG = configPath;
105
+ const cfg = loadConfig();
106
+ expect(cfg.ignorePatterns).toEqual(["**/drafts/**", "*.bak"]);
107
+ });
108
+
109
+ it("honors RALPH_KNOWLEDGE_CONFIG env var override", () => {
110
+ const configPath = join(tmpDir, "override.json");
111
+ writeFileSync(
112
+ configPath,
113
+ JSON.stringify({ roots: ["/x"], ignorePatterns: ["y/**"], dbPath: "/z.db" }),
114
+ );
115
+ process.env.RALPH_KNOWLEDGE_CONFIG = configPath;
116
+ const cfg = loadConfig();
117
+ expect(cfg).toEqual({
118
+ roots: ["/x"],
119
+ ignorePatterns: ["y/**"],
120
+ dbPath: "/z.db",
121
+ });
122
+ });
123
+
124
+ it("drops non-string roots and ignorePatterns entries", () => {
125
+ const configPath = join(tmpDir, "mixed.json");
126
+ writeFileSync(
127
+ configPath,
128
+ JSON.stringify({
129
+ roots: ["/a", 42, null, "/b"],
130
+ ignorePatterns: ["good", 7, "more"],
131
+ }),
132
+ );
133
+ process.env.RALPH_KNOWLEDGE_CONFIG = configPath;
134
+ const cfg = loadConfig();
135
+ expect(cfg.roots).toEqual(["/a", "/b"]);
136
+ expect(cfg.ignorePatterns).toEqual(["good", "more"]);
137
+ });
138
+ });
139
+
140
+ describe("resolveConfigPath", () => {
141
+ let originalEnv: string | undefined;
142
+
143
+ beforeEach(() => {
144
+ originalEnv = process.env.RALPH_KNOWLEDGE_CONFIG;
145
+ });
146
+
147
+ afterEach(() => {
148
+ if (originalEnv === undefined) {
149
+ delete process.env.RALPH_KNOWLEDGE_CONFIG;
150
+ } else {
151
+ process.env.RALPH_KNOWLEDGE_CONFIG = originalEnv;
152
+ }
153
+ });
154
+
155
+ it("defaults to ~/.ralph/knowledge.config.json when env var is unset", () => {
156
+ delete process.env.RALPH_KNOWLEDGE_CONFIG;
157
+ expect(resolveConfigPath()).toBe(
158
+ join(homedir(), ".ralph", "knowledge.config.json"),
159
+ );
160
+ });
161
+
162
+ it("expands ~ prefix in RALPH_KNOWLEDGE_CONFIG", () => {
163
+ process.env.RALPH_KNOWLEDGE_CONFIG = "~/custom/knowledge.json";
164
+ expect(resolveConfigPath()).toBe(
165
+ join(homedir(), "custom/knowledge.json"),
166
+ );
167
+ });
168
+
169
+ it("uses RALPH_KNOWLEDGE_CONFIG absolute path verbatim", () => {
170
+ process.env.RALPH_KNOWLEDGE_CONFIG = "/etc/ralph/knowledge.json";
171
+ expect(resolveConfigPath()).toBe("/etc/ralph/knowledge.json");
172
+ });
173
+ });
@@ -0,0 +1,88 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { mkdtempSync, writeFileSync, mkdirSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { findMarkdownFiles } from "../file-scanner.js";
6
+ import { loadIgnoreForRoot } from "../ignore.js";
7
+
8
+ describe("findMarkdownFiles with IgnoreMatcher", () => {
9
+ it("excludes files matched by a .ralphignore entry", () => {
10
+ const dir = mkdtempSync(join(tmpdir(), "fs-ignore-"));
11
+ writeFileSync(join(dir, "a.md"), "# A");
12
+ writeFileSync(join(dir, "b.md"), "# B");
13
+ writeFileSync(join(dir, "c.md"), "# C");
14
+ writeFileSync(join(dir, ".ralphignore"), "b.md\n");
15
+
16
+ const matcher = loadIgnoreForRoot(dir);
17
+ const files = findMarkdownFiles(dir, matcher);
18
+ expect(files).toHaveLength(2);
19
+ expect(files.some(f => f.endsWith("a.md"))).toBe(true);
20
+ expect(files.some(f => f.endsWith("c.md"))).toBe(true);
21
+ expect(files.some(f => f.endsWith("b.md"))).toBe(false);
22
+ });
23
+
24
+ it("skips a whole subdirectory covered by subdir/** pattern", () => {
25
+ const dir = mkdtempSync(join(tmpdir(), "fs-ignore-"));
26
+ writeFileSync(join(dir, "keep.md"), "# keep");
27
+ mkdirSync(join(dir, "subdir"));
28
+ writeFileSync(join(dir, "subdir", "skip.md"), "# skip");
29
+ writeFileSync(join(dir, "subdir", "also-skip.md"), "# skip2");
30
+ writeFileSync(join(dir, ".ralphignore"), "subdir/**\n");
31
+
32
+ const matcher = loadIgnoreForRoot(dir);
33
+ const files = findMarkdownFiles(dir, matcher);
34
+ expect(files).toHaveLength(1);
35
+ expect(files[0].endsWith("keep.md")).toBe(true);
36
+ });
37
+
38
+ it("honors caller-supplied global patterns via loadIgnoreForRoot", () => {
39
+ const dir = mkdtempSync(join(tmpdir(), "fs-ignore-"));
40
+ writeFileSync(join(dir, "readme.md"), "# r");
41
+ mkdirSync(join(dir, "drafts"));
42
+ writeFileSync(join(dir, "drafts", "wip.md"), "# wip");
43
+
44
+ const matcher = loadIgnoreForRoot(dir, ["drafts/**"]);
45
+ const files = findMarkdownFiles(dir, matcher);
46
+ expect(files).toHaveLength(1);
47
+ expect(files[0].endsWith("readme.md")).toBe(true);
48
+ });
49
+
50
+ it("preserves back-compat when called without a matcher", () => {
51
+ const dir = mkdtempSync(join(tmpdir(), "fs-ignore-"));
52
+ writeFileSync(join(dir, "a.md"), "# A");
53
+ writeFileSync(join(dir, "b.md"), "# B");
54
+ mkdirSync(join(dir, "nested"));
55
+ writeFileSync(join(dir, "nested", "c.md"), "# C");
56
+
57
+ const files = findMarkdownFiles(dir);
58
+ expect(files).toHaveLength(3);
59
+ expect(files.every(f => f.endsWith(".md"))).toBe(true);
60
+ });
61
+
62
+ it("still skips dot- and underscore-prefixed directories even without a matcher", () => {
63
+ const dir = mkdtempSync(join(tmpdir(), "fs-ignore-"));
64
+ writeFileSync(join(dir, "top.md"), "# top");
65
+ mkdirSync(join(dir, ".hidden"));
66
+ writeFileSync(join(dir, ".hidden", "hidden.md"), "# h");
67
+ mkdirSync(join(dir, "_private"));
68
+ writeFileSync(join(dir, "_private", "private.md"), "# p");
69
+
70
+ const files = findMarkdownFiles(dir);
71
+ expect(files).toHaveLength(1);
72
+ expect(files[0].endsWith("top.md")).toBe(true);
73
+ });
74
+
75
+ it("applies default ignore globals (node_modules/, dist/) via loadIgnoreForRoot", () => {
76
+ const dir = mkdtempSync(join(tmpdir(), "fs-ignore-"));
77
+ writeFileSync(join(dir, "root.md"), "# root");
78
+ mkdirSync(join(dir, "node_modules"));
79
+ writeFileSync(join(dir, "node_modules", "pkg.md"), "# pkg");
80
+ mkdirSync(join(dir, "dist"));
81
+ writeFileSync(join(dir, "dist", "out.md"), "# out");
82
+
83
+ const matcher = loadIgnoreForRoot(dir);
84
+ const files = findMarkdownFiles(dir, matcher);
85
+ expect(files).toHaveLength(1);
86
+ expect(files[0].endsWith("root.md")).toBe(true);
87
+ });
88
+ });
@@ -73,6 +73,34 @@ beforeEach(() => {
73
73
  hybrid = new HybridSearch(db, fts, vec, mockEmbedFn);
74
74
  });
75
75
 
76
+ /**
77
+ * Ensure the v3 schema extensions (memory_tier column, chunks table) exist.
78
+ * Phase 1 (GH-762) owns the production migration; tests add them so Phase 8
79
+ * features can be exercised independently of Phase 1's merge order.
80
+ */
81
+ function ensureV3Schema(targetDb: KnowledgeDB): void {
82
+ const rows = targetDb.db
83
+ .prepare("PRAGMA table_info(documents)")
84
+ .all() as Array<{ name: string }>;
85
+ if (!rows.some((r) => r.name === "memory_tier")) {
86
+ targetDb.db.exec(
87
+ "ALTER TABLE documents ADD COLUMN memory_tier TEXT NOT NULL DEFAULT 'doc' CHECK(memory_tier IN ('doc','raw','reflection'))",
88
+ );
89
+ }
90
+ targetDb.db.exec(
91
+ `CREATE TABLE IF NOT EXISTS chunks (
92
+ id TEXT PRIMARY KEY,
93
+ document_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
94
+ chunk_index INTEGER NOT NULL,
95
+ content TEXT NOT NULL,
96
+ char_start INTEGER NOT NULL,
97
+ char_end INTEGER NOT NULL,
98
+ context_prefix TEXT NOT NULL DEFAULT '',
99
+ UNIQUE(document_id, chunk_index)
100
+ )`,
101
+ );
102
+ }
103
+
76
104
  describe("HybridSearch", () => {
77
105
  it("returns results combining FTS and vector scores", async () => {
78
106
  const results = await hybrid.search("cache");
@@ -110,3 +138,82 @@ describe("HybridSearch", () => {
110
138
  expect(ids).not.toContain("cache-doc");
111
139
  });
112
140
  });
141
+
142
+ describe("HybridSearch memory_tier filter", () => {
143
+ it("filters to reflection when memoryTier='reflection'", async () => {
144
+ // Rebuild fixture with memory_tier column populated
145
+ ensureV3Schema(db);
146
+ db.db.prepare("UPDATE documents SET memory_tier = ? WHERE id = ?").run("doc", "cache-doc");
147
+ db.db.prepare("UPDATE documents SET memory_tier = ? WHERE id = ?").run("reflection", "auth-doc");
148
+ // FTS must be rebuilt to pick up the new column for its JOIN
149
+ fts.rebuildIndex();
150
+
151
+ const results = await hybrid.search("cache OR auth", { memoryTier: "reflection" });
152
+ const ids = results.map((r) => r.id);
153
+ expect(ids).toContain("auth-doc");
154
+ expect(ids).not.toContain("cache-doc");
155
+ });
156
+
157
+ it("returns all tiers when memoryTier='any' (default)", async () => {
158
+ ensureV3Schema(db);
159
+ db.db.prepare("UPDATE documents SET memory_tier = ? WHERE id = ?").run("raw", "cache-doc");
160
+ db.db.prepare("UPDATE documents SET memory_tier = ? WHERE id = ?").run("reflection", "auth-doc");
161
+ fts.rebuildIndex();
162
+
163
+ const results = await hybrid.search("cache OR auth", { memoryTier: "any" });
164
+ const ids = results.map((r) => r.id);
165
+ expect(ids).toContain("cache-doc");
166
+ expect(ids).toContain("auth-doc");
167
+ });
168
+
169
+ it("passes silently on v2 DB where memory_tier column is absent", async () => {
170
+ // Do NOT call ensureV3Schema — simulate v2 schema.
171
+ const results = await hybrid.search("cache", { memoryTier: "reflection" });
172
+ // Schema has no tier info; filter treats all docs as 'doc', so reflection
173
+ // filter drops everything on a v2 DB — no error thrown.
174
+ expect(Array.isArray(results)).toBe(true);
175
+ });
176
+ });
177
+
178
+ describe("HybridSearch chunk metadata enrichment", () => {
179
+ it("populates bestChunkId + chunk meta when vec returns chunk ids", async () => {
180
+ ensureV3Schema(db);
181
+
182
+ // Seed a chunk row for cache-doc and mirror its id in the vec table
183
+ db.db
184
+ .prepare(
185
+ `INSERT INTO chunks (id, document_id, chunk_index, content, char_start, char_end, context_prefix)
186
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
187
+ )
188
+ .run(
189
+ "cache-doc#c0",
190
+ "cache-doc",
191
+ 0,
192
+ "Analysis of cache invalidation patterns...",
193
+ 0,
194
+ 40,
195
+ "Research: cache-invalidation context.",
196
+ );
197
+ // Replace the doc-level vec entry with a chunk-level one.
198
+ vec.deleteEmbedding("cache-doc");
199
+ vec.upsertEmbedding("cache-doc#c0", mockEmbedding(1));
200
+
201
+ const results = await hybrid.search("cache");
202
+ const hit = results.find((r) => r.id === "cache-doc");
203
+ expect(hit).toBeDefined();
204
+ expect(hit!.bestChunkId).toBe("cache-doc#c0");
205
+ expect(hit!.chunkIndex).toBe(0);
206
+ expect(hit!.charStart).toBe(0);
207
+ expect(hit!.charEnd).toBe(40);
208
+ expect(hit!.contextPrefix).toBe("Research: cache-invalidation context.");
209
+ });
210
+
211
+ it("does not populate chunk meta when vec returns doc-level ids", async () => {
212
+ // Fixture as-is: vec stores doc ids, not chunk ids.
213
+ const results = await hybrid.search("cache");
214
+ const hit = results.find((r) => r.id === "cache-doc");
215
+ expect(hit).toBeDefined();
216
+ expect(hit!.bestChunkId).toBeUndefined();
217
+ expect(hit!.chunkIndex).toBeUndefined();
218
+ });
219
+ });
@@ -0,0 +1,86 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { mkdtempSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { loadIgnoreForRoot, DEFAULT_IGNORE_PATTERNS } from "../ignore.js";
6
+
7
+ describe("loadIgnoreForRoot", () => {
8
+ it("applies default globals when no .ralphignore and no caller globals are provided", () => {
9
+ const dir = mkdtempSync(join(tmpdir(), "ralph-ignore-"));
10
+ const matcher = loadIgnoreForRoot(dir);
11
+ expect(matcher.isIgnored("node_modules/foo.js")).toBe(true);
12
+ expect(matcher.isIgnored(".claude/settings.json")).toBe(true);
13
+ expect(matcher.isIgnored("dist/index.js")).toBe(true);
14
+ expect(matcher.isIgnored("debug.log")).toBe(true);
15
+ expect(matcher.isIgnored("thoughts/research/doc.md")).toBe(false);
16
+ });
17
+
18
+ it("honors glob pattern **/node_modules/** against nested paths", () => {
19
+ const dir = mkdtempSync(join(tmpdir(), "ralph-ignore-"));
20
+ writeFileSync(join(dir, ".ralphignore"), "**/node_modules/**\n");
21
+ const matcher = loadIgnoreForRoot(dir);
22
+ expect(matcher.isIgnored("foo/node_modules/bar.js")).toBe(true);
23
+ });
24
+
25
+ it("honors negation that overrides an earlier *.md pattern", () => {
26
+ const dir = mkdtempSync(join(tmpdir(), "ralph-ignore-"));
27
+ writeFileSync(join(dir, ".ralphignore"), "*.md\n!keep-me.md\n");
28
+ const matcher = loadIgnoreForRoot(dir);
29
+ expect(matcher.isIgnored("foo.md")).toBe(true);
30
+ expect(matcher.isIgnored("keep-me.md")).toBe(false);
31
+ });
32
+
33
+ it("treats directory-only patterns as directories", () => {
34
+ const dir = mkdtempSync(join(tmpdir(), "ralph-ignore-"));
35
+ writeFileSync(join(dir, ".ralphignore"), "dist/\n");
36
+ const matcher = loadIgnoreForRoot(dir);
37
+ // `dist/file.js` matches the directory pattern
38
+ expect(matcher.isIgnored("dist/file.js")).toBe(true);
39
+ // A file literally named `dist` should NOT be matched by the directory-only pattern
40
+ expect(matcher.isIgnored("dist")).toBe(false);
41
+ });
42
+
43
+ it("honors caller-provided globals when .ralphignore is absent", () => {
44
+ const dir = mkdtempSync(join(tmpdir(), "ralph-ignore-"));
45
+ const matcher = loadIgnoreForRoot(dir, ["custom/**"]);
46
+ expect(matcher.isIgnored("custom/inside.md")).toBe(true);
47
+ expect(matcher.isIgnored("other/inside.md")).toBe(false);
48
+ });
49
+
50
+ it("does not throw when the .ralphignore file is missing", () => {
51
+ const dir = mkdtempSync(join(tmpdir(), "ralph-ignore-"));
52
+ expect(() => loadIgnoreForRoot(dir)).not.toThrow();
53
+ const matcher = loadIgnoreForRoot(dir);
54
+ // Baseline defaults still apply
55
+ expect(matcher.isIgnored("node_modules/x.js")).toBe(true);
56
+ });
57
+
58
+ it("combines default globals, caller globals, and .ralphignore file together", () => {
59
+ const dir = mkdtempSync(join(tmpdir(), "ralph-ignore-"));
60
+ writeFileSync(join(dir, ".ralphignore"), "local/**\n");
61
+ const matcher = loadIgnoreForRoot(dir, ["global/**"]);
62
+ // From defaults
63
+ expect(matcher.isIgnored("node_modules/y.js")).toBe(true);
64
+ // From caller globals
65
+ expect(matcher.isIgnored("global/a.md")).toBe(true);
66
+ // From per-root file
67
+ expect(matcher.isIgnored("local/b.md")).toBe(true);
68
+ // Unmatched
69
+ expect(matcher.isIgnored("thoughts/keep.md")).toBe(false);
70
+ });
71
+
72
+ it("returns false for empty or root-level relative paths", () => {
73
+ const dir = mkdtempSync(join(tmpdir(), "ralph-ignore-"));
74
+ const matcher = loadIgnoreForRoot(dir);
75
+ expect(matcher.isIgnored("")).toBe(false);
76
+ expect(matcher.isIgnored("/")).toBe(false);
77
+ });
78
+
79
+ it("exports the documented default patterns", () => {
80
+ expect(DEFAULT_IGNORE_PATTERNS).toContain(".claude/");
81
+ expect(DEFAULT_IGNORE_PATTERNS).toContain("node_modules/");
82
+ expect(DEFAULT_IGNORE_PATTERNS).toContain("dist/");
83
+ expect(DEFAULT_IGNORE_PATTERNS).toContain(".git/");
84
+ expect(DEFAULT_IGNORE_PATTERNS).toContain("*.log");
85
+ });
86
+ });