@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.
- package/LICENSE +661 -0
- package/NOTICE +90 -0
- package/README.md +206 -2
- package/THIRD_PARTY_LICENSES.md +24 -0
- package/dist/byte-text.d.ts +39 -0
- package/dist/byte-text.d.ts.map +1 -0
- package/dist/byte-text.js +59 -0
- package/dist/byte-text.js.map +1 -0
- package/dist/chunker.d.ts +11 -0
- package/dist/chunker.d.ts.map +1 -0
- package/dist/chunker.js +112 -0
- package/dist/chunker.js.map +1 -0
- package/dist/config.d.ts +17 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +28 -0
- package/dist/config.js.map +1 -0
- package/dist/context.d.ts +48 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +0 -0
- package/dist/context.js.map +1 -0
- package/dist/coverage.d.ts +44 -0
- package/dist/coverage.d.ts.map +1 -0
- package/dist/coverage.js +95 -0
- package/dist/coverage.js.map +1 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +28 -0
- package/dist/index.js.map +1 -0
- package/dist/minified-detection.d.ts +27 -0
- package/dist/minified-detection.d.ts.map +1 -0
- package/dist/minified-detection.js +46 -0
- package/dist/minified-detection.js.map +1 -0
- package/dist/prepared-text.d.ts +48 -0
- package/dist/prepared-text.d.ts.map +1 -0
- package/dist/prepared-text.js +57 -0
- package/dist/prepared-text.js.map +1 -0
- package/dist/split.d.ts +36 -0
- package/dist/split.d.ts.map +1 -0
- package/dist/split.js +126 -0
- package/dist/split.js.map +1 -0
- package/dist/strategies/ast-chunk-strategy.d.ts +52 -0
- package/dist/strategies/ast-chunk-strategy.d.ts.map +1 -0
- package/dist/strategies/ast-chunk-strategy.js +0 -0
- package/dist/strategies/ast-chunk-strategy.js.map +1 -0
- package/dist/strategies/line-fallback-strategy.d.ts +4 -0
- package/dist/strategies/line-fallback-strategy.d.ts.map +1 -0
- package/dist/strategies/line-fallback-strategy.js +79 -0
- package/dist/strategies/line-fallback-strategy.js.map +1 -0
- package/dist/strategies/minified-strategy.d.ts +4 -0
- package/dist/strategies/minified-strategy.d.ts.map +1 -0
- package/dist/strategies/minified-strategy.js +42 -0
- package/dist/strategies/minified-strategy.js.map +1 -0
- package/dist/token-estimator.d.ts +28 -0
- package/dist/token-estimator.d.ts.map +1 -0
- package/dist/token-estimator.js +37 -0
- package/dist/token-estimator.js.map +1 -0
- package/dist/types.d.ts +114 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +20 -0
- package/dist/types.js.map +1 -0
- package/package.json +61 -3
- package/src/byte-text.ts +85 -0
- package/src/chunker.ts +142 -0
- package/src/config.ts +32 -0
- package/src/context.ts +0 -0
- package/src/coverage.ts +143 -0
- package/src/index.ts +63 -0
- package/src/minified-detection.ts +66 -0
- package/src/prepared-text.ts +118 -0
- package/src/split.ts +164 -0
- package/src/strategies/ast-chunk-strategy.ts +0 -0
- package/src/strategies/line-fallback-strategy.ts +108 -0
- package/src/strategies/minified-strategy.ts +49 -0
- package/src/token-estimator.ts +48 -0
- 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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
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.0"
|
|
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.0",
|
|
56
|
+
"@untangle-code/testkit": "0.1.0"
|
|
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
|
}
|
package/src/byte-text.ts
ADDED
|
@@ -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
|
package/src/coverage.ts
ADDED
|
@@ -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
|
+
}
|