mcdview 0.34.2__tar.gz → 0.34.3__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mcdview
3
- Version: 0.34.2
3
+ Version: 0.34.3
4
4
  Summary: Turn a SQL data model into one self-contained, interactive HTML page
5
5
  Project-URL: Homepage, https://github.com/Gheop/mcdview
6
6
  Project-URL: Demo, https://gheop.github.io/mcdview/
@@ -356,6 +356,32 @@ file's header.
356
356
 
357
357
  ## Changelog
358
358
 
359
+ ### v0.34.3 — `--lint` sees the indexes it used to miss (2026-09-27)
360
+
361
+ - `--lint` flagged foreign keys as `unindexed_fk` although an index covered
362
+ them, because some declared indexes were not read. They now are: Rails
363
+ `t.index` and `add_index`; Drizzle `index()`, `uniqueIndex()` and `unique()`
364
+ with `.on()` or `.onOnly()`, and column-level `.unique()`; SQL `UNIQUE` inside
365
+ `CREATE TABLE` (column or table level); MySQL `KEY` / `INDEX` inside
366
+ `CREATE TABLE`, which `mysqldump` writes for every foreign key. Across the test
367
+ corpus (13,630 files, 94,146 FKs), `unindexed_fk` drops from 64,806 to 44,172:
368
+ Rails 6,849 → 232, Drizzle 15,273 → 7,853, MySQL 8,005 → 2,531.
369
+ - phpMyAdmin exports, which add the primary key and indexes afterwards
370
+ (`ALTER TABLE t ADD PRIMARY KEY (id), ADD KEY k (col)`), lost both: nearly
371
+ every table was reported as `missing_pk`. They are read now; across the
372
+ corpus `missing_pk` drops from 20,820 to 8,505.
373
+ - Drizzle composite primary keys (`primaryKey({ columns: [...] })` and the older
374
+ `primaryKey(t.a, t.b)`) are read.
375
+ - The panel lists these indexes, and a foreign key covered by a single-column
376
+ unique index is shown as 1:1.
377
+ - A Drizzle column declared without a name string took the first string found
378
+ further on as its name, for example `cascade` from `{ onDelete: "cascade" }`
379
+ or `number` from `{ mode: "number" }`. It now takes its field name
380
+ (2,613 columns fixed in the corpus).
381
+ - A crafted Drizzle file with unclosed `pgTable(` calls could take minutes to
382
+ parse (3 min for 130 KB). Parsing is now linear in the file size.
383
+ - Table and foreign-key counts are unchanged on every corpus file.
384
+
359
385
  ### v0.34.2 — Right SQL dialect on large MySQL and Prisma models (2026-09-23)
360
386
 
361
387
  - `--dialect auto` now chooses the dialect on the whole file. On files over
@@ -334,6 +334,32 @@ file's header.
334
334
 
335
335
  ## Changelog
336
336
 
337
+ ### v0.34.3 — `--lint` sees the indexes it used to miss (2026-09-27)
338
+
339
+ - `--lint` flagged foreign keys as `unindexed_fk` although an index covered
340
+ them, because some declared indexes were not read. They now are: Rails
341
+ `t.index` and `add_index`; Drizzle `index()`, `uniqueIndex()` and `unique()`
342
+ with `.on()` or `.onOnly()`, and column-level `.unique()`; SQL `UNIQUE` inside
343
+ `CREATE TABLE` (column or table level); MySQL `KEY` / `INDEX` inside
344
+ `CREATE TABLE`, which `mysqldump` writes for every foreign key. Across the test
345
+ corpus (13,630 files, 94,146 FKs), `unindexed_fk` drops from 64,806 to 44,172:
346
+ Rails 6,849 → 232, Drizzle 15,273 → 7,853, MySQL 8,005 → 2,531.
347
+ - phpMyAdmin exports, which add the primary key and indexes afterwards
348
+ (`ALTER TABLE t ADD PRIMARY KEY (id), ADD KEY k (col)`), lost both: nearly
349
+ every table was reported as `missing_pk`. They are read now; across the
350
+ corpus `missing_pk` drops from 20,820 to 8,505.
351
+ - Drizzle composite primary keys (`primaryKey({ columns: [...] })` and the older
352
+ `primaryKey(t.a, t.b)`) are read.
353
+ - The panel lists these indexes, and a foreign key covered by a single-column
354
+ unique index is shown as 1:1.
355
+ - A Drizzle column declared without a name string took the first string found
356
+ further on as its name, for example `cascade` from `{ onDelete: "cascade" }`
357
+ or `number` from `{ mode: "number" }`. It now takes its field name
358
+ (2,613 columns fixed in the corpus).
359
+ - A crafted Drizzle file with unclosed `pgTable(` calls could take minutes to
360
+ parse (3 min for 130 KB). Parsing is now linear in the file size.
361
+ - Table and foreign-key counts are unchanged on every corpus file.
362
+
337
363
  ### v0.34.2 — Right SQL dialect on large MySQL and Prisma models (2026-09-23)
