@crewhaus/chunker 0.1.0 → 0.1.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crewhaus/chunker",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "description": "Document chunking — fixed / semantic (Intl.Segmenter) / markdown strategies",
6
6
  "main": "src/index.ts",
@@ -12,13 +12,13 @@
12
12
  "test": "bun test src"
13
13
  },
14
14
  "dependencies": {
15
- "@crewhaus/errors": "0.0.0"
15
+ "@crewhaus/errors": "0.1.2"
16
16
  },
17
17
  "license": "Apache-2.0",
18
18
  "author": {
19
19
  "name": "Max Meier",
20
- "email": "max@studiomax.io",
21
- "url": "https://studiomax.io"
20
+ "email": "max@crewhaus.ai",
21
+ "url": "https://crewhaus.ai"
22
22
  },
23
23
  "repository": {
24
24
  "type": "git",
@@ -30,12 +30,7 @@
30
30
  "url": "https://github.com/crewhaus/factory/issues"
31
31
  },
32
32
  "publishConfig": {
33
- "access": "restricted"
33
+ "access": "public"
34
34
  },
35
- "files": [
36
- "src",
37
- "README.md",
38
- "LICENSE",
39
- "NOTICE"
40
- ]
35
+ "files": ["src", "README.md", "LICENSE", "NOTICE"]
41
36
  }
package/src/index.test.ts CHANGED
@@ -59,6 +59,80 @@ describe("semantic strategy", () => {
59
59
  const out = chunk({ id: "doc", text: "" }, { strategy: "semantic", size: 3 });
60
60
  expect(out).toEqual([]);
61
61
  });
62
+
63
+ test("whitespace-only text yields no chunks (segments trimmed away)", () => {
64
+ const out = chunk({ id: "doc", text: " \n\t " }, { strategy: "semantic", size: 2 });
65
+ expect(out).toEqual([]);
66
+ });
67
+
68
+ test("overlap produces overlapping sentence windows", () => {
69
+ const text = "Alpha. Bravo. Charlie. Delta. Echo. Foxtrot.";
70
+ const out = chunk({ id: "doc", text }, { strategy: "semantic", size: 2, overlap: 1 });
71
+ // stride = max(1, 2 - 1) = 1, so a size-2 window starts at sentences
72
+ // 0,1,2,3,4 (the loop breaks once i + size reaches the 6-sentence end): 5 chunks.
73
+ expect(out.length).toBe(5);
74
+ // Consecutive windows share a sentence (overlap), so each later window
75
+ // starts strictly before the previous window's end.
76
+ expect(out[1]?.startOffset).toBeLessThan(out[0]?.endOffset ?? -1);
77
+ });
78
+
79
+ test("overlap >= size still terminates (stride clamped to 1)", () => {
80
+ const text = "Alpha. Bravo. Charlie.";
81
+ const out = chunk({ id: "doc", text }, { strategy: "semantic", size: 2, overlap: 5 });
82
+ // No infinite loop; stride floored at 1 yields one window per sentence
83
+ // until the final window reaches the end.
84
+ expect(out.length).toBe(2);
85
+ });
86
+
87
+ test("rejects non-positive size", () => {
88
+ expect(() => chunk({ id: "x", text: "A. B." }, { strategy: "semantic", size: 0 })).toThrow(
89
+ ChunkerError,
90
+ );
91
+ });
92
+
93
+ test("metadata flows through semantic chunks", () => {
94
+ const out = chunk(
95
+ { id: "doc", text: "Alpha. Bravo.", metadata: { lang: "en" } },
96
+ { strategy: "semantic", size: 1 },
97
+ );
98
+ expect(out.length).toBe(2);
99
+ expect(out[0]?.metadata).toEqual({ lang: "en" });
100
+ expect(out[1]?.metadata).toEqual({ lang: "en" });
101
+ });
102
+
103
+ test("a valid non-default locale is accepted", () => {
104
+ const out = chunk(
105
+ { id: "doc", text: "Hallo. Welt." },
106
+ { strategy: "semantic", size: 1, locale: "de" },
107
+ );
108
+ expect(out.length).toBe(2);
109
+ });
110
+
111
+ test("an invalid locale throws ChunkerError (not a raw RangeError)", () => {
112
+ let caught: unknown;
113
+ try {
114
+ chunk(
115
+ { id: "doc", text: "Hello. World." },
116
+ { strategy: "semantic", size: 2, locale: "not a valid tag!!!" },
117
+ );
118
+ } catch (e) {
119
+ caught = e;
120
+ }
121
+ expect(caught).toBeInstanceOf(ChunkerError);
122
+ // The original RangeError is preserved as the cause for debugging.
123
+ expect((caught as Error).cause).toBeInstanceOf(RangeError);
124
+ expect((caught as Error).message).toContain("invalid locale");
125
+ });
126
+
127
+ test("preserves source bytes across a single full window", () => {
128
+ const text = "Alpha. Bravo. Charlie.";
129
+ const out = chunk({ id: "doc", text }, { strategy: "semantic", size: 10 });
130
+ expect(out.length).toBe(1);
131
+ const only = out[0];
132
+ expect(only).toBeDefined();
133
+ // The single window spans from the first sentence start to the last end.
134
+ expect(text.slice(only?.startOffset, only?.endOffset)).toBe(only?.text ?? "");
135
+ });
62
136
  });
