@untangle-code/context-artifacts 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 (109) hide show
  1. package/LICENSE +661 -0
  2. package/NOTICE +90 -0
  3. package/README.md +247 -2
  4. package/THIRD_PARTY_LICENSES.md +24 -0
  5. package/dist/adapters/asyncapi.d.ts +20 -0
  6. package/dist/adapters/asyncapi.d.ts.map +1 -0
  7. package/dist/adapters/asyncapi.js +95 -0
  8. package/dist/adapters/asyncapi.js.map +1 -0
  9. package/dist/adapters/docker.d.ts +43 -0
  10. package/dist/adapters/docker.d.ts.map +1 -0
  11. package/dist/adapters/docker.js +97 -0
  12. package/dist/adapters/docker.js.map +1 -0
  13. package/dist/adapters/kubernetes.d.ts +22 -0
  14. package/dist/adapters/kubernetes.d.ts.map +1 -0
  15. package/dist/adapters/kubernetes.js +56 -0
  16. package/dist/adapters/kubernetes.js.map +1 -0
  17. package/dist/adapters/markdown.d.ts +35 -0
  18. package/dist/adapters/markdown.d.ts.map +1 -0
  19. package/dist/adapters/markdown.js +36 -0
  20. package/dist/adapters/markdown.js.map +1 -0
  21. package/dist/adapters/openapi.d.ts +21 -0
  22. package/dist/adapters/openapi.d.ts.map +1 -0
  23. package/dist/adapters/openapi.js +101 -0
  24. package/dist/adapters/openapi.js.map +1 -0
  25. package/dist/adapters/protobuf.d.ts +34 -0
  26. package/dist/adapters/protobuf.d.ts.map +1 -0
  27. package/dist/adapters/protobuf.js +221 -0
  28. package/dist/adapters/protobuf.js.map +1 -0
  29. package/dist/adapters/shared.d.ts +65 -0
  30. package/dist/adapters/shared.d.ts.map +1 -0
  31. package/dist/adapters/shared.js +95 -0
  32. package/dist/adapters/shared.js.map +1 -0
  33. package/dist/adapters/sql.d.ts +104 -0
  34. package/dist/adapters/sql.d.ts.map +1 -0
  35. package/dist/adapters/sql.js +427 -0
  36. package/dist/adapters/sql.js.map +1 -0
  37. package/dist/adapters/terraform.d.ts +40 -0
  38. package/dist/adapters/terraform.d.ts.map +1 -0
  39. package/dist/adapters/terraform.js +186 -0
  40. package/dist/adapters/terraform.js.map +1 -0
  41. package/dist/adapters/text.d.ts +24 -0
  42. package/dist/adapters/text.d.ts.map +1 -0
  43. package/dist/adapters/text.js +31 -0
  44. package/dist/adapters/text.js.map +1 -0
  45. package/dist/config-validation.d.ts +18 -0
  46. package/dist/config-validation.d.ts.map +1 -0
  47. package/dist/config-validation.js +30 -0
  48. package/dist/config-validation.js.map +1 -0
  49. package/dist/coverage.d.ts +114 -0
  50. package/dist/coverage.d.ts.map +1 -0
  51. package/dist/coverage.js +0 -0
  52. package/dist/coverage.js.map +1 -0
  53. package/dist/ids.d.ts +12 -0
  54. package/dist/ids.d.ts.map +1 -0
  55. package/dist/ids.js +42 -0
  56. package/dist/ids.js.map +1 -0
  57. package/dist/index.d.ts +55 -0
  58. package/dist/index.d.ts.map +1 -0
  59. package/dist/index.js +18 -0
  60. package/dist/index.js.map +1 -0
  61. package/dist/registry.d.ts +51 -0
  62. package/dist/registry.d.ts.map +1 -0
  63. package/dist/registry.js +86 -0
  64. package/dist/registry.js.map +1 -0
  65. package/dist/staleness.d.ts +44 -0
  66. package/dist/staleness.d.ts.map +1 -0
  67. package/dist/staleness.js +35 -0
  68. package/dist/staleness.js.map +1 -0
  69. package/dist/text/delimiter-balance.d.ts +95 -0
  70. package/dist/text/delimiter-balance.d.ts.map +1 -0
  71. package/dist/text/delimiter-balance.js +209 -0
  72. package/dist/text/delimiter-balance.js.map +1 -0
  73. package/dist/text/line-index.d.ts +61 -0
  74. package/dist/text/line-index.d.ts.map +1 -0
  75. package/dist/text/line-index.js +75 -0
  76. package/dist/text/line-index.js.map +1 -0
  77. package/dist/text/yaml-lite.d.ts +73 -0
  78. package/dist/text/yaml-lite.d.ts.map +1 -0
  79. package/dist/text/yaml-lite.js +486 -0
  80. package/dist/text/yaml-lite.js.map +1 -0
  81. package/package.json +61 -3
  82. package/src/adapters/__golden__/adr-0001-sections.json +68 -0
  83. package/src/adapters/__golden__/app-compose-services.json +24 -0
  84. package/src/adapters/__golden__/app-image-stages.json +24 -0
  85. package/src/adapters/__golden__/infra-main-sections.json +46 -0
  86. package/src/adapters/__golden__/orders-events-operations.json +24 -0
  87. package/src/adapters/__golden__/orders-proto-sections.json +57 -0
  88. package/src/adapters/__golden__/orders-schema-statements.json +68 -0
  89. package/src/adapters/__golden__/users-api-operations.json +46 -0
  90. package/src/adapters/__golden__/web-manifests-resources.json +24 -0
  91. package/src/adapters/asyncapi.ts +147 -0
  92. package/src/adapters/docker.ts +170 -0
  93. package/src/adapters/kubernetes.ts +103 -0
  94. package/src/adapters/markdown.ts +88 -0
  95. package/src/adapters/openapi.ts +156 -0
  96. package/src/adapters/protobuf.ts +286 -0
  97. package/src/adapters/shared.ts +151 -0
  98. package/src/adapters/sql.ts +555 -0
  99. package/src/adapters/terraform.ts +261 -0
  100. package/src/adapters/text.ts +72 -0
  101. package/src/config-validation.ts +41 -0
  102. package/src/coverage.ts +336 -0
  103. package/src/ids.ts +57 -0
  104. package/src/index.ts +105 -0
  105. package/src/registry.ts +155 -0
  106. package/src/staleness.ts +69 -0
  107. package/src/text/delimiter-balance.ts +295 -0
  108. package/src/text/line-index.ts +108 -0
  109. package/src/text/yaml-lite.ts +565 -0
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Staleness detection (spec.md §19.2: "Artefaktkonfiguration und
3
+ * Inhaltshash MÜSSEN Staleness bestimmen.").
4
+ *
5
+ * A context artifact goes stale when EITHER its configuration changes
6
+ * (e.g. `include`/`exclude` edited, `description` reworded, `path`
7
+ * repointed) OR its underlying file content changes (a file was edited,
8
+ * added, or removed), independently of each other — so both are hashed
9
+ * separately and compared separately; collapsing them into one combined
10
+ * hash would make "what changed" undiagnosable from the stored snapshot
11
+ * alone.
12
+ */
13
+ import type { ArtifactDocument, ContextArtifactConfig } from "@untangle-code/domain";
14
+ import { canonicalJson, sha256Hex } from "@untangle-code/domain";
15
+
16
+ /** The two independent inputs to staleness (spec.md §19.2), snapshotted together. */
17
+ export interface ArtifactStalenessSnapshot {
18
+ /** SHA-256 of the canonical JSON of the artifact's `ContextArtifactConfig`. */
19
+ configHash: string;
20
+ /**
21
+ * SHA-256 combining every current document's path and content hash
22
+ * (spec.md §19.2's "Inhaltshash"). Sorted by path first so the result is
23
+ * independent of file-discovery order.
24
+ */
25
+ contentHash: string;
26
+ }
27
+
28
+ /** SHA-256 of an artifact's canonical configuration (spec.md §19.2). */
29
+ export function computeArtifactConfigHash(config: ContextArtifactConfig): string {
30
+ return sha256Hex(canonicalJson(config));
31
+ }
32
+
33
+ /**
34
+ * Combines the per-document `contentHash`es (already SHA-256 of each
35
+ * document's raw content, per `ArtifactDocument.contentHash`) into one
36
+ * artifact-level content hash. A directory-backed artifact is stale when
37
+ * any file's content changes, a file is added, or a file is removed — all
38
+ * three change this combined hash.
39
+ */
40
+ export function computeArtifactContentHash(documents: readonly ArtifactDocument[]): string {
41
+ const sorted = [...documents]
42
+ .map((document) => ({ path: document.path, contentHash: document.contentHash }))
43
+ .sort((a, b) => a.path.localeCompare(b.path));
44
+ return sha256Hex(canonicalJson(sorted));
45
+ }
46
+
47
+ /** Snapshots both staleness inputs for one artifact's current config + documents. */
48
+ export function computeArtifactStalenessSnapshot(
49
+ config: ContextArtifactConfig,
50
+ documents: readonly ArtifactDocument[],
51
+ ): ArtifactStalenessSnapshot {
52
+ return {
53
+ configHash: computeArtifactConfigHash(config),
54
+ contentHash: computeArtifactContentHash(documents),
55
+ };
56
+ }
57
+
58
+ /**
59
+ * True when `current` differs from the last-indexed `previous` snapshot in
60
+ * either the config hash or the content hash (spec.md §19.2) — a pure
61
+ * comparison; persisting/loading the previous snapshot is a metadata-store
62
+ * concern outside this package.
63
+ */
64
+ export function isArtifactStale(
65
+ previous: ArtifactStalenessSnapshot,
66
+ current: ArtifactStalenessSnapshot,
67
+ ): boolean {
68
+ return previous.configHash !== current.configHash || previous.contentHash !== current.contentHash;
69
+ }
@@ -0,0 +1,295 @@
1
+ /**
2
+ * Plausibilitätsprüfung von Fragmentgrenzen (WP-118, BUILD_PLAN §12 E4).
3
+ *
4
+ * `coverage.ts` beantwortet die Frage „**fehlt** etwas?" — es rechnet
5
+ * Byte-Abdeckung und meldet jeden Bereich, der in keinem Chunk landet.
6
+ * Diese Frage ist blind gegenüber dem zweiten Fehlerbild: es fehlt nichts,
7
+ * aber es ist **falsch geschnitten**. Der Auslöser war die SQL-Zerlegung
8
+ * (`adapters/sql.ts`), die `INSERT INTO t VALUES ('it\'s a test; nested
9
+ * clause', 2);` in zwei syntaktisch kaputte Fragmente zerriss — bei
10
+ * lückenloser Byte-Abdeckung, ohne leere oder überlappende Ranges, also bei
11
+ * **null** Ledger-Einträgen. Der Verlust ist real (kein Fragment ist ein
12
+ * gültiges Statement, die Einbettung beider ist Müll), nur eben unsichtbar.
13
+ *
14
+ * Dieses Modul liefert das fehlende Signal, format-agnostisch und ohne
15
+ * Grammatik: ein korrekt geschnittenes Fragment einer klammer- und
16
+ * zeichenketten-strukturierten Sprache ist in sich **ausbalanciert**. Bleibt
17
+ * eine Zeichenkette, ein Blockkommentar, ein Heredoc oder eine Klammer am
18
+ * Fragmentende offen, dann verlief die Schnittkante mitten durch ein
19
+ * Konstrukt — unabhängig davon, welcher Adapter geschnitten hat und warum.
20
+ *
21
+ * Bewusste Asymmetrie zum jeweiligen Zerleger: dieser Prüfer darf
22
+ * **misstrauischer** sein als der Zerleger. Ein Fehlalarm kostet eine
23
+ * Warnzeile, ein verschwiegener Fehlschnitt kostet einen unbrauchbaren
24
+ * Chunk (E4). Deshalb kennt er z. B. Dollar-Quoting auch dort, wo ein
25
+ * Dialekt es nicht erwarten ließe.
26
+ *
27
+ * Was er NICHT ist: ein Parser. Er kennt keine Schlüsselwörter, keine
28
+ * Ausdrücke, keine Typen. Ein Fragment, das balanciert und trotzdem
29
+ * bedeutungslos ist (`FROM orders WHERE`), passiert ihn unbeanstandet. Das
30
+ * ist die dokumentierte Grenze — er schließt die Lücke „mitten durch ein
31
+ * Konstrukt geschnitten", nicht „semantisch unvollständig".
32
+ */
33
+
34
+ /** Eine Zeichenketten-/Bezeichner-Anführungskonvention (`'…'`, `"…"`, `` `…` ``). */
35
+ export interface QuoteRule {
36
+ /** Öffnende Zeichenfolge, meist ein einzelnes Zeichen. */
37
+ readonly open: string;
38
+ /** Schließende Zeichenfolge; bei symmetrischen Anführungszeichen gleich `open`. */
39
+ readonly close: string;
40
+ /** Ob ein `\` innerhalb des Literals das folgende Zeichen maskiert (dialektabhängig, siehe `adapters/sql.ts`). */
41
+ readonly backslashEscapes: boolean;
42
+ /** Ob ein verdoppeltes Schlusszeichen (`''`) im Literal ein maskiertes Anführungszeichen ist. */
43
+ readonly doubledEscapes: boolean;
44
+ /** Ob das Literal einen Zeilenumbruch enthalten darf. Ist es das nicht, ist ein Zeilenende im Literal selbst schon ein Befund. */
45
+ readonly spansLines: boolean;
46
+ }
47
+
48
+ /** Ein Klammerpaar, dessen Verschachtelung aufgehen muss. */
49
+ export interface BracketRule {
50
+ readonly open: string;
51
+ readonly close: string;
52
+ }
53
+
54
+ /** Ein Blockkommentar-Paar (`/*` … `*\/`). */
55
+ export interface BlockCommentRule {
56
+ readonly open: string;
57
+ readonly close: string;
58
+ }
59
+
60
+ /**
61
+ * Heredoc-Konvention. Bisher nur `"hcl"`: `<<TAG` bzw. `<<-TAG`, beendet
62
+ * durch eine Zeile, deren getrimmter Inhalt genau `TAG` ist. Der Inhalt
63
+ * eines Heredocs ist Rohtext — Klammern und Anführungszeichen darin zählen
64
+ * nicht, sonst meldet jedes eingebettete Shell-Skript einen Fehlalarm.
65
+ */
66
+ export type HeredocStyle = "hcl";
67
+
68
+ export interface DelimiterScanOptions {
69
+ readonly quotes: readonly QuoteRule[];
70
+ readonly brackets: readonly BracketRule[];
71
+ /** Zeilenkommentar-Präfixe (`"--"`, `"//"`, `"#"`), in Prüfreihenfolge. */
72
+ readonly lineComments: readonly string[];
73
+ readonly blockComment?: BlockCommentRule;
74
+ /**
75
+ * `$tag$ … $tag$`-Literale erkennen. Ein Öffner zählt nur, wenn davor
76
+ * kein Bezeichnerzeichen steht — sonst würde ein Bezeichner mit zwei
77
+ * `$` (`my$col$x`) fälschlich ein Literal öffnen.
78
+ */
79
+ readonly dollarQuotedStrings?: boolean;
80
+ readonly heredocs?: HeredocStyle;
81
+ }
82
+
83
+ export type DelimiterImbalanceKind =
84
+ | "unterminated_string"
85
+ | "unterminated_block_comment"
86
+ | "unterminated_heredoc"
87
+ | "unclosed_bracket"
88
+ | "unmatched_closing_bracket";
89
+
90
+ export interface DelimiterImbalance {
91
+ readonly kind: DelimiterImbalanceKind;
92
+ /** Das beteiligte Trennzeichen, wörtlich (`"'"`, `"("`, `"<<EOF"`). */
93
+ readonly delimiter: string;
94
+ /** 0-basierter Offset in der geprüften Zeichenkette (UTF-16-Einheiten), an dem das Konstrukt geöffnet wurde. */
95
+ readonly offset: number;
96
+ /** 1-basierte Zeile INNERHALB des geprüften Fragments (nicht des Dokuments). */
97
+ readonly line: number;
98
+ }
99
+
100
+ const IDENTIFIER_CHARACTER = /[A-Za-z0-9_]/;
101
+ const IDENTIFIER_START = /[A-Za-z_]/;
102
+ const HEREDOC_OPEN = /<<-?([A-Za-z_][A-Za-z0-9_]*)/y;
103
+
104
+ /** Höchstzahl gemeldeter Befunde je Fragment — ein durchgehend kaputtes Fragment soll den Ledger nicht fluten. */
105
+ const MAX_IMBALANCES = 8;
106
+
107
+ function lineOf(text: string, offset: number): number {
108
+ let line = 1;
109
+ for (let i = 0; i < offset && i < text.length; i += 1) {
110
+ if (text[i] === "\n") line += 1;
111
+ }
112
+ return line;
113
+ }
114
+
115
+ /**
116
+ * `$tag$`-Öffner an Position `i`, oder `undefined`. Der Tag darf leer sein
117
+ * (`$$`); ein vorangehendes Bezeichnerzeichen verhindert die Erkennung.
118
+ */
119
+ function matchDollarQuote(text: string, i: number): string | undefined {
120
+ if (text[i] !== "$") return undefined;
121
+ const before = i > 0 ? text[i - 1]! : "";
122
+ if (before !== "" && (IDENTIFIER_CHARACTER.test(before) || before === "$")) return undefined;
123
+ let j = i + 1;
124
+ if (j < text.length && IDENTIFIER_START.test(text[j]!)) {
125
+ j += 1;
126
+ while (j < text.length && IDENTIFIER_CHARACTER.test(text[j]!)) j += 1;
127
+ }
128
+ if (text[j] !== "$") return undefined;
129
+ return text.slice(i, j + 1);
130
+ }
131
+
132
+ interface QuoteScanResult {
133
+ /** Position nach dem Literal (bzw. am Zeilenumbruch/Textende, wenn es offen blieb). */
134
+ readonly next: number;
135
+ readonly terminated: boolean;
136
+ }
137
+
138
+ /** Überliest ein Literal ab dem Öffner an `start`. */
139
+ function scanQuoted(text: string, start: number, rule: QuoteRule): QuoteScanResult {
140
+ let i = start + rule.open.length;
141
+ while (i < text.length) {
142
+ if (!rule.spansLines && text[i] === "\n") return { next: i, terminated: false };
143
+ if (rule.backslashEscapes && text[i] === "\\") {
144
+ i += 2;
145
+ continue;
146
+ }
147
+ if (text.startsWith(rule.close, i)) {
148
+ if (rule.doubledEscapes && text.startsWith(rule.close, i + rule.close.length)) {
149
+ i += rule.close.length * 2;
150
+ continue;
151
+ }
152
+ return { next: i + rule.close.length, terminated: true };
153
+ }
154
+ i += 1;
155
+ }
156
+ return { next: text.length, terminated: false };
157
+ }
158
+
159
+ /** Überliest ein `$tag$`-Literal ab dem Öffner an `start`. */
160
+ function scanDollarQuoted(text: string, start: number, tag: string): QuoteScanResult {
161
+ const close = text.indexOf(tag, start + tag.length);
162
+ if (close === -1) return { next: text.length, terminated: false };
163
+ return { next: close + tag.length, terminated: true };
164
+ }
165
+
166
+ /** Überliest ein Heredoc ab dem `<<TAG`-Öffner an `start`. */
167
+ function scanHeredoc(text: string, start: number, opener: string, tag: string): QuoteScanResult {
168
+ let lineStart = text.indexOf("\n", start + opener.length);
169
+ if (lineStart === -1) return { next: text.length, terminated: false };
170
+ lineStart += 1;
171
+ while (lineStart <= text.length) {
172
+ const newline = text.indexOf("\n", lineStart);
173
+ const lineEnd = newline === -1 ? text.length : newline;
174
+ if (text.slice(lineStart, lineEnd).trim() === tag) return { next: lineEnd, terminated: true };
175
+ if (newline === -1) break;
176
+ lineStart = newline + 1;
177
+ }
178
+ return { next: text.length, terminated: false };
179
+ }
180
+
181
+ /**
182
+ * Findet jede Stelle, an der `text` mitten durch ein Konstrukt geschnitten
183
+ * ist. Ergebnis ist deterministisch nach Offset sortiert (numerisch, kein
184
+ * `localeCompare`) und auf {@link MAX_IMBALANCES} Einträge begrenzt.
185
+ */
186
+ export function findDelimiterImbalances(
187
+ text: string,
188
+ options: DelimiterScanOptions,
189
+ ): DelimiterImbalance[] {
190
+ const found: { kind: DelimiterImbalanceKind; delimiter: string; offset: number }[] = [];
191
+ const openBrackets: { delimiter: string; close: string; offset: number }[] = [];
192
+ const { blockComment } = options;
193
+
194
+ let i = 0;
195
+ while (i < text.length) {
196
+ if (blockComment !== undefined && text.startsWith(blockComment.open, i)) {
197
+ const close = text.indexOf(blockComment.close, i + blockComment.open.length);
198
+ if (close === -1) {
199
+ found.push({ kind: "unterminated_block_comment", delimiter: blockComment.open, offset: i });
200
+ i = text.length;
201
+ break;
202
+ }
203
+ i = close + blockComment.close.length;
204
+ continue;
205
+ }
206
+
207
+ const lineComment = options.lineComments.find((prefix) => text.startsWith(prefix, i));
208
+ if (lineComment !== undefined) {
209
+ const newline = text.indexOf("\n", i);
210
+ i = newline === -1 ? text.length : newline + 1;
211
+ continue;
212
+ }
213
+
214
+ if (options.heredocs === "hcl" && text.startsWith("<<", i)) {
215
+ HEREDOC_OPEN.lastIndex = i;
216
+ const match = HEREDOC_OPEN.exec(text);
217
+ if (match !== null) {
218
+ const result = scanHeredoc(text, i, match[0], match[1]!);
219
+ if (!result.terminated)
220
+ found.push({ kind: "unterminated_heredoc", delimiter: match[0], offset: i });
221
+ i = result.next;
222
+ continue;
223
+ }
224
+ }
225
+
226
+ if (options.dollarQuotedStrings === true) {
227
+ const tag = matchDollarQuote(text, i);
228
+ if (tag !== undefined) {
229
+ const result = scanDollarQuoted(text, i, tag);
230
+ if (!result.terminated)
231
+ found.push({ kind: "unterminated_string", delimiter: tag, offset: i });
232
+ i = result.next;
233
+ continue;
234
+ }
235
+ }
236
+
237
+ const quote = options.quotes.find((rule) => text.startsWith(rule.open, i));
238
+ if (quote !== undefined) {
239
+ const result = scanQuoted(text, i, quote);
240
+ if (!result.terminated)
241
+ found.push({ kind: "unterminated_string", delimiter: quote.open, offset: i });
242
+ i = Math.max(result.next, i + 1);
243
+ continue;
244
+ }
245
+
246
+ const opening = options.brackets.find((rule) => text.startsWith(rule.open, i));
247
+ if (opening !== undefined) {
248
+ openBrackets.push({ delimiter: opening.open, close: opening.close, offset: i });
249
+ i += opening.open.length;
250
+ continue;
251
+ }
252
+
253
+ const closing = options.brackets.find((rule) => text.startsWith(rule.close, i));
254
+ if (closing !== undefined) {
255
+ const top = openBrackets.at(-1);
256
+ if (top !== undefined && top.close === closing.close) {
257
+ openBrackets.pop();
258
+ } else {
259
+ // Ein Schlusszeichen ohne passenden Öffner IM FRAGMENT: die
260
+ // Schnittkante lag hinter dem zugehörigen Öffner.
261
+ found.push({ kind: "unmatched_closing_bracket", delimiter: closing.close, offset: i });
262
+ }
263
+ i += closing.close.length;
264
+ continue;
265
+ }
266
+
267
+ i += 1;
268
+ }
269
+
270
+ for (const open of openBrackets) {
271
+ found.push({ kind: "unclosed_bracket", delimiter: open.delimiter, offset: open.offset });
272
+ }
273
+
274
+ return found
275
+ .sort((a, b) => a.offset - b.offset)
276
+ .slice(0, MAX_IMBALANCES)
277
+ .map((entry) => ({ ...entry, line: lineOf(text, entry.offset) }));
278
+ }
279
+
280
+ const IMBALANCE_LABELS: Record<DelimiterImbalanceKind, string> = {
281
+ unterminated_string: "unterminated string literal opened by",
282
+ unterminated_block_comment: "unterminated block comment opened by",
283
+ unterminated_heredoc: "unterminated heredoc opened by",
284
+ unclosed_bracket: "unclosed bracket",
285
+ unmatched_closing_bracket: "closing bracket without a matching opener in this fragment:",
286
+ };
287
+
288
+ /** Einzeilige, menschenlesbare Zusammenfassung für den `contentPreview` eines Ledger-Eintrags. */
289
+ export function describeDelimiterImbalances(imbalances: readonly DelimiterImbalance[]): string {
290
+ const parts = imbalances.map(
291
+ (entry) =>
292
+ `${IMBALANCE_LABELS[entry.kind]} ${JSON.stringify(entry.delimiter)} at fragment line ${entry.line}`,
293
+ );
294
+ return `implausible fragment boundary: ${parts.join("; ")}`;
295
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Line-oriented byte indexing shared by every format adapter.
3
+ *
4
+ * Every adapter in this package chunks by *line range* (headings, top-level
5
+ * SQL statements, brace-delimited blocks, YAML documents, …), never by raw
6
+ * character offset, so a single small utility converts a 1-based inclusive
7
+ * line span into the byte-accurate `SourceRange` domain requires
8
+ * (`startLine`/`endLine` 1-based inclusive, `startByte`/`endByte` 0-based
9
+ * half-open — see `packages/domain/src/types/common.ts`).
10
+ *
11
+ * Splitting only ever happens right after a `\n` (0x0A, single ASCII byte,
12
+ * never a UTF-8 continuation byte), so every line boundary this module
13
+ * produces is automatically a safe UTF-8 boundary — no separate multi-byte
14
+ * safety check is needed the way the chunker package needs one for
15
+ * mid-line token-boundary splits.
16
+ */
17
+ import type { SourceRange } from "@untangle-code/domain";
18
+
19
+ export interface LineIndex {
20
+ readonly buffer: Buffer;
21
+ /**
22
+ * Byte offset of the start of each line (0-based), one entry per line,
23
+ * PLUS one trailing sentinel equal to `buffer.length` — so
24
+ * `lineStarts[lineCount]` is always a valid "end of file" byte offset.
25
+ */
26
+ readonly lineStarts: number[];
27
+ readonly lineCount: number;
28
+ }
29
+
30
+ /** Builds a `LineIndex` from full document content (a decoded UTF-8 string). */
31
+ export function buildLineIndex(content: string): LineIndex {
32
+ const buffer = Buffer.from(content, "utf8");
33
+ const lineStarts: number[] = [0];
34
+ for (let i = 0; i < buffer.length; i += 1) {
35
+ if (buffer[i] === 0x0a) {
36
+ lineStarts.push(i + 1);
37
+ }
38
+ }
39
+ const lineCount = lineStarts.length;
40
+ lineStarts.push(buffer.length);
41
+ return { buffer, lineStarts, lineCount };
42
+ }
43
+
44
+ function lineStartByte(index: LineIndex, line: number): number {
45
+ const clamped = Math.min(Math.max(line, 1), index.lineStarts.length);
46
+ return index.lineStarts[clamped - 1] ?? index.buffer.length;
47
+ }
48
+
49
+ /**
50
+ * 1-based line number containing byte offset `byteOffset` (clamped into
51
+ * `[0, buffer.length]`). Binary search over `lineStarts` (sorted ascending,
52
+ * one entry per line plus the EOF sentinel — see {@link LineIndex}), used by
53
+ * any scanner that tracks byte offsets directly (e.g. the SQL adapter's
54
+ * per-statement spans) and needs the matching 1-based line number without
55
+ * re-walking the buffer from the start.
56
+ */
57
+ export function lineNumberForByte(index: LineIndex, byteOffset: number): number {
58
+ const target = Math.min(Math.max(byteOffset, 0), index.buffer.length);
59
+ let low = 0;
60
+ let high = index.lineCount - 1;
61
+ while (low < high) {
62
+ const mid = Math.ceil((low + high) / 2);
63
+ if ((index.lineStarts[mid] ?? 0) <= target) {
64
+ low = mid;
65
+ } else {
66
+ high = mid - 1;
67
+ }
68
+ }
69
+ return low + 1;
70
+ }
71
+
72
+ /**
73
+ * Builds a `SourceRange` for the 1-based inclusive line span
74
+ * `[startLine, endLine]`. `endByte` is the byte offset of the start of the
75
+ * line AFTER `endLine` (or end-of-file for the last line), so two chunks
76
+ * built from adjacent line spans (`endLine` of one === `startLine - 1` of
77
+ * the next) are byte-adjacent with no gap and no overlap.
78
+ */
79
+ export function rangeForLines(index: LineIndex, startLine: number, endLine: number): SourceRange {
80
+ const startByte = lineStartByte(index, startLine);
81
+ const endByte = lineStartByte(index, endLine + 1);
82
+ return { startLine, endLine, startByte, endByte };
83
+ }
84
+
85
+ /** Decodes the UTF-8 bytes `[startByte, endByte)` as a string. */
86
+ export function sliceBytes(index: LineIndex, startByte: number, endByte: number): string {
87
+ return index.buffer.toString("utf8", startByte, endByte);
88
+ }
89
+
90
+ /** Convenience: raw content of the 1-based inclusive line span. */
91
+ export function sliceLines(index: LineIndex, startLine: number, endLine: number): string {
92
+ const range = rangeForLines(index, startLine, endLine);
93
+ return sliceBytes(index, range.startByte, range.endByte);
94
+ }
95
+
96
+ /**
97
+ * Splits content into physical lines WITHOUT their line terminator, plus a
98
+ * `LineIndex` for range/byte lookups. Adapters that scan line-by-line
99
+ * (Markdown headings, Dockerfile `FROM`, brace-block scanners, …) use this
100
+ * once per document.
101
+ */
102
+ export function splitLines(content: string): { lines: string[]; index: LineIndex } {
103
+ const index = buildLineIndex(content);
104
+ // `\r\n` and `\n` both split correctly here; a trailing `\r` (if any) is
105
+ // stripped so heading/keyword regexes don't have to account for it.
106
+ const lines = content.split("\n").map((line) => (line.endsWith("\r") ? line.slice(0, -1) : line));
107
+ return { lines, index };
108
+ }