@untangle-code/context-artifacts 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 +247 -2
- package/THIRD_PARTY_LICENSES.md +24 -0
- package/dist/adapters/asyncapi.d.ts +20 -0
- package/dist/adapters/asyncapi.d.ts.map +1 -0
- package/dist/adapters/asyncapi.js +95 -0
- package/dist/adapters/asyncapi.js.map +1 -0
- package/dist/adapters/docker.d.ts +43 -0
- package/dist/adapters/docker.d.ts.map +1 -0
- package/dist/adapters/docker.js +97 -0
- package/dist/adapters/docker.js.map +1 -0
- package/dist/adapters/kubernetes.d.ts +22 -0
- package/dist/adapters/kubernetes.d.ts.map +1 -0
- package/dist/adapters/kubernetes.js +56 -0
- package/dist/adapters/kubernetes.js.map +1 -0
- package/dist/adapters/markdown.d.ts +35 -0
- package/dist/adapters/markdown.d.ts.map +1 -0
- package/dist/adapters/markdown.js +36 -0
- package/dist/adapters/markdown.js.map +1 -0
- package/dist/adapters/openapi.d.ts +21 -0
- package/dist/adapters/openapi.d.ts.map +1 -0
- package/dist/adapters/openapi.js +101 -0
- package/dist/adapters/openapi.js.map +1 -0
- package/dist/adapters/protobuf.d.ts +34 -0
- package/dist/adapters/protobuf.d.ts.map +1 -0
- package/dist/adapters/protobuf.js +221 -0
- package/dist/adapters/protobuf.js.map +1 -0
- package/dist/adapters/shared.d.ts +65 -0
- package/dist/adapters/shared.d.ts.map +1 -0
- package/dist/adapters/shared.js +95 -0
- package/dist/adapters/shared.js.map +1 -0
- package/dist/adapters/sql.d.ts +104 -0
- package/dist/adapters/sql.d.ts.map +1 -0
- package/dist/adapters/sql.js +427 -0
- package/dist/adapters/sql.js.map +1 -0
- package/dist/adapters/terraform.d.ts +40 -0
- package/dist/adapters/terraform.d.ts.map +1 -0
- package/dist/adapters/terraform.js +186 -0
- package/dist/adapters/terraform.js.map +1 -0
- package/dist/adapters/text.d.ts +24 -0
- package/dist/adapters/text.d.ts.map +1 -0
- package/dist/adapters/text.js +31 -0
- package/dist/adapters/text.js.map +1 -0
- package/dist/config-validation.d.ts +18 -0
- package/dist/config-validation.d.ts.map +1 -0
- package/dist/config-validation.js +30 -0
- package/dist/config-validation.js.map +1 -0
- package/dist/coverage.d.ts +114 -0
- package/dist/coverage.d.ts.map +1 -0
- package/dist/coverage.js +0 -0
- package/dist/coverage.js.map +1 -0
- package/dist/ids.d.ts +12 -0
- package/dist/ids.d.ts.map +1 -0
- package/dist/ids.js +42 -0
- package/dist/ids.js.map +1 -0
- package/dist/index.d.ts +55 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +18 -0
- package/dist/index.js.map +1 -0
- package/dist/registry.d.ts +51 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +86 -0
- package/dist/registry.js.map +1 -0
- package/dist/staleness.d.ts +44 -0
- package/dist/staleness.d.ts.map +1 -0
- package/dist/staleness.js +35 -0
- package/dist/staleness.js.map +1 -0
- package/dist/text/delimiter-balance.d.ts +95 -0
- package/dist/text/delimiter-balance.d.ts.map +1 -0
- package/dist/text/delimiter-balance.js +209 -0
- package/dist/text/delimiter-balance.js.map +1 -0
- package/dist/text/line-index.d.ts +61 -0
- package/dist/text/line-index.d.ts.map +1 -0
- package/dist/text/line-index.js +75 -0
- package/dist/text/line-index.js.map +1 -0
- package/dist/text/yaml-lite.d.ts +73 -0
- package/dist/text/yaml-lite.d.ts.map +1 -0
- package/dist/text/yaml-lite.js +486 -0
- package/dist/text/yaml-lite.js.map +1 -0
- package/package.json +61 -3
- package/src/adapters/__golden__/adr-0001-sections.json +68 -0
- package/src/adapters/__golden__/app-compose-services.json +24 -0
- package/src/adapters/__golden__/app-image-stages.json +24 -0
- package/src/adapters/__golden__/infra-main-sections.json +46 -0
- package/src/adapters/__golden__/orders-events-operations.json +24 -0
- package/src/adapters/__golden__/orders-proto-sections.json +57 -0
- package/src/adapters/__golden__/orders-schema-statements.json +68 -0
- package/src/adapters/__golden__/users-api-operations.json +46 -0
- package/src/adapters/__golden__/web-manifests-resources.json +24 -0
- package/src/adapters/asyncapi.ts +147 -0
- package/src/adapters/docker.ts +170 -0
- package/src/adapters/kubernetes.ts +103 -0
- package/src/adapters/markdown.ts +88 -0
- package/src/adapters/openapi.ts +156 -0
- package/src/adapters/protobuf.ts +286 -0
- package/src/adapters/shared.ts +151 -0
- package/src/adapters/sql.ts +555 -0
- package/src/adapters/terraform.ts +261 -0
- package/src/adapters/text.ts +72 -0
- package/src/config-validation.ts +41 -0
- package/src/coverage.ts +336 -0
- package/src/ids.ts +57 -0
- package/src/index.ts +105 -0
- package/src/registry.ts +155 -0
- package/src/staleness.ts +69 -0
- package/src/text/delimiter-balance.ts +295 -0
- package/src/text/line-index.ts +108 -0
- package/src/text/yaml-lite.ts +565 -0
package/src/staleness.ts
ADDED
|
@@ -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
|
+
}
|