338
364
 
339
365
  - `--dialect auto` now chooses the dialect on the whole file. On files over
@@ -90,6 +90,9 @@ RE_FK_TABLE = re.compile(
90
90
  r'REFERENCES\s+(?:"?(\w+)"?\.)?"?(\w+)"?(?:\s*\(([^)]+)\))?', re.I)
91
91
  RE_FK_INLINE = re.compile(
92
92
  r'\bREFERENCES\s+(?:"?(\w+)"?\.)?"?(\w+)"?(?:\s*\(([^)]+)\))?', re.I)
93
+ RE_UNIQUE_TABLE = re.compile(
94
+ r'^\s*(?:CONSTRAINT\s+"?(\w+)"?\s+)?UNIQUE\b[^(]*\(([^)]+)\)', re.I)
95
+ RE_UNIQUE_MOT = re.compile(r'\bUNIQUE\b', re.I)
93
96
  # an entry that opens with a table constraint keyword is not a column definition
94
97
  RE_DEBUT_CONTRAINTE = re.compile(
95
98
  r'^\s*(?:CONSTRAINT\s+"?\w+"?\s+)?'
@@ -413,15 +416,21 @@ def analyser_sql(chemin, src=None):
413
416
  vues.add((de, scol, vers, dcol))
414
417
  fks.append(nouvelle_fk(de, scol, vers, dcol, nom, od, ou))
415
418
 
419
+ def ajouter_unique(de, nom, cols):
420
+ if cols and not any(ix['cols'] == cols and ix['unique'] for ix in tables[de]['index']):
421
+ tables[de]['index'].append({'nom': nom, 'cols': cols, 'unique': True})
422
+
416
423
  for cle, corps in corps_par_cle.items():
417
424
  de = resoudre(cle)
418
425
  if de not in tables:
419
426
  continue
420
427
  # this pass only ever yields FKs from a REFERENCES token (RE_FK_TABLE /
421
- # RE_FK_INLINE both require it). A body without one — every table in a
422
- # pg_dump, where FKs live in ALTER TABLE — produces nothing, so skip the
423
- # per-character body scan entirely (it was ~35 % of the parse time)
424
- if 'REFERENCES' not in corps.upper():
428
+ # RE_FK_INLINE both require it) and indexes from a UNIQUE one. A body
429
+ # with neither — every table in a pg_dump, where constraints live in
430
+ # ALTER TABLE — produces nothing, so skip the per-character body scan
431
+ # entirely (it was ~35 % of the parse time)
432
+ haut = corps.upper()
433
+ if 'REFERENCES' not in haut and 'UNIQUE' not in haut:
425
434
  continue
426
435
  # terminate the body so decouper_corps emits the final entry too (it only
427
436
  # flushes the buffer at a depth-0 ')' or comma; a multi-line body has
@@ -430,6 +439,16 @@ def analyser_sql(chemin, src=None):
430
439
  entrees, _ = decouper_corps(corps + '\n)')
431
440
  for e in entrees:
432
441
  e = RE_COMMENTAIRE.sub('', RE_LITTERAL.sub('', e)).strip()
442
+ # UNIQUE in the body is an index too: table-level `[CONSTRAINT n]
443
+ # UNIQUE (a, b)` or column-level `col type UNIQUE` (pg_dump writes
444
+ # them as ALTER TABLE ... ADD CONSTRAINT, read above)
445
+ mu = RE_UNIQUE_TABLE.match(e)
446
+ if mu:
447
+ ajouter_unique(de, mu.group(1) or '', identifiants(mu.group(2)))
448
+ continue
449
+ cm = RE_COLONNE.match(e)
450
+ if cm and not RE_DEBUT_CONTRAINTE.match(e) and RE_UNIQUE_MOT.search(cm.group(2)):
451
+ ajouter_unique(de, '', [cm.group(1)])
433
452
  mt = RE_FK_TABLE.match(e)
434
453
  if mt:
435
454
  scols, dsch, dtab, dcols = mt.groups()
@@ -580,6 +599,50 @@ def analyser(chemin, dialecte='auto'):
580
599
  return (*normaliser_casse(tables, fks), dialecte)
581
600
 
582
601
 
602
+ RE_ALTER_TETE = re.compile(
603
+ r'ALTER\s+TABLE\s+(?:ONLY\s+)?(?:[`"\[]?(\w+)[`"\]]?\.)?[`"\[]?(\w+)[`"\]]?\s+ADD\b', re.I)
604
+ RE_ALTER_CLE = re.compile(
605
+ r'\bADD\s+(?:CONSTRAINT\s+[`"\[]?\w+[`"\]]?\s+)?'
606
+ r'(PRIMARY\s+KEY|UNIQUE(?:\s+(?:KEY|INDEX))?|KEY|INDEX)\s*'
607
+ r'(?:[`"\[]?(\w+)[`"\]]?\s*)?(?:USING\s+\w+\s*)?\(((?:[^()]|\([^()]*\))*)\)', re.I)
608
+
609
+
610
+ def ajouter_cles_alter(src, tables):
611
+ """Primary keys and indexes added by ALTER TABLE, as phpMyAdmin exports them:
612
+ `ALTER TABLE t ADD PRIMARY KEY (id), ADD KEY k (a), ADD UNIQUE KEY u (b);`.
613
+ sqlglot cannot parse several ADD KEY in one statement (it keeps a raw
614
+ Command), so these are read here. A PK only fills a table that has none;
615
+ an index already present is not added twice."""
616
+ # a statement ends at the next ';' or, when that is missing, at the next
617
+ # ALTER: bounding it (and caching the ';' position) keeps many unterminated
618
+ # ALTERs linear instead of each one rescanning the rest of the file
619
+ tetes = list(RE_ALTER_TETE.finditer(src))
620
+ fin = -1
621
+ for n, m in enumerate(tetes):
622
+ cle = f"{m.group(1) or 'public'}.{m.group(2)}"
623
+ if cle not in tables:
624
+ continue
625
+ if fin < m.end():
626
+ fin = src.find(';', m.end())
627
+ if fin < 0:
628
+ fin = len(src)
629
+ borne = min(fin, tetes[n + 1].start() if n + 1 < len(tetes) else len(src))
630
+ t = tables[cle]
631
+ for c in RE_ALTER_CLE.finditer(src, m.end() - 3, borne):
632
+ genre = c.group(1).upper()
633
+ cols = [x for x in identifiants(re.sub(r'\(\d+\)|\s+(?:ASC|DESC)\b', '', c.group(3), flags=re.I))
634
+ if x]
635
+ if not cols:
636
+ continue
637
+ if genre.startswith('PRIMARY'):
638
+ if not t['pk']:
639
+ t['pk'] = cols
640
+ continue
641
+ unique = genre.startswith('UNIQUE')
642
+ if not any(ix['cols'] == cols and ix['unique'] == unique for ix in t['index']):
643
+ t['index'].append({'nom': c.group(2) or '', 'cols': cols, 'unique': unique})
644
+
645
+
583
646
  def analyser_sqlglot(chemin, dialecte, strict=True, src=None):
584
647
  try:
585
648
  import logging
@@ -689,12 +752,25 @@ def analyser_sqlglot(chemin, dialecte, strict=True, src=None):
689
752
  ref = fk.args.get('reference')
690
753
  if ref:
691
754
  ajouter_fk(k, [c.name for c in fk.expressions], ref, nom_fk(fk))
692
- # inline UNIQUE constraints become indexes
755
+ # inline UNIQUE constraints become indexes: table-level `UNIQUE (a, b)`
756
+ # carries its columns, column-level `col INT UNIQUE` has none (its
757
+ # column is the enclosing definition)
693
758
  for u in stmt.find_all(exp.UniqueColumnConstraint):
694
- sch = u.this if u.this is not None else None
695
- noms = [e.name for e in getattr(sch, 'expressions', [])] if sch else []
759
+ if u.this is not None:
760
+ noms = [e.name for e in getattr(u.this, 'expressions', [])]
761
+ else:
762
+ cd = u.find_ancestor(exp.ColumnDef)
763
+ noms = [cd.name] if cd else []
696
764
  if noms:
697
765
  tables[k]['index'].append({'nom': '', 'cols': noms, 'unique': True})
766
+ # MySQL declares plain indexes in the body: KEY / INDEX name (cols);
767
+ # mysqldump writes one for every foreign key
768
+ for ix in stmt.find_all(exp.IndexColumnConstraint):
769
+ noms = [e.name for e in ix.expressions if e.name]
770
+ if noms:
771
+ tables[k]['index'].append(
772
+ {'nom': ix.this.name if ix.this is not None else '',
773
+ 'cols': noms, 'unique': False})
698
774
  elif isinstance(stmt, exp.Create) and stmt.kind == 'INDEX':
699
775
  idx = stmt.this
700
776
  tbl = idx.args.get('table') if idx is not None else None
@@ -722,6 +798,8 @@ def analyser_sqlglot(chemin, dialecte, strict=True, src=None):
722
798
  tables[k]['index'].append(
723
799
  {'nom': cont.name if cont else '', 'cols': noms, 'unique': True})
724
800
 
801
+ ajouter_cles_alter(src, tables)
802
+
725
803
  # a reference without a column list points at the target's primary key
726
804
  for f in fks:
727
805
  if not f['colcible']:
@@ -1026,6 +1104,30 @@ def singulariser(mot):
1026
1104
  return mot
1027
1105
 
1028
1106
 
1107
+ def index_rails(args):
1108
+ """One Rails index declaration from what follows `t.index` / `add_index "t",`:
1109
+ the columns (["a", "b"], %w[a b], "a" or :a) then the options (name:,
1110
+ unique:, also the old :name => / :unique => form). None if unreadable."""
1111
+ args = args.strip()
1112
+ if args.startswith('%w'):
1113
+ mw = re.match(r'%w[\[(]([^\])]*)[\])]', args)
1114
+ cols, reste = (mw.group(1).split(), args[mw.end():]) if mw else ([], '')
1115
+ elif args.startswith('['):
1116
+ fin = args.find(']')
1117
+ if fin < 0:
1118
+ return None
1119
+ cols = [a or b for a, b in re.findall(r'["\']([^"\']+)["\']|:(\w+)', args[:fin])]
1120
+ reste = args[fin + 1:]
1121
+ else:
1122
+ mc = re.match(r'["\']([^"\']+)["\']|:(\w+)', args)
1123
+ cols, reste = ([mc.group(1) or mc.group(2)], args[mc.end():]) if mc else ([], '')
1124
+ if not cols:
1125
+ return None
1126
+ mn = re.search(r'name:\s*["\']([^"\']+)["\']|:name\s*=>\s*["\']([^"\']+)["\']', reste)
1127
+ return {'nom': (mn.group(1) or mn.group(2)) if mn else '', 'cols': cols,
1128
+ 'unique': bool(re.search(r'unique:\s*true|:unique\s*=>\s*true', reste))}
1129
+
1130
+
1029
1131
  def analyser_schema_rb(chemin):
1030
1132
  """Parse a Rails db/schema.rb natively (the create_table / add_foreign_key
1031
1133
  DSL is regular). The implicit `id` primary key is added unless `id: false`;
@@ -1057,7 +1159,7 @@ def analyser_schema_rb(chemin):
1057
1159
  for cm in re.finditer(r'\b' + re.escape(var) + r'\.(\w+)\s+["\'"]([^"\'"]+)["\'"]([^\n]*)', corps):
1058
1160
  typ, cn, reste = cm.groups()
1059
1161
  if typ == 'index':
1060
- continue
1162
+ continue # read below with the array forms
1061
1163
  defc = re.search(r'default:\s*("[^"]*"|\'[^\']*\'|[^,\n]+)', reste)
1062
1164
  defaut = defc.group(1).strip('\'"') if defc else ''
1063
1165
  if typ in ('references', 'belongs_to'):
@@ -1066,7 +1168,14 @@ def analyser_schema_rb(chemin):
1066
1168
  cols.append(nouvelle_colonne(cn + '_type', 'string'))
1067
1169
  else:
1068
1170
  cols.append(nouvelle_colonne(cn, typ, 'null: false' in reste, defaut))
1069
- tables[cle] = nouvelle_table('public', nom, cols, pk)
1171
+ index = [ix for im in re.finditer(r'\b' + re.escape(var) + r'\.index\b\(?\s*([^\n]*)', corps)
1172
+ if (ix := index_rails(im.group(1)))]
1173
+ tables[cle] = nouvelle_table('public', nom, cols, pk, index=index)
1174
+ # pre-Rails-5 dumps declare indexes after the tables: add_index "t", [...]
1175
+ for m in re.finditer(r'add_index\s*\(?\s*["\':]([\w.]+)["\']?\s*,\s*([^\n]*)', src):
1176
+ ix = index_rails(m.group(2))
1177
+ if ix and f'public.{m.group(1)}' in tables:
1178
+ tables[f'public.{m.group(1)}']['index'].append(ix)
1070
1179
  for m in re.finditer(r'add_foreign_key\s+["\']([^"\']+)["\'],\s*["\']([^"\']+)["\']([^\n]*)', src):
1071
1180
  det, verst, reste = m.groups()
1072
1181
  mp = re.search(r'primary_key:\s*["\']([^"\']+)["\']', reste)
@@ -1192,6 +1301,12 @@ def analyser_mermaid(chemin):
1192
1301
  return tables, fks
1193
1302
 
1194
1303
 
1304
+ RE_ACCOLADE = re.compile(r'[{}]')
1305
+ RE_DELIM = re.compile(r'[()\[\]{}]')
1306
+ RE_DZ_INDEX = re.compile(r'\b(uniqueIndex|index|unique)\s*\(\s*(?:["\'`]([^"\'`]*)["\'`])?\s*\)'
1307
+ r'\s*\.on(?:Only)?\(')
1308
+
1309
+
1195
1310
  def analyser_drizzle(chemin):
1196
1311
  """Parse a Drizzle ORM schema.ts natively (regular enough): each
1197
1312
  `pgTable("name", { field: type("col").notNull().primaryKey()
@@ -1229,35 +1344,73 @@ def analyser_drizzle(chemin):
1229
1344
  fabriques += re.findall(
1230
1345
  r'(?:const|let|var)\s+(\w+)\s*=\s*(?:pg|mysql|sqlite)TableCreator\s*\(', src)
1231
1346
  motif_table = '|'.join(re.escape(f) for f in dict.fromkeys(fabriques))
1232
- for m in re.finditer(
1233
- r'(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=\s*(?:' + motif_table + r')\s*\(\s*'
1234
- r'["\'`]([^"\'`]+)["\'`]\s*,\s*\{', src):
1347
+ appels = list(re.finditer(
1348
+ r'(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=\s*(?:' + motif_table + r')\s*\(\s*'
1349
+ r'["\'`]([^"\'`]+)["\'`]\s*,\s*\{', src))
1350
+ for n, m in enumerate(appels):
1235
1351
  var, nom = m.group(1), m.group(2)
1236
- i, prof = m.end() - 1, 0 # scan balanced braces for the body
1237
- while i < len(src):
1238
- prof += (src[i] == '{') - (src[i] == '}')
1352
+ # a table's scans stop where the next table starts: on unclosed braces
1353
+ # (crafted upload) each one ran to the end of file, O(n²) (3 min / 130 KiB)
1354
+ borne = appels[n + 1].start() if n + 1 < len(appels) else len(src)
1355
+ i, prof = borne, 0 # scan balanced braces for the body
1356
+ for d in RE_ACCOLADE.finditer(src, m.end() - 1, borne):
1357
+ prof += 1 if d.group() == '{' else -1
1239
1358
  if prof == 0:
1359
+ i = d.start()
1240
1360
  break
1241
- i += 1
1242
1361
  cle = f'public.{nom}'
1243
1362
  var_table[var] = cle
1244
1363
  var_cols[var] = {}
1245
- cols, pk = [], []
1364
+ cols, pk, index = [], [], []
1246
1365
  for entree in champs(src[m.end():i]):
1247
1366
  cm = re.match(r'\s*(\w+)\s*:\s*(\w+)\s*\(', entree)
1248
1367
  if not cm:
1249
1368
  continue
1250
1369
  champ, typ = cm.groups()
1251
- sm = re.search(r'''["'`]([^"'`]+)["'`]''', entree)
1370
+ # the column name is the type call's first argument when it is a
1371
+ # string (`text("user_id")`); otherwise the field name. Not the first
1372
+ # string anywhere: `uuid().references(..., { onDelete: "cascade" })`
1373
+ sm = re.match(r'''\s*["'`]([^"'`]+)["'`]''', entree[cm.end():])
1252
1374
  col = sm.group(1) if sm else champ
1253
1375
  var_cols[var][champ] = col
1254
1376
  cols.append(nouvelle_colonne(col, typ, '.notNull(' in entree))
1255
1377
  if '.primaryKey(' in entree:
1256
1378
  pk.append(col)
1379
+ if re.search(r'\.unique\(\s*(["\'`][^"\'`]*["\'`])?\s*\)', entree):
1380
+ index.append({'nom': '', 'cols': [col], 'unique': True})
1257
1381
  rm = re.search(r'\.references\(\s*\(\s*\)\s*=>\s*(\w+)\.(\w+)', entree)
1258
1382
  if rm:
1259
1383
  refs.append((cle, col, rm.group(1), rm.group(2)))
1260
- tables[cle] = nouvelle_table('public', nom, cols, pk)
1384
+ # the optional third argument, `(table) => [...]` or `=> ({...})`, holds
1385
+ # the indexes, unique constraints and composite primary key: read up to
1386
+ # the pgTable call's closing parenthesis
1387
+ j, prof = borne, 0
1388
+ for d in RE_DELIM.finditer(src, i + 1, borne):
1389
+ if prof == 0 and d.group() == ')':
1390
+ j = d.start()
1391
+ break
1392
+ prof += 1 if d.group() in '([{' else -1
1393
+ extra, champ_col = src[i + 1:j], var_cols[var]
1394
+ # the arguments of .on(...) end at the next ')'; that position is cached
1395
+ # so that many unclosed .on( do not each rescan to the end (linear)
1396
+ ferme = -1
1397
+ for im in RE_DZ_INDEX.finditer(extra):
1398
+ if ferme < im.end():
1399
+ ferme = extra.find(')', im.end())
1400
+ if ferme < 0:
1401
+ break
1402
+ ixcols = [champ_col.get(f, f) for f in re.findall(r'\w+\.(\w+)', extra[im.end():ferme])]
1403
+ if ixcols:
1404
+ index.append({'nom': im.group(2) or '', 'cols': ixcols,
1405
+ 'unique': im.group(1) != 'index'})
1406
+ mp = None if pk else re.search(r'\bprimaryKey\s*\(', extra)
1407
+ if mp:
1408
+ # primaryKey({ columns: [t.a, t.b] }), or the older primaryKey(t.a, t.b):
1409
+ # the columns are the t.x up to the closing ']' or ')' (first match only)
1410
+ fin = min((k for k in (extra.find(']', mp.end()), extra.find(')', mp.end())) if k >= 0),
1411
+ default=len(extra))
1412
+ pk = [champ_col.get(f, f) for f in re.findall(r'\w+\.(\w+)', extra[mp.end():fin])]
1413
+ tables[cle] = nouvelle_table('public', nom, cols, pk, index=index)
1261
1414
 
1262
1415
  for de, col, tvar, tfield in refs:
1263
1416
  vers = var_table.get(tvar)
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "mcdview"
7
- version = "0.34.2"
7
+ version = "0.34.3"
8
8
  description = "Turn a SQL data model into one self-contained, interactive HTML page"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
File without changes
File without changes
File without changes