preflight-kit 0.2.0__py3-none-any.whl
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.
- preflight_kit/__init__.py +0 -0
- preflight_kit/cli.py +72 -0
- preflight_kit/engine.py +62 -0
- preflight_kit/fixer.py +50 -0
- preflight_kit/header_registry.py +126 -0
- preflight_kit/json_dump.py +85 -0
- preflight_kit/loader.py +291 -0
- preflight_kit/models.py +66 -0
- preflight_kit/reporters.py +145 -0
- preflight_kit/rules/__init__.py +4 -0
- preflight_kit/rules/file_rules.py +210 -0
- preflight_kit/rules/row_rules.py +448 -0
- preflight_kit-0.2.0.dist-info/METADATA +124 -0
- preflight_kit-0.2.0.dist-info/RECORD +17 -0
- preflight_kit-0.2.0.dist-info/WHEEL +4 -0
- preflight_kit-0.2.0.dist-info/entry_points.txt +2 -0
- preflight_kit-0.2.0.dist-info/licenses/LICENSE +33 -0
preflight_kit/loader.py
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import csv
|
|
3
|
+
import io
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
from .models import Finding, Row, RowKind, Severity, FixClass, MAX_IMPORT_BYTES
|
|
8
|
+
from .header_registry import to_canonical
|
|
9
|
+
|
|
10
|
+
# csv のフィールド長上限(既定 131072)は Shopify の長い description セルや
|
|
11
|
+
# 15MB 超の単一セル(F04a fixture)で `field larger than field limit` を起こす。
|
|
12
|
+
# Shopify の 15MB import 上限を超える 64MB に引き上げ、構造検査を最後まで走らせる。
|
|
13
|
+
csv.field_size_limit(64 * 1024 * 1024)
|
|
14
|
+
|
|
15
|
+
_PII_PATTERNS = [
|
|
16
|
+
re.compile(r"e-?mail", re.IGNORECASE),
|
|
17
|
+
re.compile(r"\bphone\b", re.IGNORECASE),
|
|
18
|
+
re.compile(r"telephone", re.IGNORECASE),
|
|
19
|
+
re.compile(r"address", re.IGNORECASE),
|
|
20
|
+
re.compile(r"\bcustomer\b", re.IGNORECASE),
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class LoadResult:
|
|
26
|
+
header: list[str]
|
|
27
|
+
rows: list[Row]
|
|
28
|
+
canonical_map: dict[str, str]
|
|
29
|
+
file_findings: list[Finding]
|
|
30
|
+
encoding: str
|
|
31
|
+
raw_byte_size: int
|
|
32
|
+
blocked: bool = False
|
|
33
|
+
# 15MB 超で行 parse をスキップしたか。header は読めていても rows は空になる。
|
|
34
|
+
# CLI はこのとき fixed_products.csv を書かない(空ファイルでの元データ破壊を防ぐ)。
|
|
35
|
+
# engine はこのとき行ルール・F03c 等の「行/列の中身」判定をスキップする
|
|
36
|
+
# (header=[] でないので F03c 誤発火しないが、行が無い前提のルールも抑止する)。
|
|
37
|
+
parse_skipped: bool = False
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _file_finding(
|
|
41
|
+
rule_id, severity, message, suggested_fix, fix_class, field_name=None, row=None
|
|
42
|
+
):
|
|
43
|
+
return Finding(
|
|
44
|
+
row=row,
|
|
45
|
+
product_group_id=None,
|
|
46
|
+
row_kind=None,
|
|
47
|
+
handle=None,
|
|
48
|
+
sku=None,
|
|
49
|
+
severity=severity,
|
|
50
|
+
rule_id=rule_id,
|
|
51
|
+
field=field_name,
|
|
52
|
+
message=message,
|
|
53
|
+
suggested_fix=suggested_fix,
|
|
54
|
+
fix_class=fix_class,
|
|
55
|
+
auto_fixable=(fix_class == FixClass.PROVEN),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _detect_pii(header: list[str]) -> list[Finding]:
|
|
60
|
+
out = []
|
|
61
|
+
for col in header:
|
|
62
|
+
if any(p.search(col) for p in _PII_PATTERNS):
|
|
63
|
+
out.append(
|
|
64
|
+
_file_finding(
|
|
65
|
+
"GUARD-PII",
|
|
66
|
+
Severity.CRITICAL,
|
|
67
|
+
f"Buyer-PII-like column detected: '{col}'. Processing stopped (order/customer CSV out of scope).",
|
|
68
|
+
"Remove buyer PII columns; this tool only handles product CSV.",
|
|
69
|
+
FixClass.NONE,
|
|
70
|
+
field_name=col,
|
|
71
|
+
)
|
|
72
|
+
)
|
|
73
|
+
return out
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _is_product_start(
|
|
77
|
+
canonical: dict[str, str], handle: str, current_group_handle: str | None
|
|
78
|
+
) -> bool:
|
|
79
|
+
"""product-start 判定(#1/#2/#3 対応)。
|
|
80
|
+
|
|
81
|
+
`current_group_handle` は「現在のグループの代表 handle」(最後に product-start を
|
|
82
|
+
立てた行の handle 値。まだ1つもグループが無ければ None)。直前行の生 handle ではなく
|
|
83
|
+
**グループ代表 handle** と比較することで、handle 空の本体行を挟んでも後続の同一 handle
|
|
84
|
+
行が誤って前グループを継承する carry-over を防ぐ(round-2 #1)。
|
|
85
|
+
|
|
86
|
+
分岐:
|
|
87
|
+
- handle 非空 かつ グループ代表 handle と異なる → 新グループの product-start。
|
|
88
|
+
- handle 非空 かつ グループ代表 handle と同一 → Title 非空なら 2つ目の
|
|
89
|
+
product-start(R03 が拾う重複本体行)。Title 空なら variant/image(正常な反復)。
|
|
90
|
+
- handle 空 かつ Title 非空 → 本体行で handle 値だけ欠落 = product-start として扱い、
|
|
91
|
+
R02 が handle 欠落 severity を intent 別に判定する。
|
|
92
|
+
- handle 空 かつ Title 空 かつ 直前にグループあり → 親 handle を書き忘れた
|
|
93
|
+
variant/image 継続行とみなし product-start にしない(R02 の variant 分岐が
|
|
94
|
+
「handle 欠落 = critical」を出せる)。直前グループが無ければ product-start。
|
|
95
|
+
"""
|
|
96
|
+
title = (canonical.get("title") or "").strip()
|
|
97
|
+
if handle != "":
|
|
98
|
+
if handle != current_group_handle:
|
|
99
|
+
return True
|
|
100
|
+
return title != "" # 同一グループ handle 継続
|
|
101
|
+
# handle 空
|
|
102
|
+
if title != "":
|
|
103
|
+
return True # 本体行で handle 値だけ欠落(R02 が intent 別に判定)
|
|
104
|
+
# handle 空 かつ Title 空: 直前にグループがあれば継続行(handle 欠落 variant/image)
|
|
105
|
+
return current_group_handle is None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _classify_row(canonical: dict[str, str], is_start: bool) -> RowKind:
|
|
109
|
+
if is_start:
|
|
110
|
+
return RowKind.PRODUCT
|
|
111
|
+
title = (canonical.get("title") or "").strip()
|
|
112
|
+
has_image = bool((canonical.get("image_src") or "").strip())
|
|
113
|
+
has_option = bool(
|
|
114
|
+
(canonical.get("option1_value") or "").strip()
|
|
115
|
+
or (canonical.get("sku") or "").strip()
|
|
116
|
+
or (canonical.get("price") or "").strip()
|
|
117
|
+
)
|
|
118
|
+
# image 判定を variant より先に評価(spec Task1 Step2 の優先順位)
|
|
119
|
+
if not title and has_image and not has_option:
|
|
120
|
+
return RowKind.IMAGE
|
|
121
|
+
return RowKind.VARIANT
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def load_csv(path: str) -> LoadResult:
|
|
125
|
+
with open(path, "rb") as fh:
|
|
126
|
+
raw = fh.read()
|
|
127
|
+
raw_byte_size = len(raw)
|
|
128
|
+
file_findings: list[Finding] = []
|
|
129
|
+
|
|
130
|
+
# --- F01a: BOM ---
|
|
131
|
+
had_bom = raw.startswith(b"\xef\xbb\xbf")
|
|
132
|
+
if had_bom:
|
|
133
|
+
raw = raw[3:]
|
|
134
|
+
file_findings.append(
|
|
135
|
+
_file_finding(
|
|
136
|
+
"F01a",
|
|
137
|
+
Severity.CRITICAL,
|
|
138
|
+
"UTF-8 BOM detected at file start.",
|
|
139
|
+
"BOM removed automatically.",
|
|
140
|
+
FixClass.PROVEN,
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
# --- F01b: UTF-8 デコード可否(推定変換しない) ---
|
|
145
|
+
encoding = "utf-8"
|
|
146
|
+
try:
|
|
147
|
+
text = raw.decode("utf-8")
|
|
148
|
+
except UnicodeDecodeError:
|
|
149
|
+
encoding = "unknown"
|
|
150
|
+
file_findings.append(
|
|
151
|
+
_file_finding(
|
|
152
|
+
"F01b",
|
|
153
|
+
Severity.CRITICAL,
|
|
154
|
+
"File is not valid UTF-8 (encoding guess required).",
|
|
155
|
+
"Re-export the CSV as UTF-8; this tool does not guess-convert encodings.",
|
|
156
|
+
FixClass.SUGGESTED,
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
# デコードできた範囲だけ replace で読み、構造検査は継続
|
|
160
|
+
text = raw.decode("utf-8", errors="replace")
|
|
161
|
+
|
|
162
|
+
# --- 巨大ファイルは行 parse をスキップ(F04a は file_rules が出す) ---
|
|
163
|
+
# csv.reader は field_size_limit(64MB) を超える単一セルで csv.Error を投げて
|
|
164
|
+
# クラッシュし、F04a critical Finding / errors.csv / report.md / exit code 1 に
|
|
165
|
+
# 到達しなくなる。単一セルはファイル全体以下なので、64MB 超セルが起きるのは
|
|
166
|
+
# 必ず raw_byte_size > 15MB(_MAX_BYTES) のとき。よって 15MB 超過ファイルは
|
|
167
|
+
# **行**の parse を試みず parse_skipped=True で返し、F04a は後段の rule_f04a が
|
|
168
|
+
# raw_byte_size から出す(ここで F04a を出すと file_rules と二重発火するため出さない)。
|
|
169
|
+
#
|
|
170
|
+
# ただしヘッダー 1 行だけは読む(round-2 修正)。ヘッダーを読まないと:
|
|
171
|
+
# (1) 巨大 order/customer CSV で PII ガードを迂回する(security blocking)
|
|
172
|
+
# (2) header=[] のため F03c が handle/title 欠落を誤発火する(spec-conformance)
|
|
173
|
+
# ヘッダー行に巨大セルがあって read 自体が失敗する場合は header=[] のまま続行する。
|
|
174
|
+
parse_skipped = raw_byte_size > MAX_IMPORT_BYTES
|
|
175
|
+
if parse_skipped:
|
|
176
|
+
header: list[str] = []
|
|
177
|
+
try:
|
|
178
|
+
header = next(csv.reader(io.StringIO(text)))
|
|
179
|
+
except (StopIteration, csv.Error):
|
|
180
|
+
header = []
|
|
181
|
+
# ヘッダーが読めたら PII 判定は必ず通す(巨大ファイルでも安全境界を外さない)。
|
|
182
|
+
pii = _detect_pii(header)
|
|
183
|
+
if pii:
|
|
184
|
+
file_findings.extend(pii)
|
|
185
|
+
return LoadResult(
|
|
186
|
+
header,
|
|
187
|
+
[],
|
|
188
|
+
{},
|
|
189
|
+
file_findings,
|
|
190
|
+
encoding,
|
|
191
|
+
raw_byte_size,
|
|
192
|
+
blocked=True,
|
|
193
|
+
parse_skipped=True,
|
|
194
|
+
)
|
|
195
|
+
canonical_map = {
|
|
196
|
+
col: to_canonical(col) for col in header if to_canonical(col) is not None
|
|
197
|
+
}
|
|
198
|
+
return LoadResult(
|
|
199
|
+
header=header,
|
|
200
|
+
rows=[],
|
|
201
|
+
canonical_map=canonical_map,
|
|
202
|
+
file_findings=file_findings,
|
|
203
|
+
encoding=encoding,
|
|
204
|
+
raw_byte_size=raw_byte_size,
|
|
205
|
+
blocked=False,
|
|
206
|
+
parse_skipped=True,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
reader = csv.reader(io.StringIO(text))
|
|
210
|
+
try:
|
|
211
|
+
header = next(reader)
|
|
212
|
+
except StopIteration:
|
|
213
|
+
return LoadResult(
|
|
214
|
+
[], [], {}, file_findings, encoding, raw_byte_size, blocked=False
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
# --- PII ガード ---
|
|
218
|
+
pii = _detect_pii(header)
|
|
219
|
+
if pii:
|
|
220
|
+
file_findings.extend(pii)
|
|
221
|
+
return LoadResult(
|
|
222
|
+
header, [], {}, file_findings, encoding, raw_byte_size, blocked=True
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
canonical_map: dict[str, str] = {}
|
|
226
|
+
for col in header:
|
|
227
|
+
key = to_canonical(col)
|
|
228
|
+
if key is not None:
|
|
229
|
+
canonical_map[col] = key
|
|
230
|
+
|
|
231
|
+
rows: list[Row] = []
|
|
232
|
+
line_no = 0
|
|
233
|
+
group_seq = 0
|
|
234
|
+
group_id: str | None = None
|
|
235
|
+
# 現在のグループ代表 handle(最後に product-start を立てた行の handle 値)。
|
|
236
|
+
# 直前行の生 handle ではなくこれと比較する(carry-over 防止・round-2 #1)。
|
|
237
|
+
current_group_handle: str | None = None
|
|
238
|
+
for raw_cells in reader:
|
|
239
|
+
line_no += 1
|
|
240
|
+
cells = {
|
|
241
|
+
header[i]: (raw_cells[i] if i < len(raw_cells) else "")
|
|
242
|
+
for i in range(len(header))
|
|
243
|
+
}
|
|
244
|
+
# --- F04c: 行のセル数がヘッダー列数を超過(列ずれ) ---
|
|
245
|
+
# 未クォートのカンマや壊れた行で起きる。余剰セルは silently discard せず
|
|
246
|
+
# extra_cells に保持し(fixer が末尾へ出力)、critical Finding で報告する。
|
|
247
|
+
extra_cells: list[str] = []
|
|
248
|
+
if len(raw_cells) > len(header):
|
|
249
|
+
extra_cells = list(raw_cells[len(header) :])
|
|
250
|
+
file_findings.append(
|
|
251
|
+
_file_finding(
|
|
252
|
+
"F04c",
|
|
253
|
+
Severity.CRITICAL,
|
|
254
|
+
f"Row {line_no} has {len(raw_cells)} cells, more than the "
|
|
255
|
+
f"{len(header)} header columns (column misalignment).",
|
|
256
|
+
"Fix the row's quoting/delimiters so cell count matches the header.",
|
|
257
|
+
FixClass.NONE,
|
|
258
|
+
row=line_no,
|
|
259
|
+
)
|
|
260
|
+
)
|
|
261
|
+
canonical = {
|
|
262
|
+
canonical_map[col]: val
|
|
263
|
+
for col, val in cells.items()
|
|
264
|
+
if col in canonical_map
|
|
265
|
+
}
|
|
266
|
+
handle = (canonical.get("handle") or "").strip()
|
|
267
|
+
is_start = _is_product_start(canonical, handle, current_group_handle)
|
|
268
|
+
if is_start:
|
|
269
|
+
# product-start ごとに新グループ採番。同一 handle に複数 start が
|
|
270
|
+
# あれば別グループになり、R03 が複数本体行を検知できる。
|
|
271
|
+
group_seq += 1
|
|
272
|
+
group_id = f"g{group_seq}"
|
|
273
|
+
# グループ代表 handle を更新(本体行 handle 欠落時は空文字で記録)。
|
|
274
|
+
current_group_handle = handle
|
|
275
|
+
# 継続行(is_start=False)は group_id / current_group_handle を維持し継承する。
|
|
276
|
+
kind = _classify_row(canonical, is_start)
|
|
277
|
+
rows.append(
|
|
278
|
+
Row(
|
|
279
|
+
line_no=line_no,
|
|
280
|
+
cells=cells,
|
|
281
|
+
canonical=canonical,
|
|
282
|
+
product_group_id=group_id,
|
|
283
|
+
row_kind=kind,
|
|
284
|
+
is_product_start=is_start,
|
|
285
|
+
extra_cells=extra_cells,
|
|
286
|
+
)
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
return LoadResult(
|
|
290
|
+
header, rows, canonical_map, file_findings, encoding, raw_byte_size
|
|
291
|
+
)
|
preflight_kit/models.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from enum import Enum
|
|
4
|
+
|
|
5
|
+
# Shopify product CSV import の 15MB hard limit(F04a / loader の parse スキップ判定で共有)。
|
|
6
|
+
# loader と file_rules の双方が参照するため、依存末端の models に SoT を置く。
|
|
7
|
+
MAX_IMPORT_BYTES = 15 * 1024 * 1024
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Severity(str, Enum):
|
|
11
|
+
CRITICAL = "critical"
|
|
12
|
+
WARNING = "warning"
|
|
13
|
+
INFO = "info"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class FixClass(str, Enum):
|
|
17
|
+
PROVEN = "proven"
|
|
18
|
+
SUGGESTED = "suggested"
|
|
19
|
+
NONE = "none"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class RowKind(str, Enum):
|
|
23
|
+
PRODUCT = "product"
|
|
24
|
+
VARIANT = "variant"
|
|
25
|
+
IMAGE = "image"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ImportIntent(str, Enum):
|
|
29
|
+
NEW = "new"
|
|
30
|
+
UPDATE = "update"
|
|
31
|
+
MIXED = "mixed"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class Row:
|
|
36
|
+
"""1 CSV データ行。canonical アクセスは get(key) 経由。"""
|
|
37
|
+
|
|
38
|
+
line_no: int # 元CSV行番号(1始まり・ヘッダー除く)
|
|
39
|
+
cells: dict[str, str] # 元の列名 -> 値(列順は header で保持)
|
|
40
|
+
canonical: dict[str, str] # canonical key -> 値(解釈用)
|
|
41
|
+
product_group_id: str | None = None
|
|
42
|
+
row_kind: RowKind | None = None
|
|
43
|
+
is_product_start: bool = False
|
|
44
|
+
# ヘッダー列数を超えた余剰セル(F04c 列ずれ行)。silent discard せず保持し、
|
|
45
|
+
# fixer が出力行の末尾へそのまま付ける。通常行は空リスト。
|
|
46
|
+
extra_cells: list[str] = field(default_factory=list)
|
|
47
|
+
|
|
48
|
+
def get(self, canonical_key: str) -> str | None:
|
|
49
|
+
return self.canonical.get(canonical_key)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class Finding:
|
|
54
|
+
row: int | None
|
|
55
|
+
product_group_id: str | None
|
|
56
|
+
row_kind: RowKind | None
|
|
57
|
+
handle: str | None
|
|
58
|
+
sku: str | None
|
|
59
|
+
severity: Severity
|
|
60
|
+
rule_id: str
|
|
61
|
+
field: str | None
|
|
62
|
+
message: str
|
|
63
|
+
suggested_fix: str
|
|
64
|
+
fix_class: FixClass
|
|
65
|
+
auto_fixable: bool
|
|
66
|
+
auto_fixed: bool = False
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import csv
|
|
3
|
+
|
|
4
|
+
from .models import Finding, Severity, FixClass
|
|
5
|
+
|
|
6
|
+
_ERROR_COLUMNS = [
|
|
7
|
+
"row",
|
|
8
|
+
"product_group_id",
|
|
9
|
+
"row_kind",
|
|
10
|
+
"handle",
|
|
11
|
+
"sku",
|
|
12
|
+
"severity",
|
|
13
|
+
"rule_id",
|
|
14
|
+
"field",
|
|
15
|
+
"message",
|
|
16
|
+
"suggested_fix",
|
|
17
|
+
"fix_class",
|
|
18
|
+
"auto_fixed",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
_UNCHECKED_EN = (
|
|
22
|
+
"Not checked by this tool: image URL public reachability, handle "
|
|
23
|
+
"overwrite/ignore against an existing store, metafield product reference "
|
|
24
|
+
"resolution, and option position consistency with existing products."
|
|
25
|
+
)
|
|
26
|
+
_UNCHECKED_JA = (
|
|
27
|
+
"本CLIが未検査の範囲: 画像URLの公開到達性、既存ストアとの handle "
|
|
28
|
+
"overwrite/ignore、metafield product reference の解決、既存商品との option 位置整合。"
|
|
29
|
+
)
|
|
30
|
+
_NO_BLOCKING = "No blocking findings detected within implemented checks"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _cell(v) -> str:
|
|
34
|
+
if v is None:
|
|
35
|
+
return ""
|
|
36
|
+
if isinstance(v, (Severity, FixClass)):
|
|
37
|
+
return v.value
|
|
38
|
+
if hasattr(v, "value"):
|
|
39
|
+
return v.value
|
|
40
|
+
return str(v)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def write_errors_csv(findings: list[Finding], path: str) -> None:
|
|
44
|
+
with open(path, "w", newline="", encoding="utf-8") as fh:
|
|
45
|
+
writer = csv.writer(fh)
|
|
46
|
+
writer.writerow(_ERROR_COLUMNS)
|
|
47
|
+
for f in findings:
|
|
48
|
+
writer.writerow(
|
|
49
|
+
[
|
|
50
|
+
_cell(f.row),
|
|
51
|
+
_cell(f.product_group_id),
|
|
52
|
+
_cell(f.row_kind),
|
|
53
|
+
_cell(f.handle),
|
|
54
|
+
_cell(f.sku),
|
|
55
|
+
_cell(f.severity),
|
|
56
|
+
_cell(f.rule_id),
|
|
57
|
+
_cell(f.field),
|
|
58
|
+
_cell(f.message),
|
|
59
|
+
_cell(f.suggested_fix),
|
|
60
|
+
_cell(f.fix_class),
|
|
61
|
+
_cell(f.auto_fixed),
|
|
62
|
+
]
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def render_report_md(
|
|
67
|
+
findings: list[Finding],
|
|
68
|
+
*,
|
|
69
|
+
lang: str,
|
|
70
|
+
scanned_rows: int,
|
|
71
|
+
group_count: int,
|
|
72
|
+
applied: list[Finding],
|
|
73
|
+
) -> str:
|
|
74
|
+
crit = [f for f in findings if f.severity == Severity.CRITICAL]
|
|
75
|
+
warn = [f for f in findings if f.severity == Severity.WARNING]
|
|
76
|
+
suggested = [f for f in findings if f.fix_class == FixClass.SUGGESTED]
|
|
77
|
+
lines: list[str] = []
|
|
78
|
+
|
|
79
|
+
if lang == "ja":
|
|
80
|
+
lines.append("# Shopify CSV Preflight Report")
|
|
81
|
+
lines.append("")
|
|
82
|
+
lines.append(
|
|
83
|
+
f"- 概要: 検査行数 {scanned_rows} / 商品グループ数 {group_count} / "
|
|
84
|
+
f"critical {len(crit)}件 / warning {len(warn)}件 / "
|
|
85
|
+
f"自動修正(proven) {len(applied)}件 / 提案(suggested) {len(suggested)}件"
|
|
86
|
+
)
|
|
87
|
+
crit_head, warn_head = (
|
|
88
|
+
"## Critical(インポート前に必ず直す)",
|
|
89
|
+
"## Warning(確認推奨)",
|
|
90
|
+
)
|
|
91
|
+
applied_head, sug_head = (
|
|
92
|
+
"## 自動修正した内容(proven)",
|
|
93
|
+
"## 提案(suggested・CSV未反映)",
|
|
94
|
+
)
|
|
95
|
+
unchecked_head, next_head = "## 未検査範囲", "## 次のアクション"
|
|
96
|
+
unchecked = _UNCHECKED_JA
|
|
97
|
+
verdict = (
|
|
98
|
+
(_NO_BLOCKING + "(import可能を保証しない範囲あり)")
|
|
99
|
+
if not crit
|
|
100
|
+
else "Critical があります。import 前に解消してください。import可能を保証しません。"
|
|
101
|
+
)
|
|
102
|
+
else:
|
|
103
|
+
lines.append("# Shopify CSV Preflight Report")
|
|
104
|
+
lines.append("")
|
|
105
|
+
lines.append(
|
|
106
|
+
f"- Summary: scanned rows {scanned_rows} / product groups {group_count} / "
|
|
107
|
+
f"{len(crit)} critical / {len(warn)} warning / "
|
|
108
|
+
f"{len(applied)} auto-fixed (proven) / {len(suggested)} suggested"
|
|
109
|
+
)
|
|
110
|
+
crit_head, warn_head = (
|
|
111
|
+
"## Critical (fix before import)",
|
|
112
|
+
"## Warning (review recommended)",
|
|
113
|
+
)
|
|
114
|
+
applied_head, sug_head = (
|
|
115
|
+
"## Auto-fixed (proven)",
|
|
116
|
+
"## Suggested (not applied to CSV)",
|
|
117
|
+
)
|
|
118
|
+
unchecked_head, next_head = "## Not checked", "## Next actions"
|
|
119
|
+
unchecked = _UNCHECKED_EN
|
|
120
|
+
verdict = (
|
|
121
|
+
(_NO_BLOCKING + " (import not guaranteed).")
|
|
122
|
+
if not crit
|
|
123
|
+
else "Critical findings present. Resolve before import. Import is not guaranteed."
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
def _section(head, items):
|
|
127
|
+
lines.append("")
|
|
128
|
+
lines.append(head)
|
|
129
|
+
if not items:
|
|
130
|
+
lines.append("- (none)")
|
|
131
|
+
for f in items:
|
|
132
|
+
loc = f"row {f.row}" if f.row else "file"
|
|
133
|
+
lines.append(f"- [{f.rule_id}] {loc}: {f.message}")
|
|
134
|
+
|
|
135
|
+
_section(crit_head, crit)
|
|
136
|
+
_section(warn_head, warn)
|
|
137
|
+
_section(applied_head, applied)
|
|
138
|
+
_section(sug_head, suggested)
|
|
139
|
+
lines.append("")
|
|
140
|
+
lines.append(unchecked_head)
|
|
141
|
+
lines.append(f"- {unchecked}")
|
|
142
|
+
lines.append("")
|
|
143
|
+
lines.append(next_head)
|
|
144
|
+
lines.append(f"- {verdict}")
|
|
145
|
+
return "\n".join(lines) + "\n"
|