@untangle-code/chunker 0.0.0-stage → 0.1.1

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
package/package.json CHANGED
@@ -1,6 +1,64 @@
1
1
  {
2
2
  "name": "@untangle-code/chunker",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.1",
4
+ "private": false,
5
+ "type": "module",
6
+ "description": "Untangle AST chunker: lossless, token-budgeted code chunking with line-fallback and minified strategies (spec.md §8, architecture.md §6.6, §14).",
7
+ "license": "AGPL-3.0-only",
8
+ "homepage": "https://github.com/lboel/untangle",
9
+ "bugs": {
10
+ "url": "https://github.com/lboel/untangle/issues"
11
+ },
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/lboel/untangle.git",
15
+ "directory": "packages/chunker"
16
+ },
17
+ "publishConfig": {
18
+ "access": "public"
19
+ },
20
+ "engines": {
21
+ "node": "^22.13.0 || >=24.0.0"
22
+ },
23
+ "exports": {
24
+ ".": {
25
+ "types": "./dist/index.d.ts",
26
+ "default": "./dist/index.js"
27
+ }
28
+ },
29
+ "files": [
30
+ "dist",
31
+ "!dist/**/*.test.*",
32
+ "!dist/**/test-helpers.*",
33
+ "!dist/**/test-support.*",
34
+ "!dist/**/test-support/**",
35
+ "!dist/**/testing/**",
36
+ "!dist/**/__tests__/**",
37
+ "src",
38
+ "!src/**/*.test.ts",
39
+ "!src/**/test-helpers.ts",
40
+ "!src/**/test-support.ts",
41
+ "!src/**/test-support/**",
42
+ "!src/**/testing/**",
43
+ "LICENSE",
44
+ "NOTICE",
45
+ "README.md",
46
+ "THIRD_PARTY_LICENSES.md"
47
+ ],
48
+ "dependencies": {
49
+ "@untangle-code/domain": "0.1.1"
50
+ },
51
+ "devDependencies": {
52
+ "@types/node": "22.19.7",
53
+ "typescript": "5.9.3",
54
+ "vitest": "3.2.7",
55
+ "@untangle-code/parser-registry": "0.1.1",
56
+ "@untangle-code/testkit": "0.1.1"
57
+ },
58
+ "scripts": {
59
+ "build": "node ../../scripts/tsc-build.mjs tsconfig.json",
60
+ "typecheck": "tsc -p tsconfig.typecheck.json",
61
+ "test": "vitest run",
62
+ "clean": "rm -rf dist *.tsbuildinfo"
63
+ }
6
64
  }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Byte-accurate text slicing and line/byte conversion (spec.md §7.1's
3
+ * `SourceRange` convention: byte offsets are 0-based half-open, line numbers
4
+ * 1-based inclusive).
5
+ *
6
+ * The chunker MUST slice chunk content by byte offset, not by JS string
7
+ * (UTF-16) index, so that `CodeChunkPayloadV1.start_byte`/`end_byte` are
8
+ * exact and so that content is byte-identical to the source file on disk.
9
+ * Everything here operates on a `Buffer` built once per file.
10
+ */
11
+ import type { SourceRange } from "@untangle-code/domain";
12
+
13
+ /** Precomputed line-start byte offsets for one file's content. */
14
+ export interface LineIndex {
15
+ buffer: Buffer;
16
+ /** Byte offset of the first byte of each line; `lineStarts[0] === 0`. */
17
+ lineStarts: number[];
18
+ }
19
+
20
+ /** Builds a `LineIndex` from full file content (decoded UTF-8 string). */
21
+ export function buildLineIndex(content: string): LineIndex {
22
+ const buffer = Buffer.from(content, "utf8");
23
+ const lineStarts: number[] = [0];
24
+ for (let i = 0; i < buffer.length; i += 1) {
25
+ if (buffer[i] === 0x0a) {
26
+ lineStarts.push(i + 1);
27
+ }
28
+ }
29
+ return { buffer, lineStarts };
30
+ }
31
+
32
+ /** 1-based line number containing the given 0-based byte offset. */
33
+ export function lineNumberForByteOffset(index: LineIndex, byteOffset: number): number {
34
+ const { lineStarts } = index;
35
+ let lo = 0;
36
+ let hi = lineStarts.length - 1;
37
+ while (lo < hi) {
38
+ const mid = Math.ceil((lo + hi) / 2);
39
+ if (lineStarts[mid]! <= byteOffset) {
40
+ lo = mid;
41
+ } else {
42
+ hi = mid - 1;
43
+ }
44
+ }
45
+ return lo + 1;
46
+ }
47
+
48
+ /**
49
+ * Builds a `SourceRange` for a byte span, deriving line numbers from the
50
+ * index. `endByte` is exclusive; for a non-empty span the end line is the
51
+ * line containing the last included byte (`endByte - 1`).
52
+ */
53
+ export function rangeForByteSpan(
54
+ index: LineIndex,
55
+ startByte: number,
56
+ endByte: number,
57
+ ): SourceRange {
58
+ const startLine = lineNumberForByteOffset(index, startByte);
59
+ const endLine = endByte > startByte ? lineNumberForByteOffset(index, endByte - 1) : startLine;
60
+ return { startLine, endLine, startByte, endByte };
61
+ }
62
+
63
+ /** Decodes the UTF-8 bytes `[startByte, endByte)` of the file as a string. */
64
+ export function sliceBytes(index: LineIndex, startByte: number, endByte: number): string {
65
+ return index.buffer.toString("utf8", startByte, endByte);
66
+ }
67
+
68
+ /** True if `byte` is a UTF-8 continuation byte (`10xxxxxx`). */
69
+ export function isUtf8ContinuationByte(byte: number): boolean {
70
+ return (byte & 0b1100_0000) === 0b1000_0000;
71
+ }
72
+
73
+ /**
74
+ * Returns the largest offset `<= target` (and `>= 0`) that does not fall in
75
+ * the middle of a multi-byte UTF-8 sequence, i.e. `buffer[offset]` (if any)
76
+ * is not a continuation byte. Used to keep token-boundary splits byte-safe
77
+ * (architecture.md §14.4) even when the file mixes multi-byte characters.
78
+ */
79
+ export function safeUtf8Boundary(buffer: Buffer, target: number): number {
80
+ let offset = Math.max(0, Math.min(target, buffer.length));
81
+ while (offset > 0 && offset < buffer.length && isUtf8ContinuationByte(buffer[offset]!)) {
82
+ offset -= 1;
83
+ }
84
+ return offset;
85
+ }
package/src/chunker.ts ADDED
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Top-level chunking entry point (spec.md §8.3): picks a strategy, runs it,
3
+ * and — regardless of which strategy ran — guarantees the lossless coverage
4
+ * invariant (spec.md §8.4) before returning: any residual gap is filled with
5
+ * `fallback_lines` chunks, then the result is validated one last time and
6
+ * `ANALYSIS_INCOMPLETE` is thrown if that somehow still fails (defense in
7
+ * depth; a correct strategy should never leave a gap).
8
+ */
9
+ import { classifyTextFormat, validateCoverage } from "@untangle-code/domain";
10
+ import type { CodeChunkDraft, CoverageEntry, SourceRange } from "@untangle-code/domain";
11
+
12
+ import { resolveChunkerConfig } from "./config.js";
13
+ import { contentOf, createBuildContext, emitChunk } from "./context.js";
14
+ import { assertLosslessCoverage, assertTruthfulLedger, chunkedEntry } from "./coverage.js";
15
+ import { detectMinifiedContent } from "./minified-detection.js";
16
+ import { splitByTokenBoundary } from "./split.js";
17
+ import { runAstChunkStrategy } from "./strategies/ast-chunk-strategy.js";
18
+ import { runLineFallbackStrategy } from "./strategies/line-fallback-strategy.js";
19
+ import { runMinifiedStrategy } from "./strategies/minified-strategy.js";
20
+ import type { ChunkDegradation, ChunkFileParams, ChunkFileResult } from "./types.js";
21
+
22
+ const GAP_HEADER = { signature: "fallback_lines" } as const;
23
+
24
+ /** Fills any residual byte gap with `fallback_lines` chunks (defense in depth for every strategy). */
25
+ function fillGaps(
26
+ ctx: ReturnType<typeof createBuildContext>,
27
+ drafts: CodeChunkDraft[],
28
+ coverage: CoverageEntry[],
29
+ ): void {
30
+ const gaps = validateCoverage(coverage, ctx.fileRange).gaps;
31
+ for (const gap of gaps) {
32
+ const range: SourceRange = { ...gap, startLine: -1, endLine: -1 };
33
+ for (const piece of splitByTokenBoundary(ctx, range, GAP_HEADER)) {
34
+ const draft = emitChunk(ctx, {
35
+ range: piece,
36
+ content: contentOf(ctx, piece),
37
+ scopeId: ctx.identity.fileId,
38
+ chunkKind: "fallback_lines",
39
+ header: {
40
+ signature: "fallback_lines",
41
+ metricsScope: "chunk",
42
+ metricsScopeId: ctx.identity.fileId,
43
+ },
44
+ });
45
+ drafts.push(draft);
46
+ coverage.push(chunkedEntry(draft.range, draft.chunkId));
47
+ }
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Chunks one file per spec.md §8.3: minified detection takes priority over
53
+ * AST chunking (a minified file may still parse "successfully" but produces
54
+ * no meaningful symbol structure), then a failed/empty parse falls back to
55
+ * line windows, and otherwise the AST strategy runs (README "Entscheidungen"
56
+ * documents this ordering as an Untangle decision — the spec numbers 9/10
57
+ * as trailing fallback steps without fixing their relative priority).
58
+ */
59
+ export function chunkFile(params: ChunkFileParams): ChunkFileResult {
60
+ const config = resolveChunkerConfig(params.config);
61
+ const ctx = createBuildContext(
62
+ params.identity,
63
+ params.content,
64
+ config,
65
+ params.parseResult.parseQuality,
66
+ );
67
+
68
+ const isMinified =
69
+ params.isMinified ??
70
+ detectMinifiedContent(
71
+ params.content,
72
+ config.minifiedDetection,
73
+ classifyTextFormat(params.identity.filePath),
74
+ );
75
+ const hasUsableAst = params.parseResult.parseQuality !== "failed" && params.regions.length > 0;
76
+
77
+ let drafts: CodeChunkDraft[];
78
+ let coverage: CoverageEntry[];
79
+ let strategy: ChunkFileResult["strategy"];
80
+ // E4 (WP-165): jede Abweichung von der AST-Strategie bekommt hier einen
81
+ // benannten Grund. Ohne ihn ist die Abstufung unsichtbar -- die Bytes sind
82
+ // lueckenlos erfasst, `parseQuality` kann `"complete"` lauten, und trotzdem
83
+ // hat kein Chunk eine Symbolzuordnung.
84
+ const degradations: ChunkDegradation[] = [];
85
+
86
+ if (isMinified) {
87
+ const result = runMinifiedStrategy(ctx);
88
+ drafts = result.drafts;
89
+ coverage = result.coverage;
90
+ strategy = "minified";
91
+ degradations.push({
92
+ reason: "minified_content",
93
+ strategy: "minified",
94
+ detail: `content was classified as minified (${
95
+ params.isMinified === undefined ? "auto-detected" : "declared by the caller"
96
+ }), so it was cut into byte windows; no symbol association exists for this file`,
97
+ });
98
+ } else if (!hasUsableAst) {
99
+ const result = runLineFallbackStrategy(ctx);
100
+ drafts = result.drafts;
101
+ coverage = result.coverage;
102
+ strategy = "line_fallback";
103
+ degradations.push(
104
+ params.parseResult.parseQuality === "failed"
105
+ ? {
106
+ reason: "parse_failed",
107
+ strategy: "line_fallback",
108
+ detail: `the parse failed (${String(
109
+ params.parseResult.errorNodeCount,
110
+ )} error node(s), ${String(
111
+ params.parseResult.missingNodeCount,
112
+ )} missing node(s)), so the file was cut into line windows; no symbol association exists for this file`,
113
+ }
114
+ : {
115
+ reason: "ast_regions_missing",
116
+ strategy: "line_fallback",
117
+ detail: `the parse reported "${params.parseResult.parseQuality}" but the language adapter delivered no chunk regions, so the file was cut into line windows; no symbol association exists for this file although the parse claims success`,
118
+ },
119
+ );
120
+ } else {
121
+ const result = runAstChunkStrategy(ctx, params.regions, params.symbols);
122
+ drafts = result.drafts;
123
+ coverage = result.coverage;
124
+ strategy = "ast";
125
+ }
126
+
127
+ fillGaps(ctx, drafts, coverage);
128
+ // Two-sided machine check (spec.md §8.4): the ledger must be COMPLETE
129
+ // (assertLosslessCoverage) and every claim in it must be TRUE
130
+ // (assertTruthfulLedger) — a synthesized chunk may never stand in for
131
+ // bytes its content does not literally carry.
132
+ assertTruthfulLedger(ctx.lineIndex, drafts, coverage, params.identity.filePath);
133
+ assertLosslessCoverage(coverage, ctx.fileRange, params.identity.filePath);
134
+
135
+ return {
136
+ drafts,
137
+ coverage,
138
+ strategy,
139
+ parseQuality: params.parseResult.parseQuality,
140
+ degradations,
141
+ };
142
+ }
package/src/config.ts ADDED
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Default chunker configuration and resolution of caller overrides.
3
+ */
4
+ import { CHUNKER_VERSION, PARSER_VERSION } from "@untangle-code/domain";
5
+
6
+ import { DEFAULT_MINIFIED_DETECTION_CONFIG } from "./minified-detection.js";
7
+ import { DEFAULT_TOKEN_ESTIMATOR_CONFIG } from "./token-estimator.js";
8
+ import type { ChunkerConfig, ChunkerConfigInput } from "./types.js";
9
+
10
+ /**
11
+ * Untangle decision (README "Entscheidungen"): 800 tokens is the default
12
+ * per-chunk budget — generous enough for a typical method or a small class
13
+ * overview, small enough that four to six chunks fit a model's context
14
+ * alongside a query and other results.
15
+ */
16
+ export const DEFAULT_MAX_TOKENS_PER_CHUNK = 800;
17
+
18
+ export const DEFAULT_LINE_FALLBACK_CONFIG = { linesPerChunk: 60, overlapLines: 8 };
19
+ export const DEFAULT_MINIFIED_STRATEGY_CONFIG = { overlapBytes: 0 };
20
+
21
+ export function resolveChunkerConfig(input: ChunkerConfigInput): ChunkerConfig {
22
+ return {
23
+ chunkerVersion: input.chunkerVersion ?? CHUNKER_VERSION,
24
+ parserVersion: input.parserVersion ?? PARSER_VERSION,
25
+ maxTokensPerChunk: input.maxTokensPerChunk ?? DEFAULT_MAX_TOKENS_PER_CHUNK,
26
+ tokenEstimator: input.tokenEstimator ?? DEFAULT_TOKEN_ESTIMATOR_CONFIG,
27
+ preparedText: input.preparedText,
28
+ minifiedDetection: input.minifiedDetection ?? DEFAULT_MINIFIED_DETECTION_CONFIG,
29
+ lineFallback: input.lineFallback ?? DEFAULT_LINE_FALLBACK_CONFIG,
30
+ minifiedStrategy: input.minifiedStrategy ?? DEFAULT_MINIFIED_STRATEGY_CONFIG,
31
+ };
32
+ }
package/src/context.ts ADDED
Binary file
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Coverage ledger construction (architecture.md §14.1; spec.md §8.4).
3
+ *
4
+ * The validator itself (`validateCoverage`) already lives in
5
+ * `@untangle-code/domain` as a pure function — it is the shared contract
6
+ * every producer must satisfy, so it is not redefined here. This module only
7
+ * builds the ledger entries as chunks are produced, and fills any residual
8
+ * gap the strategies left behind so the lossless invariant always holds
9
+ * before a file's chunking result is returned.
10
+ */
11
+ import type { CodeChunkDraft, CoverageEntry, SourceRange } from "@untangle-code/domain";
12
+ import { UntangleError, validateCoverage } from "@untangle-code/domain";
13
+
14
+ import { type LineIndex, sliceBytes } from "./byte-text.js";
15
+
16
+ /** One "chunked" ledger entry for a single emitted chunk. */
17
+ export function chunkedEntry(range: SourceRange, chunkId: string): CoverageEntry {
18
+ return { range, status: "chunked", chunkIds: [chunkId] };
19
+ }
20
+
21
+ /** One "excluded" ledger entry; `reason` is mandatory (spec.md §8.4). */
22
+ export function excludedEntry(range: SourceRange, reason: string): CoverageEntry {
23
+ return { range, status: "excluded", exclusionReason: reason };
24
+ }
25
+
26
+ /** The chunk fields `assertTruthfulLedger` needs (a structural subset of `CodeChunkDraft`). */
27
+ export type LedgerChunkView = Pick<
28
+ CodeChunkDraft,
29
+ "chunkId" | "chunkKind" | "range" | "content" | "synthesized"
30
+ >;
31
+
32
+ /**
33
+ * Asserts every ledger claim is TRUE, not merely byte-complete
34
+ * (spec.md §8.4; docs/BUILD_PLAN.md E4 — no silent masking of content):
35
+ *
36
+ * - every NON-synthesized chunk's `content` is byte-exactly the source
37
+ * bytes of its `range` (the `CodeChunkDraft.content` contract), so any
38
+ * "chunked" entry lying inside such a chunk's range is literally carried;
39
+ * - every "chunked" entry that is only backed by SYNTHESIZED chunks claims
40
+ * nothing beyond what those chunks literally carry: the claimed range's
41
+ * text (whitespace-trimmed) must appear verbatim in a claiming chunk's
42
+ * content;
43
+ * - every `chunkIds` reference resolves to an emitted chunk.
44
+ *
45
+ * A violation is an error (`UntangleError("ANALYSIS_INCOMPLETE")`), never a
46
+ * warning: a ledger entry that claims bytes no chunk content carries would
47
+ * silently mask content exactly as spec.md §8.4 forbids.
48
+ */
49
+ export function assertTruthfulLedger(
50
+ lineIndex: LineIndex,
51
+ drafts: readonly LedgerChunkView[],
52
+ entries: readonly CoverageEntry[],
53
+ filePath: string,
54
+ ): void {
55
+ const violations: string[] = [];
56
+ const byId = new Map<string, LedgerChunkView>();
57
+ for (const draft of drafts) {
58
+ byId.set(draft.chunkId, draft);
59
+ }
60
+
61
+ for (const draft of drafts) {
62
+ if (draft.synthesized === true) {
63
+ continue;
64
+ }
65
+ const sourceText = sliceBytes(lineIndex, draft.range.startByte, draft.range.endByte);
66
+ if (draft.content !== sourceText) {
67
+ violations.push(
68
+ `non-synthesized ${draft.chunkKind} chunk ${draft.chunkId} content is not byte-exact for its range ${draft.range.startByte}..${draft.range.endByte}`,
69
+ );
70
+ }
71
+ }
72
+
73
+ for (const entry of entries) {
74
+ if (entry.status !== "chunked") {
75
+ continue;
76
+ }
77
+ const claimedTrimmed = sliceBytes(lineIndex, entry.range.startByte, entry.range.endByte).trim();
78
+ let proven = false;
79
+ for (const chunkId of entry.chunkIds ?? []) {
80
+ const draft = byId.get(chunkId);
81
+ if (draft === undefined) {
82
+ violations.push(
83
+ `chunked entry ${entry.range.startByte}..${entry.range.endByte} references unknown chunk ${chunkId}`,
84
+ );
85
+ continue;
86
+ }
87
+ if (draft.synthesized !== true) {
88
+ // Content is byte-exact (checked above), so containment of the
89
+ // claimed byte range in the chunk range proves the claim literally.
90
+ if (
91
+ draft.range.startByte <= entry.range.startByte &&
92
+ entry.range.endByte <= draft.range.endByte
93
+ ) {
94
+ proven = true;
95
+ break;
96
+ }
97
+ } else if (draft.content.includes(claimedTrimmed)) {
98
+ proven = true;
99
+ break;
100
+ }
101
+ }
102
+ if (!proven) {
103
+ violations.push(
104
+ `chunked entry ${entry.range.startByte}..${entry.range.endByte} claims bytes none of its chunks [${(entry.chunkIds ?? []).join(", ")}] literally carries`,
105
+ );
106
+ }
107
+ }
108
+
109
+ if (violations.length > 0) {
110
+ throw new UntangleError(
111
+ "ANALYSIS_INCOMPLETE",
112
+ `coverage ledger of "${filePath}" claims content its chunks do not literally carry (spec.md §8.4)`,
113
+ {
114
+ operation: "chunker.assertTruthfulLedger",
115
+ context: { filePath, violations },
116
+ },
117
+ );
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Asserts the lossless invariant holds for `entries` over `fileRange`, or
123
+ * throws `UntangleError("ANALYSIS_INCOMPLETE")` naming the concrete gaps
124
+ * (spec.md §8.4: hard truncation without a follow-up chunk is a bug, not a
125
+ * warning). Every chunking strategy MUST call this before returning.
126
+ */
127
+ export function assertLosslessCoverage(
128
+ entries: readonly CoverageEntry[],
129
+ fileRange: SourceRange,
130
+ filePath: string,
131
+ ): void {
132
+ const result = validateCoverage(entries, fileRange);
133
+ if (!result.complete) {
134
+ throw new UntangleError(
135
+ "ANALYSIS_INCOMPLETE",
136
+ `chunking left the source of "${filePath}" incompletely covered (spec.md §8.4)`,
137
+ {
138
+ operation: "chunker.assertLosslessCoverage",
139
+ context: { filePath, gaps: result.gaps, issues: result.issues },
140
+ },
141
+ );
142
+ }
143
+ }
package/src/index.ts ADDED
@@ -0,0 +1,63 @@
1
+ /**
2
+ * @untangle-code/chunker — lossless, token-budgeted AST chunking
3
+ * (spec.md §8, architecture.md §6.6 and §14).
4
+ *
5
+ * The only public export of this package. Consumers pass in what a
6
+ * `LanguageAdapter` (from `@untangle-code/domain`, implemented by
7
+ * `parser-registry`) already produced — `ParseResult`, `AstRegion[]`,
8
+ * `ExtractedSymbol[]` — plus file identity and raw content, and get back
9
+ * `CodeChunkDraft[]` ready for embedding and storage.
10
+ */
11
+
12
+ // Main entry point (spec.md §8.3)
13
+ export { chunkFile } from "./chunker.js";
14
+
15
+ // Configuration
16
+ export {
17
+ DEFAULT_LINE_FALLBACK_CONFIG,
18
+ DEFAULT_MAX_TOKENS_PER_CHUNK,
19
+ DEFAULT_MINIFIED_STRATEGY_CONFIG,
20
+ resolveChunkerConfig,
21
+ } from "./config.js";
22
+ // E4-Abstufungsledger (WP-165): warum die AST-Strategie nicht lief.
23
+ export { CHUNK_DEGRADATION_REASONS } from "./types.js";
24
+ export type {
25
+ ChunkDegradation,
26
+ ChunkDegradationReason,
27
+ ChunkerConfig,
28
+ ChunkerConfigInput,
29
+ ChunkFileIdentity,
30
+ ChunkFileParams,
31
+ ChunkFileResult,
32
+ LineFallbackConfig,
33
+ MinifiedStrategyConfig,
34
+ } from "./types.js";
35
+
36
+ // Token estimation (architecture.md §14.4) — exposed so callers can budget
37
+ // consistently, e.g. when deciding how many chunks fit a query context.
38
+ export { DEFAULT_TOKEN_ESTIMATOR_CONFIG, estimateTokens } from "./token-estimator.js";
39
+ export type { TokenEstimatorConfig } from "./token-estimator.js";
40
+
41
+ // Minified-content detection (spec.md §8.3 step 10) — exposed so a scanner
42
+ // can pre-classify files without re-implementing the heuristic.
43
+ export { DEFAULT_MINIFIED_DETECTION_CONFIG, detectMinifiedContent } from "./minified-detection.js";
44
+ export type { MinifiedDetectionConfig } from "./minified-detection.js";
45
+
46
+ // Prepared text builder (spec.md §8.6)
47
+ export {
48
+ buildBm25Text,
49
+ buildBm25TextForDraft,
50
+ buildPreparedDenseText,
51
+ buildPreparedDenseTextForDraft,
52
+ } from "./prepared-text.js";
53
+ export type { Bm25TextMeta, PreparedTextContext, PreparedTextMeta } from "./prepared-text.js";
54
+
55
+ // Coverage ledger helpers (architecture.md §14.1; spec.md §8.4) — the
56
+ // validator itself is `validateCoverage` from `@untangle-code/domain`.
57
+ export {
58
+ assertLosslessCoverage,
59
+ assertTruthfulLedger,
60
+ chunkedEntry,
61
+ excludedEntry,
62
+ } from "./coverage.js";
63
+ export type { LedgerChunkView } from "./coverage.js";
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Minified-content detection (spec.md §8.3 step 10).
3
+ *
4
+ * Untangle decision (documented in README "Entscheidungen"): minification is
5
+ * detected from the raw text alone, BEFORE looking at parse results, because
6
+ * minified code can parse perfectly well while still being useless for
7
+ * per-symbol AST chunking (a bundled `.min.js` often has its entire runtime
8
+ * as one statement on one line). Detection is a simple, explainable
9
+ * character-shape heuristic — no dependency on a bundler manifest or file
10
+ * name convention, since generated output does not always carry one.
11
+ */
12
+
13
+ import { DEFAULT_MINIFIED_RULE, isMinifiedShape } from "@untangle-code/domain";
14
+ import type { TextFormatKind } from "@untangle-code/domain";
15
+
16
+ export interface MinifiedDetectionConfig {
17
+ /** Below this file size (bytes), never classify as minified (avoids false positives on tiny files). */
18
+ minSizeBytes: number;
19
+ /** Average bytes-per-line at or above which content is considered minified. */
20
+ avgLineLengthThreshold: number;
21
+ /** A single long line is sufficient only for structured data, not code or prose. */
22
+ maxLineLengthThreshold: number;
23
+ }
24
+
25
+ export const DEFAULT_MINIFIED_DETECTION_CONFIG: MinifiedDetectionConfig = {
26
+ minSizeBytes: 2000,
27
+ avgLineLengthThreshold: 300,
28
+ // Welle 13: von 1000 auf 500 angeglichen. Der Scanner entschied ohnehin bei
29
+ // 500, und im Indexpfad kam dieser Wert nie zum Zug — zwei Schwellen fuer
30
+ // eine Frage waren der Fehler, nicht die eine oder die andere Zahl.
31
+ maxLineLengthThreshold: DEFAULT_MINIFIED_RULE.maxLineLengthThreshold,
32
+ };
33
+
34
+ /**
35
+ * Heuristically classifies `content` as minified. Pure, deterministic,
36
+ * offline — no tokenizer, no bundler introspection.
37
+ */
38
+ export function detectMinifiedContent(
39
+ content: string,
40
+ config: MinifiedDetectionConfig = DEFAULT_MINIFIED_DETECTION_CONFIG,
41
+ textFormat?: TextFormatKind | null,
42
+ ): boolean {
43
+ const size = Buffer.byteLength(content, "utf8");
44
+ if (size < config.minSizeBytes) {
45
+ return false;
46
+ }
47
+ const lines = content.split("\n");
48
+ let maxLineLength = 0;
49
+ for (const line of lines) {
50
+ if (line.length > maxLineLength) {
51
+ maxLineLength = line.length;
52
+ }
53
+ }
54
+ // Entschieden wird in `@untangle-code/domain`: dieselbe Frage darf nicht an
55
+ // zwei Orten mit zwei Schwellen beantwortet werden. Bis Welle 13 galt hier
56
+ // 1000 und im Scanner 500 — welche Regel griff, hing am Aufrufpfad.
57
+ const avgLineLength = content.length / lines.length;
58
+ return isMinifiedShape(
59
+ { maxLineLength, averageLineLength: avgLineLength },
60
+ { textFormat: textFormat ?? null },
61
+ {
62
+ maxLineLengthThreshold: config.maxLineLengthThreshold,
63
+ averageLineLengthThreshold: config.avgLineLengthThreshold,
64
+ },
65
+ );
66
+ }