@untangle-code/chunker 0.0.0-stage → 0.1.0

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 (75) hide show
  1. package/LICENSE +661 -0
  2. package/NOTICE +90 -0
  3. package/README.md +206 -2
  4. package/THIRD_PARTY_LICENSES.md +24 -0
  5. package/dist/byte-text.d.ts +39 -0
  6. package/dist/byte-text.d.ts.map +1 -0
  7. package/dist/byte-text.js +59 -0
  8. package/dist/byte-text.js.map +1 -0
  9. package/dist/chunker.d.ts +11 -0
  10. package/dist/chunker.d.ts.map +1 -0
  11. package/dist/chunker.js +112 -0
  12. package/dist/chunker.js.map +1 -0
  13. package/dist/config.d.ts +17 -0
  14. package/dist/config.d.ts.map +1 -0
  15. package/dist/config.js +28 -0
  16. package/dist/config.js.map +1 -0
  17. package/dist/context.d.ts +48 -0
  18. package/dist/context.d.ts.map +1 -0
  19. package/dist/context.js +0 -0
  20. package/dist/context.js.map +1 -0
  21. package/dist/coverage.d.ts +44 -0
  22. package/dist/coverage.d.ts.map +1 -0
  23. package/dist/coverage.js +95 -0
  24. package/dist/coverage.js.map +1 -0
  25. package/dist/index.d.ts +23 -0
  26. package/dist/index.d.ts.map +1 -0
  27. package/dist/index.js +28 -0
  28. package/dist/index.js.map +1 -0
  29. package/dist/minified-detection.d.ts +27 -0
  30. package/dist/minified-detection.d.ts.map +1 -0
  31. package/dist/minified-detection.js +46 -0
  32. package/dist/minified-detection.js.map +1 -0
  33. package/dist/prepared-text.d.ts +48 -0
  34. package/dist/prepared-text.d.ts.map +1 -0
  35. package/dist/prepared-text.js +57 -0
  36. package/dist/prepared-text.js.map +1 -0
  37. package/dist/split.d.ts +36 -0
  38. package/dist/split.d.ts.map +1 -0
  39. package/dist/split.js +126 -0
  40. package/dist/split.js.map +1 -0
  41. package/dist/strategies/ast-chunk-strategy.d.ts +52 -0
  42. package/dist/strategies/ast-chunk-strategy.d.ts.map +1 -0
  43. package/dist/strategies/ast-chunk-strategy.js +0 -0
  44. package/dist/strategies/ast-chunk-strategy.js.map +1 -0
  45. package/dist/strategies/line-fallback-strategy.d.ts +4 -0
  46. package/dist/strategies/line-fallback-strategy.d.ts.map +1 -0
  47. package/dist/strategies/line-fallback-strategy.js +79 -0
  48. package/dist/strategies/line-fallback-strategy.js.map +1 -0
  49. package/dist/strategies/minified-strategy.d.ts +4 -0
  50. package/dist/strategies/minified-strategy.d.ts.map +1 -0
  51. package/dist/strategies/minified-strategy.js +42 -0
  52. package/dist/strategies/minified-strategy.js.map +1 -0
  53. package/dist/token-estimator.d.ts +28 -0
  54. package/dist/token-estimator.d.ts.map +1 -0
  55. package/dist/token-estimator.js +37 -0
  56. package/dist/token-estimator.js.map +1 -0
  57. package/dist/types.d.ts +114 -0
  58. package/dist/types.d.ts.map +1 -0
  59. package/dist/types.js +20 -0
  60. package/dist/types.js.map +1 -0
  61. package/package.json +61 -3
  62. package/src/byte-text.ts +85 -0
  63. package/src/chunker.ts +142 -0
  64. package/src/config.ts +32 -0
  65. package/src/context.ts +0 -0
  66. package/src/coverage.ts +143 -0
  67. package/src/index.ts +63 -0
  68. package/src/minified-detection.ts +66 -0
  69. package/src/prepared-text.ts +118 -0
  70. package/src/split.ts +164 -0
  71. package/src/strategies/ast-chunk-strategy.ts +0 -0
  72. package/src/strategies/line-fallback-strategy.ts +108 -0
  73. package/src/strategies/minified-strategy.ts +49 -0
  74. package/src/token-estimator.ts +48 -0
  75. package/src/types.ts +138 -0
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Prepared text builder (spec.md §8.6).
3
+ *
4
+ * Dense text gets a metadata header (repository/ref/path/language/symbol/
5
+ * signature) followed by a blank line and the raw content — this header
6
+ * counts toward the chunk's token budget (architecture.md §14.4), which is
7
+ * why every function here is also usable standalone during chunk planning,
8
+ * before a `CodeChunkDraft` object exists.
9
+ *
10
+ * BM25 text is deliberately undecorated: raw code, path, symbol names and a
11
+ * light identifier extraction, with no natural-language framing, so a
12
+ * lexical index scores on the same surface a keyword search would use.
13
+ */
14
+ import type { CodeChunkDraft } from "@untangle-code/domain";
15
+
16
+ /** Header fields that are not part of a `CodeChunkDraft` (spec.md §8.6). */
17
+ export interface PreparedTextContext {
18
+ /** Corresponds to `EmbeddingProfile.documentPrefix` (spec.md §5.7, §9). */
19
+ documentPrefix: string;
20
+ /** Human-readable repository label (`repo_label`), e.g. "untangle". */
21
+ repoLabel: string;
22
+ /** Human-readable ref name (`ref_name`), e.g. "main". */
23
+ refName: string;
24
+ }
25
+
26
+ /** The subset of chunk metadata the dense header needs. */
27
+ export interface PreparedTextMeta {
28
+ filePath: string;
29
+ language: string;
30
+ symbolFqn?: string | undefined;
31
+ signature?: string | undefined;
32
+ }
33
+
34
+ function buildDenseHeader(ctx: PreparedTextContext, meta: PreparedTextMeta): string {
35
+ // Trailing "\n\n" reproduces the blank line spec.md §8.6 shows between the
36
+ // header's last line ("signature: ...") and "{content}" — a `join("\n")`
37
+ // over the header lines alone only yields a single trailing newline.
38
+ return (
39
+ [
40
+ ctx.documentPrefix,
41
+ `repository: ${ctx.repoLabel}`,
42
+ `ref: ${ctx.refName}`,
43
+ `path: ${meta.filePath}`,
44
+ `language: ${meta.language}`,
45
+ `symbol: ${meta.symbolFqn ?? ""}`,
46
+ `signature: ${meta.signature ?? ""}`,
47
+ ].join("\n") + "\n\n"
48
+ );
49
+ }
50
+
51
+ /** Builds the dense document text of spec.md §8.6 from loose metadata + content. */
52
+ export function buildPreparedDenseText(
53
+ ctx: PreparedTextContext,
54
+ meta: PreparedTextMeta,
55
+ content: string,
56
+ ): string {
57
+ return buildDenseHeader(ctx, meta) + content;
58
+ }
59
+
60
+ /** Convenience wrapper of {@link buildPreparedDenseText} for an existing draft. */
61
+ export function buildPreparedDenseTextForDraft(
62
+ ctx: PreparedTextContext,
63
+ draft: CodeChunkDraft,
64
+ ): string {
65
+ return buildPreparedDenseText(
66
+ ctx,
67
+ {
68
+ filePath: draft.filePath,
69
+ language: draft.language,
70
+ symbolFqn: draft.symbolFqn,
71
+ signature: draft.signature,
72
+ },
73
+ draft.content,
74
+ );
75
+ }
76
+
77
+ /** Identifier-shaped tokens: letters/digits/underscore/`$`, not starting with a digit. */
78
+ const IDENTIFIER_PATTERN = /[A-Za-z_$][A-Za-z0-9_$]*/g;
79
+
80
+ function extractIdentifiers(text: string): string[] {
81
+ const seen = new Set<string>();
82
+ for (const match of text.matchAll(IDENTIFIER_PATTERN)) {
83
+ seen.add(match[0]);
84
+ }
85
+ return [...seen];
86
+ }
87
+
88
+ /** The subset of chunk metadata the BM25 text needs. */
89
+ export interface Bm25TextMeta {
90
+ filePath: string;
91
+ symbolName?: string | undefined;
92
+ symbolFqn?: string | undefined;
93
+ }
94
+
95
+ /**
96
+ * Builds the BM25 text of spec.md §8.6: raw code, path, symbol names and
97
+ * identifiers, without natural-language decoration.
98
+ */
99
+ export function buildBm25Text(meta: Bm25TextMeta, content: string): string {
100
+ const parts = [meta.filePath];
101
+ if (meta.symbolFqn) {
102
+ parts.push(meta.symbolFqn);
103
+ }
104
+ if (meta.symbolName) {
105
+ parts.push(meta.symbolName);
106
+ }
107
+ parts.push(...extractIdentifiers(content));
108
+ parts.push(content);
109
+ return parts.join("\n");
110
+ }
111
+
112
+ /** Convenience wrapper of {@link buildBm25Text} for an existing draft. */
113
+ export function buildBm25TextForDraft(draft: CodeChunkDraft): string {
114
+ return buildBm25Text(
115
+ { filePath: draft.filePath, symbolName: draft.symbolName, symbolFqn: draft.symbolFqn },
116
+ draft.content,
117
+ );
118
+ }
package/src/split.ts ADDED
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Region splitting mechanics shared by the AST strategy (spec.md §8.3 steps
3
+ * 6-8) and reused defensively by the fallback strategies:
4
+ *
5
+ * 1. `flattenToAtoms` — recursive AST-block splitting (step 6) plus
6
+ * statement grouping (step 7): descends into `AstRegion.children` only as
7
+ * far as needed, filling any gap between/around children with a raw
8
+ * "leftover text" atom so the atom list always covers the parent range
9
+ * exactly, then greedily re-merges adjacent atoms under the token budget.
10
+ * 2. `splitByTokenBoundary` — safe token/byte-boundary splitting (step 8):
11
+ * the last resort for a single atom that is still too large on its own
12
+ * (e.g. one enormous statement with no further AST structure).
13
+ */
14
+ import type { AstRegion, SourceRange } from "@untangle-code/domain";
15
+
16
+ import { rangeForByteSpan, safeUtf8Boundary, sliceBytes } from "./byte-text.js";
17
+ import {
18
+ type BuildContext,
19
+ contentOf,
20
+ estimateTokensWithHeader,
21
+ type HeaderMeta,
22
+ } from "./context.js";
23
+
24
+ type Header = Pick<HeaderMeta, "symbolFqn" | "signature">;
25
+
26
+ function fits(ctx: BuildContext, range: SourceRange, header: Header): boolean {
27
+ return (
28
+ estimateTokensWithHeader(ctx, contentOf(ctx, range), header) <= ctx.config.maxTokensPerChunk
29
+ );
30
+ }
31
+
32
+ function merge(a: SourceRange, b: SourceRange): SourceRange {
33
+ return { startLine: a.startLine, endLine: b.endLine, startByte: a.startByte, endByte: b.endByte };
34
+ }
35
+
36
+ /** Greedily merges adjacent (contiguous, ordered) atoms while staying under budget. */
37
+ export function groupAtomsByBudget(
38
+ ctx: BuildContext,
39
+ atoms: readonly SourceRange[],
40
+ header: Header,
41
+ ): SourceRange[] {
42
+ if (atoms.length === 0) {
43
+ return [];
44
+ }
45
+ const result: SourceRange[] = [];
46
+ let current = atoms[0]!;
47
+ for (let i = 1; i < atoms.length; i += 1) {
48
+ const next = atoms[i]!;
49
+ const merged = merge(current, next);
50
+ if (fits(ctx, merged, header)) {
51
+ current = merged;
52
+ } else {
53
+ result.push(current);
54
+ current = next;
55
+ }
56
+ }
57
+ result.push(current);
58
+ return result;
59
+ }
60
+
61
+ /**
62
+ * Recursively splits `node` into an ordered, gap-free list of ranges that
63
+ * together cover `node.range` exactly. A node that already fits the budget
64
+ * is returned whole. A node with no further AST structure that still does
65
+ * not fit is returned whole too — {@link splitByTokenBoundary} handles it.
66
+ */
67
+ export function flattenToAtoms(ctx: BuildContext, node: AstRegion, header: Header): SourceRange[] {
68
+ if (fits(ctx, node.range, header)) {
69
+ return [node.range];
70
+ }
71
+ if (node.splittable && node.children && node.children.length > 0) {
72
+ const children = [...node.children].sort((a, b) => a.range.startByte - b.range.startByte);
73
+ const atoms: SourceRange[] = [];
74
+ let cursor = node.range.startByte;
75
+ for (const child of children) {
76
+ if (child.range.startByte > cursor) {
77
+ atoms.push(rangeForByteSpan(ctx.lineIndex, cursor, child.range.startByte));
78
+ }
79
+ atoms.push(...flattenToAtoms(ctx, child, header));
80
+ cursor = Math.max(cursor, child.range.endByte);
81
+ }
82
+ if (cursor < node.range.endByte) {
83
+ atoms.push(rangeForByteSpan(ctx.lineIndex, cursor, node.range.endByte));
84
+ }
85
+ return groupAtomsByBudget(ctx, atoms, header);
86
+ }
87
+ return [node.range];
88
+ }
89
+
90
+ /** How far back (bytes) to look for a newline to split on instead of a hard cut. */
91
+ const NEWLINE_LOOKBACK_BYTES = 120;
92
+
93
+ function findBudgetEnd(ctx: BuildContext, start: number, maxEnd: number, header: Header): number {
94
+ const wholeRange = rangeForByteSpan(ctx.lineIndex, start, maxEnd);
95
+ if (fits(ctx, wholeRange, header)) {
96
+ return maxEnd;
97
+ }
98
+ let lo = start + 1;
99
+ let hi = maxEnd;
100
+ // Guard: even one byte does not fit under an absurdly small budget — make
101
+ // forced minimal progress rather than looping forever.
102
+ if (!fits(ctx, rangeForByteSpan(ctx.lineIndex, start, lo), header)) {
103
+ return lo;
104
+ }
105
+ while (lo < hi) {
106
+ const mid = lo + Math.ceil((hi - lo) / 2);
107
+ if (fits(ctx, rangeForByteSpan(ctx.lineIndex, start, mid), header)) {
108
+ lo = mid;
109
+ } else {
110
+ hi = mid - 1;
111
+ }
112
+ }
113
+ return lo;
114
+ }
115
+
116
+ function preferNewlineBoundary(buffer: Buffer, start: number, end: number): number {
117
+ const lookbackFloor = Math.max(start, end - NEWLINE_LOOKBACK_BYTES);
118
+ // Do not shrink the piece by more than half just for a nicer boundary.
119
+ const minAcceptable = Math.max(start + 1, start + Math.floor((end - start) / 2));
120
+ for (let i = end - 1; i >= lookbackFloor; i -= 1) {
121
+ if (buffer[i] === 0x0a && i + 1 >= minAcceptable) {
122
+ return i + 1;
123
+ }
124
+ }
125
+ return end;
126
+ }
127
+
128
+ /**
129
+ * Splits `range` into an ordered, gap-free, non-overlapping list of ranges,
130
+ * each within the token budget, at safe UTF-8 boundaries (architecture.md
131
+ * §14.4). Last-resort splitter: used only on atoms `flattenToAtoms` could
132
+ * not shrink through AST structure.
133
+ */
134
+ export function splitByTokenBoundary(
135
+ ctx: BuildContext,
136
+ range: SourceRange,
137
+ header: Header,
138
+ ): SourceRange[] {
139
+ const pieces: SourceRange[] = [];
140
+ let cursor = range.startByte;
141
+ while (cursor < range.endByte) {
142
+ let end = findBudgetEnd(ctx, cursor, range.endByte, header);
143
+ if (end > cursor + 1) {
144
+ end = preferNewlineBoundary(ctx.lineIndex.buffer, cursor, end);
145
+ }
146
+ end = safeUtf8Boundary(ctx.lineIndex.buffer, end);
147
+ if (end <= cursor) {
148
+ // Never emit an empty piece or fail to progress: force one byte, then
149
+ // walk forward off any continuation byte it landed on.
150
+ end = cursor + 1;
151
+ while (end < range.endByte && (ctx.lineIndex.buffer[end]! & 0b1100_0000) === 0b1000_0000) {
152
+ end += 1;
153
+ }
154
+ }
155
+ pieces.push(rangeForByteSpan(ctx.lineIndex, cursor, end));
156
+ cursor = end;
157
+ }
158
+ return pieces;
159
+ }
160
+
161
+ /** Re-exported for strategies that need a raw byte slice without going through an `AstRegion`. */
162
+ export function sliceRange(ctx: BuildContext, range: SourceRange): string {
163
+ return sliceBytes(ctx.lineIndex, range.startByte, range.endByte);
164
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Line fallback strategy (spec.md §8.3 step 9): used whole-file when parsing
3
+ * failed or produced no usable regions. Splits the file into fixed-size,
4
+ * overlapping line windows so a retrieval hit still carries some
5
+ * surrounding context despite the missing AST structure. Any individual
6
+ * window that is still too large for the token budget (e.g. one very long
7
+ * line) is defensively re-split at a safe byte boundary.
8
+ */
9
+ import type { SourceRange } from "@untangle-code/domain";
10
+
11
+ import { rangeForByteSpan } from "../byte-text.js";
12
+ import {
13
+ type BuildContext,
14
+ contentOf,
15
+ emitChunk,
16
+ estimateTokensWithHeader,
17
+ type HeaderMeta,
18
+ } from "../context.js";
19
+ import { chunkedEntry } from "../coverage.js";
20
+ import { splitByTokenBoundary } from "../split.js";
21
+
22
+ import type { StrategyResult } from "./ast-chunk-strategy.js";
23
+
24
+ const NO_HEADER: Pick<HeaderMeta, "symbolFqn" | "signature"> = { signature: "fallback_lines" };
25
+
26
+ /**
27
+ * Computes the ordered, overlapping line windows covering `[1, totalLines]`.
28
+ * `step = linesPerChunk - overlapLines` (clamped to at least 1 to guarantee
29
+ * termination); the last window is extended to the final line.
30
+ */
31
+ function computeLineWindows(
32
+ totalLines: number,
33
+ linesPerChunk: number,
34
+ overlapLines: number,
35
+ ): [number, number][] {
36
+ const chunkSize = Math.max(1, linesPerChunk);
37
+ const overlap = Math.min(Math.max(0, overlapLines), chunkSize - 1);
38
+ const step = Math.max(1, chunkSize - overlap);
39
+ const windows: [number, number][] = [];
40
+ let start = 1;
41
+ while (start <= totalLines) {
42
+ const end = Math.min(totalLines, start + chunkSize - 1);
43
+ windows.push([start, end]);
44
+ if (end >= totalLines) {
45
+ break;
46
+ }
47
+ start += step;
48
+ }
49
+ return windows;
50
+ }
51
+
52
+ export function runLineFallbackStrategy(ctx: BuildContext): StrategyResult {
53
+ const result: StrategyResult = { drafts: [], coverage: [] };
54
+ const totalLines = ctx.lineIndex.lineStarts.length;
55
+ const windows = computeLineWindows(
56
+ totalLines,
57
+ ctx.config.lineFallback.linesPerChunk,
58
+ ctx.config.lineFallback.overlapLines,
59
+ );
60
+
61
+ for (const [startLine, endLine] of windows) {
62
+ const startByte = ctx.lineIndex.lineStarts[startLine - 1]!;
63
+ const endByte =
64
+ endLine < totalLines ? ctx.lineIndex.lineStarts[endLine]! : ctx.lineIndex.buffer.length;
65
+ if (endByte <= startByte) {
66
+ // Degenerate trailing "line" produced when the file ends in a newline
67
+ // (an empty line index entry with nothing after it) — zero bytes, so
68
+ // there is nothing to cover and nothing to chunk.
69
+ continue;
70
+ }
71
+ const range: SourceRange = { startLine, endLine, startByte, endByte };
72
+ const content = contentOf(ctx, range);
73
+ if (estimateTokensWithHeader(ctx, content, NO_HEADER) <= ctx.config.maxTokensPerChunk) {
74
+ const draft = emitChunk(ctx, {
75
+ range,
76
+ content,
77
+ scopeId: ctx.identity.fileId,
78
+ chunkKind: "fallback_lines",
79
+ header: {
80
+ signature: "fallback_lines",
81
+ metricsScope: "chunk",
82
+ metricsScopeId: ctx.identity.fileId,
83
+ },
84
+ });
85
+ result.drafts.push(draft);
86
+ result.coverage.push(chunkedEntry(draft.range, draft.chunkId));
87
+ } else {
88
+ for (const piece of splitByTokenBoundary(ctx, range, NO_HEADER)) {
89
+ const pieceContent = contentOf(ctx, piece);
90
+ const rangeWithLines = rangeForByteSpan(ctx.lineIndex, piece.startByte, piece.endByte);
91
+ const draft = emitChunk(ctx, {
92
+ range: rangeWithLines,
93
+ content: pieceContent,
94
+ scopeId: ctx.identity.fileId,
95
+ chunkKind: "fallback_lines",
96
+ header: {
97
+ signature: "fallback_lines",
98
+ metricsScope: "chunk",
99
+ metricsScopeId: ctx.identity.fileId,
100
+ },
101
+ });
102
+ result.drafts.push(draft);
103
+ result.coverage.push(chunkedEntry(draft.range, draft.chunkId));
104
+ }
105
+ }
106
+ }
107
+ return result;
108
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Minified strategy (spec.md §8.3 step 10): the file is treated as one
3
+ * opaque blob and split purely at safe token/byte boundaries — there is no
4
+ * useful AST structure to lean on, by definition of "minified" here.
5
+ */
6
+ import { rangeForByteSpan, safeUtf8Boundary } from "../byte-text.js";
7
+ import { type BuildContext, contentOf, emitChunk, type HeaderMeta } from "../context.js";
8
+ import { chunkedEntry } from "../coverage.js";
9
+ import { splitByTokenBoundary } from "../split.js";
10
+
11
+ import type { StrategyResult } from "./ast-chunk-strategy.js";
12
+
13
+ const HEADER: Pick<HeaderMeta, "symbolFqn" | "signature"> = { signature: "minified_fragment" };
14
+
15
+ export function runMinifiedStrategy(ctx: BuildContext): StrategyResult {
16
+ const result: StrategyResult = { drafts: [], coverage: [] };
17
+ const pieces = splitByTokenBoundary(ctx, ctx.fileRange, HEADER);
18
+ const overlapBytes = Math.max(0, ctx.config.minifiedStrategy.overlapBytes);
19
+
20
+ pieces.forEach((piece, index) => {
21
+ // Configurable trailing overlap: pull this piece's start back into the
22
+ // previous piece's territory for retrieval context. Never before the
23
+ // file start, and never a UTF-8-unsafe cut (architecture.md §14.4).
24
+ // Coverage stays gap-free either way — overlaps are allowed.
25
+ let startByte = piece.startByte;
26
+ if (index > 0 && overlapBytes > 0) {
27
+ startByte = safeUtf8Boundary(
28
+ ctx.lineIndex.buffer,
29
+ Math.max(ctx.fileRange.startByte, piece.startByte - overlapBytes),
30
+ );
31
+ }
32
+ const range = rangeForByteSpan(ctx.lineIndex, startByte, piece.endByte);
33
+ const content = contentOf(ctx, range);
34
+ const draft = emitChunk(ctx, {
35
+ range,
36
+ content,
37
+ scopeId: ctx.identity.fileId,
38
+ chunkKind: "minified_fragment",
39
+ header: {
40
+ signature: "minified_fragment",
41
+ metricsScope: "chunk",
42
+ metricsScopeId: ctx.identity.fileId,
43
+ },
44
+ });
45
+ result.drafts.push(draft);
46
+ result.coverage.push(chunkedEntry(draft.range, draft.chunkId));
47
+ });
48
+ return result;
49
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Conservative, offline token estimator (architecture.md §14.4).
3
+ *
4
+ * There is no network tokenizer call here on purpose (spec.md §24 privacy,
5
+ * and the chunker must run fully offline). The estimate is deliberately
6
+ * biased to OVER-estimate real BPE-style tokenizer counts rather than
7
+ * under-estimate: real code tokenizers average roughly 3.3-4.5 characters
8
+ * per token (identifiers and keywords tokenize efficiently; whitespace and
9
+ * punctuation less so). Dividing by a lower `charsPerToken` than that
10
+ * average guarantees the estimate is usually >= the true count, so a chunk
11
+ * that "fits the budget" here also fits the embedding model's real budget —
12
+ * the failure mode of under-estimating (silently truncated embeddings) is
13
+ * far worse than over-fragmenting slightly.
14
+ */
15
+
16
+ export interface TokenEstimatorConfig {
17
+ /** Conservative average characters per token; lower over-estimates. */
18
+ charsPerToken: number;
19
+ /** Per-line overhead (in tokens) added for newline/indentation noise. */
20
+ perLineOverheadTokens: number;
21
+ }
22
+
23
+ /** Untangle decision: 3 chars/token (vs. the ~4 real average) plus a small per-line overhead. */
24
+ export const DEFAULT_TOKEN_ESTIMATOR_CONFIG: TokenEstimatorConfig = {
25
+ charsPerToken: 3,
26
+ perLineOverheadTokens: 0.25,
27
+ };
28
+
29
+ /**
30
+ * Estimates the token count of `text`. Pure function of its inputs; never
31
+ * touches the network or a real tokenizer vocabulary.
32
+ */
33
+ export function estimateTokens(
34
+ text: string,
35
+ config: TokenEstimatorConfig = DEFAULT_TOKEN_ESTIMATOR_CONFIG,
36
+ ): number {
37
+ if (text.length === 0) {
38
+ return 0;
39
+ }
40
+ let lineCount = 1;
41
+ for (let i = 0; i < text.length; i += 1) {
42
+ if (text.charCodeAt(i) === 10) {
43
+ lineCount += 1;
44
+ }
45
+ }
46
+ const raw = text.length / config.charsPerToken + lineCount * config.perLineOverheadTokens;
47
+ return Math.max(1, Math.ceil(raw));
48
+ }
package/src/types.ts ADDED
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Chunker-local configuration and input/output contracts.
3
+ *
4
+ * These types are additive to `@untangle-code/domain` — they configure how
5
+ * this package turns `AstRegion`/`ExtractedSymbol`/`ParseResult` into
6
+ * `CodeChunkDraft`s, but never redefine a domain contract.
7
+ */
8
+ import type {
9
+ AstRegion,
10
+ CodeChunkDraft,
11
+ CoverageEntry,
12
+ ExtractedSymbol,
13
+ ParseQuality,
14
+ ParseResult,
15
+ } from "@untangle-code/domain";
16
+
17
+ import type { MinifiedDetectionConfig } from "./minified-detection.js";
18
+ import type { PreparedTextContext } from "./prepared-text.js";
19
+ import type { TokenEstimatorConfig } from "./token-estimator.js";
20
+
21
+ /** Line-window fallback config for parse failures (spec.md §8.3 step 9). */
22
+ export interface LineFallbackConfig {
23
+ /** Target number of source lines per fallback chunk before overlap. */
24
+ linesPerChunk: number;
25
+ /** Number of trailing lines repeated at the start of the next chunk. */
26
+ overlapLines: number;
27
+ }
28
+
29
+ /** Configurable overlap for the minified strategy (spec.md §8.3 step 10). */
30
+ export interface MinifiedStrategyConfig {
31
+ /** Bytes of trailing context repeated at the start of the next fragment. */
32
+ overlapBytes: number;
33
+ }
34
+
35
+ /** Full chunker configuration; every field has a documented default. */
36
+ export interface ChunkerConfig {
37
+ /** Chunk identity version input (spec.md §5.6). Defaults to `CHUNKER_VERSION`. */
38
+ chunkerVersion: string;
39
+ /** Stamped onto every draft as provenance (architecture.md §2.3). */
40
+ parserVersion: string;
41
+ /** Conservative token budget per chunk, header included (architecture.md §14.4). */
42
+ maxTokensPerChunk: number;
43
+ tokenEstimator: TokenEstimatorConfig;
44
+ preparedText: PreparedTextContext;
45
+ minifiedDetection: MinifiedDetectionConfig;
46
+ lineFallback: LineFallbackConfig;
47
+ minifiedStrategy: MinifiedStrategyConfig;
48
+ }
49
+
50
+ /** User-supplied overrides; every field optional except the prepared-text header. */
51
+ export type ChunkerConfigInput = Partial<Omit<ChunkerConfig, "preparedText">> & {
52
+ preparedText: PreparedTextContext;
53
+ };
54
+
55
+ /** Minimal identity of the file being chunked (a slice of domain `SourceFile`). */
56
+ export interface ChunkFileIdentity {
57
+ workspaceId: string;
58
+ repoId: string;
59
+ refId: string;
60
+ fileId: string;
61
+ /** "/"-normalized path relative to the repository root. */
62
+ filePath: string;
63
+ languageId: string;
64
+ }
65
+
66
+ /** Input to {@link chunkFile} (see `src/chunker.ts`). */
67
+ export interface ChunkFileParams {
68
+ identity: ChunkFileIdentity;
69
+ /** Full UTF-8 decoded file content (spec.md §7). */
70
+ content: string;
71
+ parseResult: ParseResult;
72
+ /** `adapter.extractChunks(ast)` output; empty when `parseResult.ast` is null. */
73
+ regions: AstRegion[];
74
+ /** `adapter.extractSymbols(ast)` output; empty when `parseResult.ast` is null. */
75
+ symbols: ExtractedSymbol[];
76
+ /**
77
+ * Overrides minified auto-detection when the caller already knows (e.g. a
78
+ * `.min.js` scanner classification). Leave unset to auto-detect.
79
+ */
80
+ isMinified?: boolean;
81
+ config: ChunkerConfigInput;
82
+ }
83
+
84
+ /**
85
+ * Warum der Chunker unterhalb der AST-Strategie geblieben ist (E4, WP-165).
86
+ *
87
+ * Geschlossene Menge, damit die Gruende zaehlbar bleiben; die freie
88
+ * Erlaeuterung steht in `detail`, nicht im Grund. Bewusst PAKETLOKAL und nicht
89
+ * als `SkipReason` aus `@untangle-code/domain`: dessen geschlossener Katalog
90
+ * kennt keinen Grund fuer "der Parser war erfolgreich, lieferte aber keine
91
+ * Regionen", und `parse_failed` waere dafuer schlicht falsch. Dieselbe
92
+ * Entscheidung hat `@untangle-code/context-artifacts` fuer seinen
93
+ * `ArtifactSkipReason` getroffen.
94
+ */
95
+ export const CHUNK_DEGRADATION_REASONS = [
96
+ /** Der Parser ist gescheitert; es gab keinen Baum, an dem entlang zu schneiden waere. */
97
+ "parse_failed",
98
+ /** Der Parser meldete Erfolg, der Sprachadapter lieferte aber keine Regionen. */
99
+ "ast_regions_missing",
100
+ /** Der Inhalt ist minifiziert; Byte-Fenster statt Symbolstruktur (spec.md §8.3 Schritt 10). */
101
+ "minified_content",
102
+ ] as const;
103
+
104
+ export type ChunkDegradationReason = (typeof CHUNK_DEGRADATION_REASONS)[number];
105
+
106
+ /**
107
+ * Eine Abstufung: der Chunker hat die Datei zwar lueckenlos erfasst, aber OHNE
108
+ * die Symbolstruktur, die die AST-Strategie geliefert haette.
109
+ *
110
+ * Ohne diesen Eintrag ist genau das unsichtbar. Die Byte-Abdeckung ist
111
+ * vollstaendig (`assertLosslessCoverage` ist zufrieden), `parseQuality` kann
112
+ * `"complete"` lauten -- und trotzdem hat kein einziger Chunk eine
113
+ * Symbolzuordnung. Ein Aufrufer, der `parseQuality` liest, haelt das Ergebnis
114
+ * fuer strukturell analysiert. Das ist die Fehlerklasse "Vollstaendigkeit
115
+ * behauptet, Teilbaum verworfen" in ihrer unauffaelligsten Form, deshalb steht
116
+ * sie hier als eigener, benannter Befund im Ergebnis.
117
+ */
118
+ export interface ChunkDegradation {
119
+ reason: ChunkDegradationReason;
120
+ /** Welche Strategie stattdessen lief. */
121
+ strategy: "line_fallback" | "minified";
122
+ /** Menschenlesbare Begruendung; MUSS nicht leer sein (E4). */
123
+ detail: string;
124
+ }
125
+
126
+ /** Output of {@link chunkFile}. */
127
+ export interface ChunkFileResult {
128
+ drafts: CodeChunkDraft[];
129
+ coverage: CoverageEntry[];
130
+ /** Which of the three strategies (spec.md §8.3) produced `drafts`. */
131
+ strategy: "ast" | "line_fallback" | "minified";
132
+ parseQuality: ParseQuality;
133
+ /**
134
+ * Leer, wenn die AST-Strategie lief. Sonst genau ein Eintrag mit dem Grund
135
+ * (E4: keine stillschweigende Abstufung) -- siehe {@link ChunkDegradation}.
136
+ */
137
+ degradations: ChunkDegradation[];
138
+ }