@untangle-code/file-graph 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 +284 -2
- package/THIRD_PARTY_LICENSES.md +29 -0
- package/dist/assignment.d.ts +15 -0
- package/dist/assignment.d.ts.map +1 -0
- package/dist/assignment.js +75 -0
- package/dist/assignment.js.map +1 -0
- package/dist/boundaries/endpoints.d.ts +11 -0
- package/dist/boundaries/endpoints.d.ts.map +1 -0
- package/dist/boundaries/endpoints.js +195 -0
- package/dist/boundaries/endpoints.js.map +1 -0
- package/dist/boundaries/index.d.ts +9 -0
- package/dist/boundaries/index.d.ts.map +1 -0
- package/dist/boundaries/index.js +93 -0
- package/dist/boundaries/index.js.map +1 -0
- package/dist/boundaries/requests.d.ts +12 -0
- package/dist/boundaries/requests.d.ts.map +1 -0
- package/dist/boundaries/requests.js +311 -0
- package/dist/boundaries/requests.js.map +1 -0
- package/dist/edges.d.ts +49 -0
- package/dist/edges.d.ts.map +1 -0
- package/dist/edges.js +223 -0
- package/dist/edges.js.map +1 -0
- package/dist/file-index.d.ts +37 -0
- package/dist/file-index.d.ts.map +1 -0
- package/dist/file-index.js +103 -0
- package/dist/file-index.js.map +1 -0
- package/dist/graph.d.ts +160 -0
- package/dist/graph.d.ts.map +1 -0
- package/dist/graph.js +448 -0
- package/dist/graph.js.map +1 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +48 -0
- package/dist/index.js.map +1 -0
- package/dist/metrics.d.ts +67 -0
- package/dist/metrics.d.ts.map +1 -0
- package/dist/metrics.js +272 -0
- package/dist/metrics.js.map +1 -0
- package/dist/package-catalog.d.ts +43 -0
- package/dist/package-catalog.d.ts.map +1 -0
- package/dist/package-catalog.js +136 -0
- package/dist/package-catalog.js.map +1 -0
- package/dist/paths.d.ts +56 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +129 -0
- package/dist/paths.js.map +1 -0
- package/dist/persistence.d.ts +83 -0
- package/dist/persistence.d.ts.map +1 -0
- package/dist/persistence.js +538 -0
- package/dist/persistence.js.map +1 -0
- package/dist/resolve/candidates.d.ts +52 -0
- package/dist/resolve/candidates.d.ts.map +1 -0
- package/dist/resolve/candidates.js +202 -0
- package/dist/resolve/candidates.js.map +1 -0
- package/dist/resolve/package-exports.d.ts +31 -0
- package/dist/resolve/package-exports.d.ts.map +1 -0
- package/dist/resolve/package-exports.js +229 -0
- package/dist/resolve/package-exports.js.map +1 -0
- package/dist/resolve/pipeline.d.ts +11 -0
- package/dist/resolve/pipeline.d.ts.map +1 -0
- package/dist/resolve/pipeline.js +109 -0
- package/dist/resolve/pipeline.js.map +1 -0
- package/dist/resolve/resolvers.d.ts +25 -0
- package/dist/resolve/resolvers.d.ts.map +1 -0
- package/dist/resolve/resolvers.js +367 -0
- package/dist/resolve/resolvers.js.map +1 -0
- package/dist/restore.d.ts +75 -0
- package/dist/restore.d.ts.map +1 -0
- package/dist/restore.js +136 -0
- package/dist/restore.js.map +1 -0
- package/dist/topology/compose.d.ts +27 -0
- package/dist/topology/compose.d.ts.map +1 -0
- package/dist/topology/compose.js +208 -0
- package/dist/topology/compose.js.map +1 -0
- package/dist/topology/jsonc.d.ts +38 -0
- package/dist/topology/jsonc.d.ts.map +1 -0
- package/dist/topology/jsonc.js +137 -0
- package/dist/topology/jsonc.js.map +1 -0
- package/dist/topology/load.d.ts +61 -0
- package/dist/topology/load.d.ts.map +1 -0
- package/dist/topology/load.js +436 -0
- package/dist/topology/load.js.map +1 -0
- package/dist/topology/manifest.d.ts +48 -0
- package/dist/topology/manifest.d.ts.map +1 -0
- package/dist/topology/manifest.js +109 -0
- package/dist/topology/manifest.js.map +1 -0
- package/dist/topology/project-config.d.ts +39 -0
- package/dist/topology/project-config.d.ts.map +1 -0
- package/dist/topology/project-config.js +150 -0
- package/dist/topology/project-config.js.map +1 -0
- package/dist/topology/workspace-definition.d.ts +26 -0
- package/dist/topology/workspace-definition.d.ts.map +1 -0
- package/dist/topology/workspace-definition.js +104 -0
- package/dist/topology/workspace-definition.js.map +1 -0
- package/dist/traverse.d.ts +49 -0
- package/dist/traverse.d.ts.map +1 -0
- package/dist/traverse.js +167 -0
- package/dist/traverse.js.map +1 -0
- package/dist/types.d.ts +332 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +63 -3
- package/src/assignment.ts +83 -0
- package/src/boundaries/endpoints.ts +273 -0
- package/src/boundaries/index.ts +118 -0
- package/src/boundaries/requests.ts +411 -0
- package/src/edges.ts +334 -0
- package/src/file-index.ts +127 -0
- package/src/graph.ts +591 -0
- package/src/index.ts +58 -0
- package/src/metrics.ts +350 -0
- package/src/package-catalog.ts +145 -0
- package/src/paths.ts +138 -0
- package/src/persistence.ts +746 -0
- package/src/resolve/candidates.ts +230 -0
- package/src/resolve/package-exports.ts +268 -0
- package/src/resolve/pipeline.ts +140 -0
- package/src/resolve/resolvers.ts +468 -0
- package/src/restore.ts +201 -0
- package/src/topology/compose.ts +268 -0
- package/src/topology/jsonc.ts +147 -0
- package/src/topology/load.ts +644 -0
- package/src/topology/manifest.ts +132 -0
- package/src/topology/project-config.ts +183 -0
- package/src/topology/workspace-definition.ts +115 -0
- package/src/traverse.ts +232 -0
- package/src/types.ts +372 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Container-Kompositionsdatei → Dienstknoten und Dienstkanten.
|
|
3
|
+
*
|
|
4
|
+
* WARUM DAS HIERHER GEHOERT (Nutzerwunsch A3): Ein Monorepo ist nicht nur ein
|
|
5
|
+
* Importgraph. Wer wissen will, was bricht, wenn er etwas aendert, muss auch
|
|
6
|
+
* die Betriebsebene sehen: `frontend`, `backend`, `worker` sind Knoten einer
|
|
7
|
+
* groeberen Ebene, und ihre Startreihenfolge (`depends_on`) und ihre
|
|
8
|
+
* Erreichbarkeit (gemeinsame Netzwerke) sind Kanten, die kein Import je
|
|
9
|
+
* abbildet.
|
|
10
|
+
*
|
|
11
|
+
* ZWEI KANTENARTEN, BEWUSST GETRENNT:
|
|
12
|
+
* - `depends_on` ist GERICHTET und beantwortet „wer braucht wen zuerst";
|
|
13
|
+
* - `shared-network` ist UNGERICHTET (als zwei gegenlaeufige Kanten gefuehrt)
|
|
14
|
+
* und beantwortet „wer kann wen ueberhaupt erreichen". Zwei Dienste ohne
|
|
15
|
+
* `depends_on`, aber im selben Netzwerk, koennen einander trotzdem
|
|
16
|
+
* aufrufen — das waere sonst ein unsichtbarer Zusammenhang.
|
|
17
|
+
*
|
|
18
|
+
* IMPLIZITES `default`-NETZWERK: Compose legt fuer jede Datei ein Netzwerk
|
|
19
|
+
* `default` an, dem jeder Dienst ohne eigene `networks`-Angabe beitritt. Wer
|
|
20
|
+
* das weglaesst, meldet fuer die haeufigste aller Kompositionsdateien — eine
|
|
21
|
+
* ohne jede `networks`-Angabe — null Erreichbarkeitskanten, obwohl in
|
|
22
|
+
* Wirklichkeit jeder jeden erreicht.
|
|
23
|
+
*/
|
|
24
|
+
import { compareCodepoints, compareByKeys } from "@untangle-code/domain";
|
|
25
|
+
import { parse as parseYaml } from "yaml";
|
|
26
|
+
|
|
27
|
+
import { dirnameOf, joinRelative, normalizePath } from "../paths.js";
|
|
28
|
+
import type { ContainerServiceEdgeV1, ContainerServiceV1 } from "../types.js";
|
|
29
|
+
|
|
30
|
+
/** Name des impliziten Netzwerks, dem Dienste ohne eigene Angabe beitreten. */
|
|
31
|
+
export const IMPLICIT_DEFAULT_NETWORK = "default";
|
|
32
|
+
|
|
33
|
+
/** Dateinamen, die als Kompositionsdatei gelten (Compose-Spezifikation). */
|
|
34
|
+
export const COMPOSE_FILE_NAMES = [
|
|
35
|
+
"compose.yaml",
|
|
36
|
+
"compose.yml",
|
|
37
|
+
"docker-compose.yaml",
|
|
38
|
+
"docker-compose.yml",
|
|
39
|
+
] as const;
|
|
40
|
+
|
|
41
|
+
export interface ComposeParseResultV1 {
|
|
42
|
+
services: ContainerServiceV1[];
|
|
43
|
+
edges: ContainerServiceEdgeV1[];
|
|
44
|
+
/** Was nicht gelesen werden konnte, mit Grund (E4). */
|
|
45
|
+
warnings: string[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type ComposeReadResult =
|
|
49
|
+
{ ok: true; result: ComposeParseResultV1 } | { ok: false; error: string };
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Liest eine Kompositionsdatei.
|
|
53
|
+
*
|
|
54
|
+
* `composeFilePath` ist repo-relativ; relative Build-Kontexte werden gegen
|
|
55
|
+
* SEIN Verzeichnis aufgeloest, nicht gegen die Repositorywurzel — genau so
|
|
56
|
+
* verhaelt sich Compose auch.
|
|
57
|
+
*/
|
|
58
|
+
export function parseComposeFile(
|
|
59
|
+
repoId: string,
|
|
60
|
+
composeFilePath: string,
|
|
61
|
+
text: string,
|
|
62
|
+
): ComposeReadResult {
|
|
63
|
+
let document: unknown;
|
|
64
|
+
try {
|
|
65
|
+
document = parseYaml(text) as unknown;
|
|
66
|
+
} catch (error) {
|
|
67
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
68
|
+
}
|
|
69
|
+
if (!isObject(document)) {
|
|
70
|
+
return { ok: false, error: "compose document root is not a mapping" };
|
|
71
|
+
}
|
|
72
|
+
const servicesValue = document["services"];
|
|
73
|
+
if (!isObject(servicesValue)) {
|
|
74
|
+
return { ok: false, error: 'compose document has no "services" mapping' };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const composeFile = normalizePath(composeFilePath);
|
|
78
|
+
const composeDir = dirnameOf(composeFile);
|
|
79
|
+
const warnings: string[] = [];
|
|
80
|
+
const services: ContainerServiceV1[] = [];
|
|
81
|
+
|
|
82
|
+
for (const [name, rawService] of Object.entries(servicesValue)) {
|
|
83
|
+
if (!isObject(rawService)) {
|
|
84
|
+
warnings.push(`service "${name}" in ${composeFile} is not a mapping`);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const service: ContainerServiceV1 = {
|
|
88
|
+
name,
|
|
89
|
+
repoId,
|
|
90
|
+
composeFile,
|
|
91
|
+
dependsOn: readDependsOn(rawService, name, composeFile, warnings).names,
|
|
92
|
+
networks: readNetworks(rawService, name, composeFile, warnings),
|
|
93
|
+
};
|
|
94
|
+
const image = rawService["image"];
|
|
95
|
+
if (typeof image === "string") {
|
|
96
|
+
service.image = image;
|
|
97
|
+
}
|
|
98
|
+
const buildContext = readBuildContext(rawService, composeDir, name, composeFile, warnings);
|
|
99
|
+
if (buildContext !== undefined) {
|
|
100
|
+
service.buildContext = buildContext;
|
|
101
|
+
}
|
|
102
|
+
services.push(service);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
services.sort((left, right) => compareCodepoints(left.name, right.name));
|
|
106
|
+
|
|
107
|
+
const knownNames = new Set(services.map((service) => service.name));
|
|
108
|
+
const edges: ContainerServiceEdgeV1[] = [];
|
|
109
|
+
|
|
110
|
+
for (const [name, rawService] of Object.entries(servicesValue)) {
|
|
111
|
+
if (!isObject(rawService)) continue;
|
|
112
|
+
const dependsOn = readDependsOn(rawService, name, composeFile, warnings);
|
|
113
|
+
for (const target of dependsOn.names) {
|
|
114
|
+
if (!knownNames.has(target)) {
|
|
115
|
+
// E4: eine `depends_on`-Angabe auf einen unbekannten Dienst wird
|
|
116
|
+
// gemeldet, nicht verschluckt — sie ist fast immer ein Tippfehler.
|
|
117
|
+
warnings.push(`service "${name}" in ${composeFile} depends_on unknown service "${target}"`);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
const edge: ContainerServiceEdgeV1 = {
|
|
121
|
+
fromService: name,
|
|
122
|
+
toService: target,
|
|
123
|
+
kind: "depends_on",
|
|
124
|
+
};
|
|
125
|
+
const condition = dependsOn.conditions[target];
|
|
126
|
+
if (condition !== undefined) {
|
|
127
|
+
edge.condition = condition;
|
|
128
|
+
}
|
|
129
|
+
edges.push(edge);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Erreichbarkeit: je Netzwerk jedes geordnete Paar verschiedener Dienste.
|
|
134
|
+
const membersByNetwork = new Map<string, string[]>();
|
|
135
|
+
for (const service of services) {
|
|
136
|
+
for (const network of service.networks) {
|
|
137
|
+
const members = membersByNetwork.get(network) ?? [];
|
|
138
|
+
members.push(service.name);
|
|
139
|
+
membersByNetwork.set(network, members);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
for (const [network, rawMembers] of [...membersByNetwork.entries()].sort((left, right) =>
|
|
143
|
+
compareCodepoints(left[0], right[0]),
|
|
144
|
+
)) {
|
|
145
|
+
const members = [...rawMembers].sort(compareCodepoints);
|
|
146
|
+
for (const from of members) {
|
|
147
|
+
for (const to of members) {
|
|
148
|
+
if (from === to) continue;
|
|
149
|
+
edges.push({ fromService: from, toService: to, kind: "shared-network", network });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
edges.sort((left, right) =>
|
|
155
|
+
compareByKeys(
|
|
156
|
+
["kind", "fromService", "toService", "network"] as const,
|
|
157
|
+
sortable(left),
|
|
158
|
+
sortable(right),
|
|
159
|
+
),
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
return { ok: true, result: { services, edges, warnings } };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Vergleichsform: `compareByKeys` verlangt Strings, `network` ist optional. */
|
|
166
|
+
function sortable(edge: ContainerServiceEdgeV1): {
|
|
167
|
+
kind: string;
|
|
168
|
+
fromService: string;
|
|
169
|
+
toService: string;
|
|
170
|
+
network: string;
|
|
171
|
+
} {
|
|
172
|
+
return {
|
|
173
|
+
kind: edge.kind,
|
|
174
|
+
fromService: edge.fromService,
|
|
175
|
+
toService: edge.toService,
|
|
176
|
+
network: edge.network ?? "",
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function readDependsOn(
|
|
181
|
+
service: Record<string, unknown>,
|
|
182
|
+
serviceName: string,
|
|
183
|
+
composeFile: string,
|
|
184
|
+
warnings: string[],
|
|
185
|
+
): { names: string[]; conditions: Record<string, string> } {
|
|
186
|
+
const value = service["depends_on"];
|
|
187
|
+
const conditions: Record<string, string> = {};
|
|
188
|
+
if (value === undefined) {
|
|
189
|
+
return { names: [], conditions };
|
|
190
|
+
}
|
|
191
|
+
// Kurzform: eine Liste von Dienstnamen.
|
|
192
|
+
if (Array.isArray(value)) {
|
|
193
|
+
const names = value.filter((entry): entry is string => typeof entry === "string");
|
|
194
|
+
if (names.length !== value.length) {
|
|
195
|
+
warnings.push(`depends_on of "${serviceName}" in ${composeFile} contains non-string entries`);
|
|
196
|
+
}
|
|
197
|
+
return { names: names.sort(compareCodepoints), conditions };
|
|
198
|
+
}
|
|
199
|
+
// Langform: Name → { condition: ... }.
|
|
200
|
+
if (isObject(value)) {
|
|
201
|
+
const names: string[] = [];
|
|
202
|
+
for (const [target, options] of Object.entries(value)) {
|
|
203
|
+
names.push(target);
|
|
204
|
+
if (isObject(options) && typeof options["condition"] === "string") {
|
|
205
|
+
conditions[target] = options["condition"];
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return { names: names.sort(compareCodepoints), conditions };
|
|
209
|
+
}
|
|
210
|
+
warnings.push(`depends_on of "${serviceName}" in ${composeFile} is neither a list nor a mapping`);
|
|
211
|
+
return { names: [], conditions };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function readNetworks(
|
|
215
|
+
service: Record<string, unknown>,
|
|
216
|
+
serviceName: string,
|
|
217
|
+
composeFile: string,
|
|
218
|
+
warnings: string[],
|
|
219
|
+
): string[] {
|
|
220
|
+
const value = service["networks"];
|
|
221
|
+
if (value === undefined) {
|
|
222
|
+
return [IMPLICIT_DEFAULT_NETWORK];
|
|
223
|
+
}
|
|
224
|
+
if (Array.isArray(value)) {
|
|
225
|
+
const names = value.filter((entry): entry is string => typeof entry === "string");
|
|
226
|
+
if (names.length !== value.length) {
|
|
227
|
+
warnings.push(`networks of "${serviceName}" in ${composeFile} contains non-string entries`);
|
|
228
|
+
}
|
|
229
|
+
return names.length === 0 ? [IMPLICIT_DEFAULT_NETWORK] : names.sort(compareCodepoints);
|
|
230
|
+
}
|
|
231
|
+
if (isObject(value)) {
|
|
232
|
+
const names = Object.keys(value).sort(compareCodepoints);
|
|
233
|
+
return names.length === 0 ? [IMPLICIT_DEFAULT_NETWORK] : names;
|
|
234
|
+
}
|
|
235
|
+
warnings.push(`networks of "${serviceName}" in ${composeFile} is neither a list nor a mapping`);
|
|
236
|
+
return [IMPLICIT_DEFAULT_NETWORK];
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function readBuildContext(
|
|
240
|
+
service: Record<string, unknown>,
|
|
241
|
+
composeDir: string,
|
|
242
|
+
serviceName: string,
|
|
243
|
+
composeFile: string,
|
|
244
|
+
warnings: string[],
|
|
245
|
+
): string | undefined {
|
|
246
|
+
const build = service["build"];
|
|
247
|
+
const rawContext =
|
|
248
|
+
typeof build === "string"
|
|
249
|
+
? build
|
|
250
|
+
: isObject(build) && typeof build["context"] === "string"
|
|
251
|
+
? build["context"]
|
|
252
|
+
: undefined;
|
|
253
|
+
if (rawContext === undefined) {
|
|
254
|
+
return undefined;
|
|
255
|
+
}
|
|
256
|
+
const resolved = joinRelative(composeDir, rawContext);
|
|
257
|
+
if (resolved === null) {
|
|
258
|
+
warnings.push(
|
|
259
|
+
`build context of "${serviceName}" in ${composeFile} escapes the repository root: ${rawContext}`,
|
|
260
|
+
);
|
|
261
|
+
return undefined;
|
|
262
|
+
}
|
|
263
|
+
return resolved;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
267
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
268
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Toleranter JSON-Leser fuer Projektkonfigurationen.
|
|
3
|
+
*
|
|
4
|
+
* Warum nicht direkt `JSON.parse`: Projektkonfigurationen im
|
|
5
|
+
* TypeScript-Umfeld duerfen Kommentare und abschliessende Kommata enthalten
|
|
6
|
+
* (die `tsconfig.base.json` dieses Repositories tut es). `JSON.parse` wirft
|
|
7
|
+
* darauf, und ein geworfener Fehler wuerde bedeuten, dass saemtliche
|
|
8
|
+
* Pfad-Aliase eines Pakets stillschweigend fehlen — genau das, was E4
|
|
9
|
+
* verbietet. Deshalb ein Vorlauf, der Kommentare und abschliessende Kommata
|
|
10
|
+
* entfernt, und danach `JSON.parse`.
|
|
11
|
+
*
|
|
12
|
+
* Der Vorlauf ist zeichenweise und stringbewusst: ein `"http://x"` in einem
|
|
13
|
+
* Wert darf nicht als Zeilenkommentar gelesen werden.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Entfernt Kommentare und abschliessende Kommata; laesst alles andere unveraendert. */
|
|
17
|
+
export function stripJsonComments(text: string): string {
|
|
18
|
+
let result = "";
|
|
19
|
+
let index = 0;
|
|
20
|
+
let inString = false;
|
|
21
|
+
while (index < text.length) {
|
|
22
|
+
const char = text[index] as string;
|
|
23
|
+
if (inString) {
|
|
24
|
+
result += char;
|
|
25
|
+
if (char === "\\" && index + 1 < text.length) {
|
|
26
|
+
result += text[index + 1] as string;
|
|
27
|
+
index += 2;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (char === '"') {
|
|
31
|
+
inString = false;
|
|
32
|
+
}
|
|
33
|
+
index += 1;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (char === '"') {
|
|
37
|
+
inString = true;
|
|
38
|
+
result += char;
|
|
39
|
+
index += 1;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (char === "/" && text[index + 1] === "/") {
|
|
43
|
+
while (index < text.length && text[index] !== "\n") {
|
|
44
|
+
index += 1;
|
|
45
|
+
}
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (char === "/" && text[index + 1] === "*") {
|
|
49
|
+
index += 2;
|
|
50
|
+
while (index < text.length && !(text[index] === "*" && text[index + 1] === "/")) {
|
|
51
|
+
// Zeilenumbrueche erhalten, damit Zeilennummern in Fehlermeldungen stimmen.
|
|
52
|
+
if (text[index] === "\n") {
|
|
53
|
+
result += "\n";
|
|
54
|
+
}
|
|
55
|
+
index += 1;
|
|
56
|
+
}
|
|
57
|
+
index += 2;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
result += char;
|
|
61
|
+
index += 1;
|
|
62
|
+
}
|
|
63
|
+
return removeTrailingCommas(result);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function removeTrailingCommas(text: string): string {
|
|
67
|
+
let result = "";
|
|
68
|
+
let inString = false;
|
|
69
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
70
|
+
const char = text[index] as string;
|
|
71
|
+
if (inString) {
|
|
72
|
+
result += char;
|
|
73
|
+
if (char === "\\" && index + 1 < text.length) {
|
|
74
|
+
result += text[index + 1] as string;
|
|
75
|
+
index += 1;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (char === '"') {
|
|
79
|
+
inString = false;
|
|
80
|
+
}
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (char === '"') {
|
|
84
|
+
inString = true;
|
|
85
|
+
result += char;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (char === ",") {
|
|
89
|
+
let lookahead = index + 1;
|
|
90
|
+
while (lookahead < text.length && /\s/.test(text[lookahead] as string)) {
|
|
91
|
+
lookahead += 1;
|
|
92
|
+
}
|
|
93
|
+
const next = text[lookahead];
|
|
94
|
+
if (next === "}" || next === "]") {
|
|
95
|
+
continue; // abschliessendes Komma verwerfen
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
result += char;
|
|
99
|
+
}
|
|
100
|
+
return result;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Ergebnis eines toleranten JSON-Lesevorgangs. */
|
|
104
|
+
export type JsoncParseResult = { ok: true; value: unknown } | { ok: false; error: string };
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Liest ein JSON-Dokument mit Kommentaren. Wirft NICHT: bei einem Fehlschlag
|
|
108
|
+
* kommt `{ ok: false, error }` zurueck, damit der Aufrufer einen
|
|
109
|
+
* Ledger-Eintrag mit Grund schreiben kann, statt still ein leeres Objekt zu
|
|
110
|
+
* verwenden (E4).
|
|
111
|
+
*/
|
|
112
|
+
export function parseJsonc(text: string): JsoncParseResult {
|
|
113
|
+
try {
|
|
114
|
+
return { ok: true, value: JSON.parse(stripJsonComments(text)) as unknown };
|
|
115
|
+
} catch (error) {
|
|
116
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Typwaechter fuer „ein JSON-Objekt, keine Liste, kein null". */
|
|
121
|
+
export function isJsonObject(value: unknown): value is Record<string, unknown> {
|
|
122
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Liest ein Stringfeld eines JSON-Objekts; `undefined`, wenn es keinen String gibt. */
|
|
126
|
+
export function stringField(object: Record<string, unknown>, key: string): string | undefined {
|
|
127
|
+
const value = object[key];
|
|
128
|
+
return typeof value === "string" ? value : undefined;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Liest ein `Record<string, string>`-Feld; nicht-String-Werte werden ausgelassen. */
|
|
132
|
+
export function stringRecordField(
|
|
133
|
+
object: Record<string, unknown>,
|
|
134
|
+
key: string,
|
|
135
|
+
): Record<string, string> {
|
|
136
|
+
const value = object[key];
|
|
137
|
+
if (!isJsonObject(value)) {
|
|
138
|
+
return {};
|
|
139
|
+
}
|
|
140
|
+
const result: Record<string, string> = {};
|
|
141
|
+
for (const [entryKey, entryValue] of Object.entries(value)) {
|
|
142
|
+
if (typeof entryValue === "string") {
|
|
143
|
+
result[entryKey] = entryValue;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return result;
|
|
147
|
+
}
|