flatlock 1.0.1 → 1.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/README.md CHANGED
@@ -4,7 +4,7 @@ The Matlock of lockfile parsers - cuts through the complexity to get just the fa
4
4
 
5
5
  ## What makes `flatlock` different?
6
6
 
7
- ![matlockish](https://github.com/indexzero/flatlock/raw/main/doc/matlockish.png)
7
+ ![matlockish](https://github.com/indexzero/flatlock/raw/main/doc/img/matlockish.png)
8
8
 
9
9
  Most lockfile parsers (like `@npmcli/arborist` or `snyk-nodejs-lockfile-parser`) build the full dependency graph with edges representing relationships between packages. This is necessary for dependency resolution but overkill for many use cases.
10
10
 
@@ -1,243 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { readFile, readdir, stat, mkdir, writeFile, rm } from 'node:fs/promises';
4
- import { join, dirname } from 'node:path';
3
+ import { readdir, stat } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
5
  import { parseArgs } from 'node:util';
6
- import { fileURLToPath } from 'node:url';
7
- import crypto from 'node:crypto';
8
6
  import * as flatlock from '../src/index.js';
9
7
 
10
- const __dirname = dirname(fileURLToPath(import.meta.url));
11
-
12
- // Unique run ID for this script execution (7 char hash of timestamp)
13
- const RUN_ID = Date.now().toString(36).slice(-7);
14
- const TMP_BASE = join(__dirname, 'tmp', RUN_ID);
15
- let tmpDirCreated = false;
16
-
17
- // Comparison parsers (lazy loaded)
18
- let Arborist, yarnLockfile, parseSyml, yaml;
19
-
20
- async function loadArborist() {
21
- if (!Arborist) {
22
- const mod = await import('@npmcli/arborist');
23
- Arborist = mod.default;
24
- }
25
- return Arborist;
26
- }
27
-
28
- async function loadYarnClassic() {
29
- if (!yarnLockfile) {
30
- const mod = await import('@yarnpkg/lockfile');
31
- yarnLockfile = mod.default || mod;
32
- }
33
- return yarnLockfile;
34
- }
35
-
36
- async function loadYarnBerry() {
37
- if (!parseSyml) {
38
- const mod = await import('@yarnpkg/parsers');
39
- parseSyml = mod.parseSyml;
40
- }
41
- return parseSyml;
42
- }
43
-
44
- async function loadYaml() {
45
- if (!yaml) {
46
- const mod = await import('js-yaml');
47
- yaml = mod.default;
48
- }
49
- return yaml;
50
- }
51
-
52
- /**
53
- * Ensure the temp directory for this run exists
54
- */
55
- async function ensureTmpDir() {
56
- if (!tmpDirCreated) {
57
- await mkdir(TMP_BASE, { recursive: true });
58
- tmpDirCreated = true;
59
- }
60
- }
61
-
62
- /**
63
- * Cleanup the temp directory for this run
64
- */
65
- async function cleanup() {
66
- if (tmpDirCreated) {
67
- try {
68
- await rm(TMP_BASE, { recursive: true, force: true });
69
- } catch {
70
- // Ignore cleanup errors
71
- }
72
- }
73
- }
74
-
75
- /**
76
- * Get packages set using @npmcli/arborist
77
- *
78
- * Arborist requires a directory with package-lock.json (and optionally package.json).
79
- * We create a temp directory structure for each lockfile:
80
- * bin/tmp/<run-id>/<file-hash>/package-lock.json
81
- */
82
- async function getPackagesFromNpm(content, filepath) {
83
- const ArboristClass = await loadArborist();
84
- await ensureTmpDir();
85
-
86
- // Create unique subdir for this lockfile
87
- const fileId = crypto.createHash('md5').update(filepath).digest('hex').slice(0, 7);
88
- const tmpDir = join(TMP_BASE, fileId);
89
-
90
- await mkdir(tmpDir, { recursive: true });
91
- await writeFile(join(tmpDir, 'package-lock.json'), content);
92
-
93
- // Create minimal package.json from lockfile root entry
94
- const lockfile = JSON.parse(content);
95
- const root = lockfile.packages?.[''] || {};
96
- const pkg = {
97
- name: root.name || 'arborist-temp',
98
- version: root.version || '1.0.0'
99
- };
100
- await writeFile(join(tmpDir, 'package.json'), JSON.stringify(pkg));
101
-
102
- try {
103
- const arb = new ArboristClass({ path: tmpDir });
104
- const tree = await arb.loadVirtual();
105
-
106
- const result = new Set();
107
- let workspaceCount = 0;
108
- for (const node of tree.inventory.values()) {
109
- if (node.isRoot) continue;
110
- // Skip workspace symlinks (link:true, no version in raw lockfile)
111
- if (node.isLink) {
112
- workspaceCount++;
113
- continue;
114
- }
115
- // Skip workspace package definitions (not in node_modules)
116
- // Flatlock only yields packages from node_modules/ paths
117
- if (node.location && !node.location.includes('node_modules')) {
118
- workspaceCount++;
119
- continue;
120
- }
121
- if (node.name && node.version) {
122
- result.add(`${node.name}@${node.version}`);
123
- }
124
- }
125
- return { packages: result, workspaceCount };
126
- } finally {
127
- // Cleanup this specific lockfile's temp dir
128
- await rm(tmpDir, { recursive: true, force: true });
129
- }
130
- }
131
-
132
- /**
133
- * Get packages set using @yarnpkg/lockfile (classic)
134
- */
135
- async function getPackagesFromYarnClassic(content) {
136
- const yarnLock = await loadYarnClassic();
137
- const parse = yarnLock.parse || yarnLock.default?.parse;
138
- const { object: lockfile } = parse(content);
139
-
140
- const result = new Set();
141
- let workspaceCount = 0;
142
- for (const [key, pkg] of Object.entries(lockfile)) {
143
- if (key === '__metadata') continue;
144
-
145
- // Skip workspace/link entries - flatlock only cares about external dependencies
146
- const resolved = pkg.resolved || '';
147
- if (resolved.startsWith('file:') || resolved.startsWith('link:')) {
148
- workspaceCount++;
149
- continue;
150
- }
151
-
152
- let name;
153
- if (key.startsWith('@')) {
154
- const idx = key.indexOf('@', 1);
155
- name = key.slice(0, idx);
156
- } else {
157
- name = key.split('@')[0];
158
- }
159
- if (name && pkg.version) result.add(`${name}@${pkg.version}`);
160
- }
161
-
162
- return { packages: result, workspaceCount };
163
- }
164
-
165
- /**
166
- * Get packages set using @yarnpkg/parsers (berry)
167
- */
168
- async function getPackagesFromYarnBerry(content) {
169
- const parse = await loadYarnBerry();
170
- const lockfile = parse(content);
171
-
172
- const result = new Set();
173
- let workspaceCount = 0;
174
- for (const [key, pkg] of Object.entries(lockfile)) {
175
- if (key === '__metadata') continue;
176
-
177
- // Skip workspace/link entries - flatlock only cares about external dependencies
178
- const resolution = pkg.resolution || '';
179
- if (resolution.startsWith('workspace:') ||
180
- resolution.startsWith('portal:') ||
181
- resolution.startsWith('link:')) {
182
- workspaceCount++;
183
- continue;
184
- }
185
-
186
- let name;
187
- if (key.startsWith('@')) {
188
- const idx = key.indexOf('@', 1);
189
- name = key.slice(0, idx);
190
- } else {
191
- name = key.split('@')[0];
192
- }
193
- if (name && pkg.version) result.add(`${name}@${pkg.version}`);
194
- }
195
-
196
- return { packages: result, workspaceCount };
197
- }
198
-
199
- /**
200
- * Get packages set using js-yaml (pnpm)
201
- */
202
- async function getPackagesFromPnpm(content) {
203
- const y = await loadYaml();
204
- const lockfile = y.load(content);
205
- const packages = lockfile.packages || {};
206
-
207
- const result = new Set();
208
- let workspaceCount = 0;
209
- for (const [key, pkg] of Object.entries(packages)) {
210
- // Skip link/file entries - flatlock only cares about external dependencies
211
- // Keys can be: link:path, file:path, or @pkg@file:path
212
- if (key.startsWith('link:') || key.startsWith('file:') ||
213
- key.includes('@link:') || key.includes('@file:')) {
214
- workspaceCount++;
215
- continue;
216
- }
217
- // Also skip if resolution.type is 'directory' (workspace)
218
- if (pkg.resolution?.type === 'directory') {
219
- workspaceCount++;
220
- continue;
221
- }
222
- // pnpm keys are like /lodash@4.17.21 or /@babel/core@7.0.0
223
- const match = key.match(/^\/?(@?[^@]+)@(.+)$/);
224
- if (match) result.add(`${match[1]}@${match[2]}`);
225
- }
226
-
227
- return { packages: result, workspaceCount };
228
- }
229
-
230
- /**
231
- * Get packages set with flatlock
232
- */
233
- async function getPackagesFromFlatlock(filepath) {
234
- const result = new Set();
235
- for await (const dep of flatlock.fromPath(filepath)) {
236
- if (dep.name && dep.version) result.add(`${dep.name}@${dep.version}`);
237
- }
238
- return result;
239
- }
240
-
241
8
  /**
242
9
  * Get comparison parser name for type
243
10
  */
@@ -251,28 +18,6 @@ function getComparisonName(type) {
251
18
  }
252
19
  }
253
20
 
254
- /**
255
- * Get packages with comparison parser based on type
256
- */
257
- async function getPackagesFromComparison(type, content, filepath) {
258
- switch (type) {
259
- case 'npm': return getPackagesFromNpm(content, filepath);
260
- case 'yarn-classic': return getPackagesFromYarnClassic(content);
261
- case 'yarn-berry': return getPackagesFromYarnBerry(content);
262
- case 'pnpm': return getPackagesFromPnpm(content);
263
- default: return null;
264
- }
265
- }
266
-
267
- /**
268
- * Compare two sets and return differences
269
- */
270
- function compareSets(setA, setB) {
271
- const onlyInA = new Set([...setA].filter(x => !setB.has(x)));
272
- const onlyInB = new Set([...setB].filter(x => !setA.has(x)));
273
- return { onlyInA, onlyInB };
274
- }
275
-
276
21
  /**
277
22
  * Convert glob pattern to regex
278
23
  */
@@ -308,30 +53,21 @@ async function findFiles(dir, pattern) {
308
53
  }
309
54
 
310
55
  /**
311
- * Process a single lockfile - compare sets, not just counts
56
+ * Process a single lockfile using flatlock.compare()
312
57
  */
313
58
  async function processFile(filepath, baseDir) {
314
59
  try {
315
- const content = await readFile(filepath, 'utf8');
316
- const type = flatlock.detectType({ path: filepath, content });
60
+ const result = await flatlock.compare(filepath);
317
61
  const rel = baseDir ? filepath.replace(baseDir + '/', '') : filepath;
318
- const comparisonName = getComparisonName(type);
319
-
320
- const flatlockSet = await getPackagesFromFlatlock(filepath);
321
- let comparisonResult;
62
+ const comparisonName = getComparisonName(result.type);
322
63
 
323
- try {
324
- comparisonResult = await getPackagesFromComparison(type, content, filepath);
325
- } catch (err) {
326
- comparisonResult = null;
327
- }
328
-
329
- if (!comparisonResult) {
64
+ if (result.identical === null) {
65
+ // Unsupported type or no comparison available
330
66
  return {
331
- type,
67
+ type: result.type,
332
68
  path: rel,
333
69
  comparisonName,
334
- flatlockCount: flatlockSet.size,
70
+ flatlockCount: result.flatlockCount,
335
71
  comparisonCount: null,
336
72
  workspaceCount: 0,
337
73
  identical: null,
@@ -340,20 +76,16 @@ async function processFile(filepath, baseDir) {
340
76
  };
341
77
  }
342
78
 
343
- const { packages: comparisonSet, workspaceCount } = comparisonResult;
344
- const { onlyInA: onlyInFlatlock, onlyInB: onlyInComparison } = compareSets(flatlockSet, comparisonSet);
345
- const identical = onlyInFlatlock.size === 0 && onlyInComparison.size === 0;
346
-
347
79
  return {
348
- type,
80
+ type: result.type,
349
81
  path: rel,
350
82
  comparisonName,
351
- flatlockCount: flatlockSet.size,
352
- comparisonCount: comparisonSet.size,
353
- workspaceCount,
354
- identical,
355
- onlyInFlatlock,
356
- onlyInComparison
83
+ flatlockCount: result.flatlockCount,
84
+ comparisonCount: result.comparisonCount,
85
+ workspaceCount: result.workspaceCount,
86
+ identical: result.identical,
87
+ onlyInFlatlock: result.onlyInFlatlock,
88
+ onlyInComparison: result.onlyInComparison
357
89
  };
358
90
  } catch (err) {
359
91
  const rel = baseDir ? filepath.replace(baseDir + '/', '') : filepath;
@@ -423,92 +155,87 @@ Examples:
423
155
  let matchCount = 0;
424
156
  let mismatchCount = 0;
425
157
 
426
- try {
427
- for (const file of files) {
428
- const result = await processFile(file, baseDir);
158
+ for (const file of files) {
159
+ const result = await processFile(file, baseDir);
429
160
 
430
- if (result.error) {
431
- errorCount++;
432
- if (!values.quiet) {
433
- console.log(`\n❌ ERROR: ${result.path}`);
434
- console.log(` ${result.error}`);
435
- }
436
- continue;
161
+ if (result.error) {
162
+ errorCount++;
163
+ if (!values.quiet) {
164
+ console.log(`\n❌ ERROR: ${result.path}`);
165
+ console.log(` ${result.error}`);
437
166
  }
167
+ continue;
168
+ }
438
169
 
439
- fileCount++;
440
- totalFlatlock += result.flatlockCount;
441
- totalWorkspaces += result.workspaceCount || 0;
170
+ fileCount++;
171
+ totalFlatlock += result.flatlockCount;
172
+ totalWorkspaces += result.workspaceCount || 0;
442
173
 
443
- if (result.comparisonCount === null) {
444
- if (!values.quiet) {
445
- console.log(`\n⚠️ ${result.path}`);
446
- console.log(` flatlock: ${result.flatlockCount} packages`);
447
- console.log(` ${result.comparisonName}: unavailable`);
448
- }
449
- continue;
174
+ if (result.comparisonCount === null) {
175
+ if (!values.quiet) {
176
+ console.log(`\n⚠️ ${result.path}`);
177
+ console.log(` flatlock: ${result.flatlockCount} packages`);
178
+ console.log(` ${result.comparisonName}: unavailable`);
450
179
  }
180
+ continue;
181
+ }
451
182
 
452
- totalComparison += result.comparisonCount;
183
+ totalComparison += result.comparisonCount;
453
184
 
454
- if (result.identical) {
455
- matchCount++;
456
- if (!values.quiet) {
457
- const wsNote = result.workspaceCount > 0 ? ` (${result.workspaceCount} workspaces excluded)` : '';
458
- console.log(`✓ ${result.path}${wsNote}`);
459
- console.log(` count: flatlock=${result.flatlockCount} ${result.comparisonName}=${result.comparisonCount}`);
460
- console.log(` sets: identical`);
461
- }
462
- } else {
463
- mismatchCount++;
464
- console.log(`\n❌ ${result.path}`);
185
+ if (result.identical) {
186
+ matchCount++;
187
+ if (!values.quiet) {
188
+ const wsNote = result.workspaceCount > 0 ? ` (${result.workspaceCount} workspaces excluded)` : '';
189
+ console.log(`✓ ${result.path}${wsNote}`);
465
190
  console.log(` count: flatlock=${result.flatlockCount} ${result.comparisonName}=${result.comparisonCount}`);
466
- console.log(` sets: MISMATCH`);
467
-
468
- if (result.onlyInFlatlock.size > 0) {
469
- console.log(` only in flatlock (${result.onlyInFlatlock.size}):`);
470
- for (const pkg of [...result.onlyInFlatlock].slice(0, 10)) {
471
- console.log(` + ${pkg}`);
472
- }
473
- if (result.onlyInFlatlock.size > 10) {
474
- console.log(` ... and ${result.onlyInFlatlock.size - 10} more`);
475
- }
191
+ console.log(` sets: identical`);
192
+ }
193
+ } else {
194
+ mismatchCount++;
195
+ console.log(`\n❌ ${result.path}`);
196
+ console.log(` count: flatlock=${result.flatlockCount} ${result.comparisonName}=${result.comparisonCount}`);
197
+ console.log(` sets: MISMATCH`);
198
+
199
+ if (result.onlyInFlatlock.length > 0) {
200
+ console.log(` only in flatlock (${result.onlyInFlatlock.length}):`);
201
+ for (const pkg of result.onlyInFlatlock.slice(0, 10)) {
202
+ console.log(` + ${pkg}`);
476
203
  }
204
+ if (result.onlyInFlatlock.length > 10) {
205
+ console.log(` ... and ${result.onlyInFlatlock.length - 10} more`);
206
+ }
207
+ }
477
208
 
478
- if (result.onlyInComparison.size > 0) {
479
- console.log(` only in ${result.comparisonName} (${result.onlyInComparison.size}):`);
480
- for (const pkg of [...result.onlyInComparison].slice(0, 10)) {
481
- console.log(` - ${pkg}`);
482
- }
483
- if (result.onlyInComparison.size > 10) {
484
- console.log(` ... and ${result.onlyInComparison.size - 10} more`);
485
- }
209
+ if (result.onlyInComparison.length > 0) {
210
+ console.log(` only in ${result.comparisonName} (${result.onlyInComparison.length}):`);
211
+ for (const pkg of result.onlyInComparison.slice(0, 10)) {
212
+ console.log(` - ${pkg}`);
213
+ }
214
+ if (result.onlyInComparison.length > 10) {
215
+ console.log(` ... and ${result.onlyInComparison.length - 10} more`);
486
216
  }
487
217
  }
488
218
  }
219
+ }
489
220
 
490
- // Summary
491
- console.log('\n' + '='.repeat(70));
492
- console.log(`SUMMARY: ${fileCount} files, ${matchCount} identical, ${mismatchCount} mismatches, ${errorCount} errors`);
493
- console.log(` flatlock total: ${totalFlatlock.toString().padStart(8)} packages`);
494
- if (totalComparison > 0) {
495
- console.log(` comparison total: ${totalComparison.toString().padStart(8)} packages`);
496
- }
497
- if (totalWorkspaces > 0) {
498
- console.log(` workspaces: ${totalWorkspaces.toString().padStart(8)} excluded (local/workspace refs)`);
499
- }
221
+ // Summary
222
+ console.log('\n' + '='.repeat(70));
223
+ console.log(`SUMMARY: ${fileCount} files, ${matchCount} identical, ${mismatchCount} mismatches, ${errorCount} errors`);
224
+ console.log(` flatlock total: ${totalFlatlock.toString().padStart(8)} packages`);
225
+ if (totalComparison > 0) {
226
+ console.log(` comparison total: ${totalComparison.toString().padStart(8)} packages`);
227
+ }
228
+ if (totalWorkspaces > 0) {
229
+ console.log(` workspaces: ${totalWorkspaces.toString().padStart(8)} excluded (local/workspace refs)`);
230
+ }
500
231
 
501
- // Exit with error if any mismatches
502
- if (mismatchCount > 0) {
503
- process.exit(1);
504
- }
505
- } finally {
506
- await cleanup();
232
+ // Exit with error if any mismatches
233
+ if (mismatchCount > 0) {
234
+ process.exit(1);
507
235
  }
508
236
  }
509
237
 
510
- main().catch(async err => {
511
- await cleanup();
238
+ main().catch(err => {
512
239
  console.error('Fatal error:', err.message);
513
240
  process.exit(1);
514
241
  });
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Compare flatlock output against established parser for a lockfile
3
+ * @param {string} filepath - Path to lockfile
4
+ * @param {CompareOptions} [options] - Options
5
+ * @returns {Promise<ComparisonResult>}
6
+ */
7
+ export function compare(filepath: string, options?: CompareOptions): Promise<ComparisonResult>;
8
+ /**
9
+ * Compare multiple lockfiles
10
+ * @param {string[]} filepaths - Paths to lockfiles
11
+ * @param {CompareOptions} [options] - Options
12
+ * @returns {AsyncGenerator<ComparisonResult & { filepath: string }>}
13
+ */
14
+ export function compareAll(filepaths: string[], options?: CompareOptions): AsyncGenerator<ComparisonResult & {
15
+ filepath: string;
16
+ }>;
17
+ export type CompareOptions = {
18
+ /**
19
+ * - Temp directory for Arborist (npm only)
20
+ */
21
+ tmpDir?: string;
22
+ };
23
+ export type ComparisonResult = {
24
+ /**
25
+ * - Lockfile type
26
+ */
27
+ type: string;
28
+ /**
29
+ * - Whether flatlock matches comparison parser
30
+ */
31
+ identical: boolean | null;
32
+ /**
33
+ * - Number of packages found by flatlock
34
+ */
35
+ flatlockCount: number;
36
+ /**
37
+ * - Number of packages found by comparison parser
38
+ */
39
+ comparisonCount?: number;
40
+ /**
41
+ * - Number of workspace packages skipped
42
+ */
43
+ workspaceCount?: number;
44
+ /**
45
+ * - Packages only found by flatlock
46
+ */
47
+ onlyInFlatlock?: string[];
48
+ /**
49
+ * - Packages only found by comparison parser
50
+ */
51
+ onlyInComparison?: string[];
52
+ };
53
+ export type PackagesResult = {
54
+ /**
55
+ * - Set of package@version strings
56
+ */
57
+ packages: Set<string>;
58
+ /**
59
+ * - Number of workspace packages skipped
60
+ */
61
+ workspaceCount: number;
62
+ };
63
+ //# sourceMappingURL=compare.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"compare.d.ts","sourceRoot":"","sources":["../src/compare.js"],"names":[],"mappings":"AAyMA;;;;;GAKG;AACH,kCAJW,MAAM,YACN,cAAc,GACZ,OAAO,CAAC,gBAAgB,CAAC,CA6CrC;AAED;;;;;GAKG;AACH,sCAJW,MAAM,EAAE,YACR,cAAc,GACZ,cAAc,CAAC,gBAAgB,GAAG;IAAE,QAAQ,EAAE,MAAM,CAAA;CAAE,CAAC,CAMnE;;;;;aAzPa,MAAM;;;;;;UAKN,MAAM;;;;eACN,OAAO,GAAG,IAAI;;;;mBACd,MAAM;;;;sBACN,MAAM;;;;qBACN,MAAM;;;;qBACN,MAAM,EAAE;;;;uBACR,MAAM,EAAE;;;;;;cAKR,GAAG,CAAC,MAAM,CAAC;;;;oBACX,MAAM"}
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Detect lockfile type from content and/or path
3
+ *
4
+ * Content is the primary signal - we actually parse the content to verify
5
+ * it's a valid lockfile of the detected type. This prevents spoofing attacks
6
+ * where malicious content contains detection markers in strings/comments.
7
+ *
8
+ * Path is only used as a fallback hint when content is not provided.
9
+ *
10
+ * @param {Object} options - Detection options
11
+ * @param {string} [options.path] - Path to the lockfile (optional hint)
12
+ * @param {string} [options.content] - Lockfile content (primary signal)
13
+ * @returns {LockfileType}
14
+ * @throws {Error} If unable to detect lockfile type
15
+ */
16
+ export function detectType({ path, content }?: {
17
+ path?: string | undefined;
18
+ content?: string | undefined;
19
+ }): LockfileType;
20
+ /**
21
+ * @typedef {'npm' | 'pnpm' | 'yarn-classic' | 'yarn-berry'} LockfileType
22
+ */
23
+ /**
24
+ * Lockfile type constants
25
+ */
26
+ export const Type: Readonly<{
27
+ NPM: "npm";
28
+ PNPM: "pnpm";
29
+ YARN_CLASSIC: "yarn-classic";
30
+ YARN_BERRY: "yarn-berry";
31
+ }>;
32
+ export type LockfileType = "npm" | "pnpm" | "yarn-classic" | "yarn-berry";
33
+ //# sourceMappingURL=detect.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"detect.d.ts","sourceRoot":"","sources":["../src/detect.js"],"names":[],"mappings":"AA8FA;;;;;;;;;;;;;;GAcG;AACH,+CALG;IAAyB,IAAI;IACJ,OAAO;CAChC,GAAU,YAAY,CAgDxB;AAtJD;;GAEG;AAEH;;GAEG;AACH;;;;;GAKG;2BAXU,KAAK,GAAG,MAAM,GAAG,cAAc,GAAG,YAAY"}