63
137
 
64
138
  describe("markdown strategy", () => {
@@ -84,4 +158,78 @@ para`;
84
158
  // Section 2 is short → 1 piece.
85
159
  expect(out.length).toBeGreaterThan(2);
86
160
  });
161
+
162
+ test("sub-chunk ids and offsets are remapped to absolute document positions", () => {
163
+ const long = "y".repeat(120);
164
+ const md = `# Big\n${long}`; // single section, > size → sub-chunked
165
+ const out = chunk({ id: "doc", text: md }, { strategy: "markdown", size: 50 });
166
+ expect(out.length).toBeGreaterThan(1);
167
+ // Indices are contiguous and ids encode absolute startOffset.
168
+ out.forEach((c, i) => {
169
+ expect(c.index).toBe(i);
170
+ expect(c.id).toBe(`doc:${i}:${c.startOffset}`);
171
+ // Each chunk's text is exactly the document slice at its absolute offsets.
172
+ expect(md.slice(c.startOffset, c.endOffset)).toBe(c.text);
173
+ });
174
+ expect(out[0]?.startOffset).toBe(0);
175
+ });
176
+
177
+ test("text before the first header becomes its own leading section", () => {
178
+ const md = "preamble line\n# Header\nbody";
179
+ const out = chunk({ id: "doc", text: md }, { strategy: "markdown", size: 100 });
180
+ expect(out.length).toBe(2);
181
+ expect(out[0]?.text.startsWith("preamble")).toBe(true);
182
+ expect(out[1]?.text.startsWith("# Header")).toBe(true);
183
+ });
184
+
185
+ test("a document with no headers yields a single chunk", () => {
186
+ const md = "just plain text, no headers here";
187
+ const out = chunk({ id: "doc", text: md }, { strategy: "markdown", size: 100 });
188
+ expect(out.length).toBe(1);
189
+ expect(out[0]?.text).toBe(md);
190
+ });
191
+
192
+ test("an empty document yields no chunks", () => {
193
+ const out = chunk({ id: "doc", text: "" }, { strategy: "markdown", size: 100 });
194
+ expect(out).toEqual([]);
195
+ });
196
+
197
+ test("metadata flows through markdown chunks (both short and sub-chunked paths)", () => {
198
+ const long = "z".repeat(120);
199
+ const md = `# Big\n${long}\n## Small\nshort`;
200
+ const out = chunk(
201
+ { id: "doc", text: md, metadata: { src: "wiki" } },
202
+ { strategy: "markdown", size: 50 },
203
+ );
204
+ expect(out.length).toBeGreaterThan(2);
205
+ for (const c of out) {
206
+ expect(c.metadata).toEqual({ src: "wiki" });
207
+ }
208
+ });
209
+
210
+ test("reconstructs the source bytes by concatenating chunk text (overlap=0)", () => {
211
+ const md = "# A\npara one\n## B\npara two\n### C\npara three";
212
+ const out = chunk({ id: "doc", text: md }, { strategy: "markdown", size: 100 });
213
+ expect(out.map((c) => c.text).join("")).toBe(md);
214
+ });
215
+ });
216
+
217
+ describe("strategy dispatch", () => {
218
+ test("an unknown strategy throws ChunkerError", () => {
219
+ expect(() =>
220
+ // @ts-expect-error — deliberately bypass the typed strategy union
221
+ chunk({ id: "x", text: "abc" }, { strategy: "bogus", size: 5 }),
222
+ ).toThrow(ChunkerError);
223
+ });
224
+
225
+ test("the unknown-strategy message names the offending value", () => {
226
+ let caught: unknown;
227
+ try {
228
+ // @ts-expect-error — deliberately bypass the typed strategy union
229
+ chunk({ id: "x", text: "abc" }, { strategy: "nope", size: 5 });
230
+ } catch (e) {
231
+ caught = e;
232
+ }
233
+ expect((caught as Error).message).toContain("nope");
234
+ });
87
235
  });
package/src/index.ts CHANGED
@@ -90,8 +90,15 @@ function chunkFixed(doc: Document, opts: ChunkOptions): Chunk[] {
90
90
  function chunkSemantic(doc: Document, opts: ChunkOptions): Chunk[] {
91
91
  if (opts.size <= 0) throw new ChunkerError("size must be positive");
92
92
  const overlap = opts.overlap ?? 0;
93
- // Intl.Segmenter is widely available in modern Node + Bun.
94
- const segmenter = new Intl.Segmenter(opts.locale ?? "en", { granularity: "sentence" });
93
+ // Intl.Segmenter is widely available in modern Node + Bun. An invalid
94
+ // `locale` makes the constructor throw a raw `RangeError`; wrap it so callers
95
+ // see the package's typed `ChunkerError` contract (with the original as cause).
96
+ let segmenter: Intl.Segmenter;
97
+ try {
98
+ segmenter = new Intl.Segmenter(opts.locale ?? "en", { granularity: "sentence" });
99
+ } catch (cause) {
100
+ throw new ChunkerError(`invalid locale ${JSON.stringify(opts.locale)}`, cause);
101
+ }
95
102
  type Sentence = { readonly text: string; readonly start: number; readonly end: number };
96
103
  const sentences: Sentence[] = [];
97
104
  for (const seg of segmenter.segment(doc.text)) {