docs(plan): SLICE-8 Shopify dialect implementation plan (SD-0002 §7.2)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 02:17:30 -07:00
parent 8ed9b25771
commit e16ec7cf94
@@ -0,0 +1,721 @@
# SLICE-8 — Shopify dialect + public format docs — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Auto-detect a Shopify product-CSV export by its header set, map it to the canonical model at the codec boundary (INV-17), warn (not fail) on Shopify columns with no canonical home, and publish the public column reference (DOC-2) + finalized sample CSV (DOC-3) — completing PUC-6 (a Shopify export imports directly) and PUC-11 (learning the format).
**Architecture:** A Shopify file is recognized by *signature columns* it carries that canonical never does (`Body (HTML)`, `Variant Grams`, `Cost per item`, `Google Shopping / *`, …). Detected Shopify headers are rewritten to canonical names *before* the existing known/unknown split in `parse_csv`, so every downstream stage (validate → diff → apply) is already dialect-agnostic and unchanged (INV-17). Shopify columns with no canonical home — including two name-collision overrides (`Type` is Shopify's *free-text* type vs canonical's *structural* type; `Variant Weight Unit` is superseded by the grams→weight transform) — flow into the existing `unknown_columns` warning, which the preview UI already renders. The only value transform is `Variant Grams` → canonical `Variant Weight` (+ synthesized unit `g`). Frontend is already built for dialect display and the not-imported banner; SLICE-8 only sharpens the label and the upload-screen format help, and adds DOC-2.
**Tech Stack:** Python 3.13 / FastAPI backend (`backend/app/domains/products/`), Vitest + React/TS SPA (`frontend/src/`), Playwright E2E (`e2e/`). Tests: `pytest` (backend), `npm test` (frontend), `scripts/e2e.sh` (browser).
---
## The exhaustive Shopify → canonical mapping (the §6.5.1 fixture)
This table is the contract. It is pinned as a Python literal in Task 1 and as a CSV fixture in Task 3.
**Mapped — renamed** (Shopify header → canonical header):
| Shopify column | Canonical column | Note |
| --- | --- | --- |
| `Body (HTML)` | `Description` | HTML, sanitized downstream (INV-15) |
| `Product Category` | `Google Product Category` | taxonomy string |
| `Cost per item` | `Variant Cost` | decimal |
| `Variant Grams` | `Variant Weight` | value transform: grams numeric; unit `g` synthesized |
**Mapped — direct** (same name, already in `KNOWN_COLUMNS`, pass through): `Handle`, `Title`, `Vendor`, `Tags`, `Published`, `Status`, `Option1 Name`, `Option2 Name`, `Option3 Name`, `Option1 Value`, `Option2 Value`, `Option3 Value`, `Variant SKU`, `Variant Barcode`, `Variant Price`, `Variant Inventory Tracker`, `Variant Inventory Qty`, `Variant Image`, `Image Src`, `Image Position`, `Image Alt Text`.
**Not imported — warned** (no canonical home; surfaced in `unknown_columns`):
- **Name-collision overrides (must NOT pass through):**
- `Type` — Shopify free-text type; canonical `Type` is structural (`standalone`/kits, #15). Per decision D (spec §11): warned, never folded into canonical `Type` or `Tags`.
- `Variant Weight Unit` — superseded by the `Variant Grams` → weight transform (unit forced to `g`).
- **Shopify-only columns:** `Variant Compare At Price` (a.k.a. `Compare At Price`), `Variant Inventory Policy`, `Variant Fulfillment Service`, `Variant Requires Shipping`, `Variant Taxable`, `Variant Tax Code`, `Gift Card`, `SEO Title`, `SEO Description`, every `Google Shopping / *` column, and any market/region column (`Included / *`, `Price / *`, `Compare At Price / *`, `Price / International`, …).
**Algorithm (handles the unbounded market columns without enumerating them):** for each header column, in order —
1. in `SHOPIFY_RENAME` → its canonical name (mapped);
2. in `SHOPIFY_DROP` overrides (`{"Type", "Variant Weight Unit"}`) → not imported;
3. else in `KNOWN_COLUMNS` → pass through (mapped);
4. else → not imported.
**Detection signature** — a header is Shopify iff it contains ≥1 column canonical never has:
`{"Body (HTML)", "Variant Grams", "Cost per item", "Variant Compare At Price", "Variant Inventory Policy", "Variant Fulfillment Service", "Variant Requires Shipping", "Variant Taxable", "Gift Card", "SEO Title", "SEO Description"}` **or** any column starting `"Google Shopping / "`. Otherwise canonical (the safe default — shared columns map identically, so an ambiguous file never misparses; §7.4 risk row).
---
## File structure
- **Create** `backend/app/domains/products/dialect_shopify.py` — detection + header mapping + the pinned table. One responsibility: the Shopify adapter.
- **Modify** `backend/app/domains/products/codec.py``detect_dialect()` calls the adapter; `parse_csv()` rewrites the header before the known/unknown split and synthesizes the weight unit.
- **Create** `backend/tests/test_products_dialect_shopify.py` — adapter unit tests + the exhaustive-mapping fixture test.
- **Modify** `backend/tests/test_products_codec.py` — detection + end-to-end parse-as-shopify tests.
- **Modify** `backend/tests/test_products_invariants.py` — INV-10 over a partial Shopify file.
- **Create** `backend/app/domains/products/columns.md` — DOC-2 source (column reference, every column + dialect notes).
- **Modify** `backend/app/main.py` — add `GET /api/products/columns.md`.
- **Modify** `backend/tests/test_products_api.py` (or the existing API test module) — route test for DOC-2.
- **Modify** `frontend/src/productsApi.ts``dialectLabel("shopify")``"Shopify product CSV — mapped"`.
- **Modify** `frontend/src/screens/products/ImportUpload.tsx` — format help mentions Shopify + links DOC-2.
- **Modify** `frontend/src/screens/products/ProductsPage.tsx` — link DOC-2 beside the sample CSV.
- **Modify** `frontend/src/productsApi.test.ts` (or create) — `dialectLabel` cases.
- **Create** `e2e/fixtures/shopify-export.csv` — an unmodified-shape Shopify export fixture.
- **Create** `e2e/tests/import-shopify-dialect.spec.ts``e2e_import_shopify_dialect`.
- **Modify** `docs/products-domain.md` — DOC-4 dialect-adapter notes.
- **Modify** `backend/app/__init__.py` (or wherever `__version__`/`app.json` version lives) + `CHANGELOG`/version bump to v0.8.0.
---
### Task 1: Shopify dialect adapter module
**Files:**
- Create: `backend/app/domains/products/dialect_shopify.py`
- Test: `backend/tests/test_products_dialect_shopify.py`
- [ ] **Step 1: Write the failing tests**
```python
# backend/tests/test_products_dialect_shopify.py
from app.domains.products.dialect_shopify import is_shopify_header, map_shopify_header
def test_detects_shopify_by_signature_column():
assert is_shopify_header(["Handle", "Title", "Body (HTML)", "Variant Price"]) is True
assert is_shopify_header(["Handle", "Title", "Google Shopping / MPN"]) is True
def test_canonical_header_is_not_shopify():
assert is_shopify_header(["Handle", "Title", "Description", "Variant Cost"]) is False
def test_ambiguous_shared_only_header_defaults_canonical():
# Only columns common to both dialects -> not Shopify (safe default, never misparse).
assert is_shopify_header(["Handle", "Title", "Option1 Name", "Variant Price", "Image Src"]) is False
def test_map_renames_and_passes_through():
mapped, not_imported = map_shopify_header(
["Handle", "Body (HTML)", "Cost per item", "Variant Price"]
)
assert mapped == ["Handle", "Description", "Variant Cost", "Variant Price"]
assert not_imported == []
def test_map_drops_type_and_weight_unit_overrides():
mapped, not_imported = map_shopify_header(
["Handle", "Type", "Variant Grams", "Variant Weight Unit"]
)
assert mapped == ["Handle", None, "Variant Weight", None]
assert not_imported == ["Type", "Variant Weight Unit"]
def test_map_drops_shopify_only_and_market_columns():
mapped, not_imported = map_shopify_header(
["Handle", "Variant Compare At Price", "Gift Card", "Price / International"]
)
assert mapped == ["Handle", None, None, None]
assert not_imported == ["Variant Compare At Price", "Gift Card", "Price / International"]
```
- [ ] **Step 2: Run to verify it fails**
Run: `cd backend && python -m pytest tests/test_products_dialect_shopify.py -v`
Expected: FAIL — `ModuleNotFoundError: app.domains.products.dialect_shopify`
- [ ] **Step 3: Implement the adapter**
```python
# backend/app/domains/products/dialect_shopify.py
"""Shopify product-CSV adapter (INV-17): detect by header signature, map to canonical.
The mapping is the §6.5.1 contract; the exhaustive table is pinned here and
mirrored by tests/test_products_dialect_shopify.py + e2e/fixtures/shopify-export.csv.
"""
from __future__ import annotations
from .models import KNOWN_COLUMNS
# Shopify header -> canonical header (renames only; direct same-name columns pass
# through via KNOWN_COLUMNS). Variant Grams carries a value transform (see codec).
SHOPIFY_RENAME: dict[str, str] = {
"Body (HTML)": "Description",
"Product Category": "Google Product Category",
"Cost per item": "Variant Cost",
"Variant Grams": "Variant Weight",
}
# Canonical-named columns Shopify uses differently — must NOT pass through.
SHOPIFY_DROP: frozenset[str] = frozenset({"Type", "Variant Weight Unit"})
# Columns whose presence proves the file is a Shopify export (canonical never has them).
_SIGNATURE: frozenset[str] = frozenset({
"Body (HTML)", "Variant Grams", "Cost per item", "Variant Compare At Price",
"Variant Inventory Policy", "Variant Fulfillment Service",
"Variant Requires Shipping", "Variant Taxable", "Gift Card",
"SEO Title", "SEO Description",
})
def is_shopify_header(header: list[str]) -> bool:
cols = {h.strip() for h in header}
if cols & _SIGNATURE:
return True
return any(c.startswith("Google Shopping / ") for c in cols)
def map_shopify_header(header: list[str]) -> tuple[list[str | None], list[str]]:
"""Return (mapped, not_imported): mapped[i] is the canonical name for header[i],
or None when that Shopify column has no canonical home; not_imported lists the
original Shopify names (in order) that were dropped — the preview warning."""
mapped: list[str | None] = []
not_imported: list[str] = []
for raw in header:
col = raw.strip()
if col in SHOPIFY_RENAME:
mapped.append(SHOPIFY_RENAME[col])
elif col in SHOPIFY_DROP:
mapped.append(None)
not_imported.append(col)
elif col in KNOWN_COLUMNS:
mapped.append(col)
else:
mapped.append(None)
if col:
not_imported.append(col)
return mapped, not_imported
```
- [ ] **Step 4: Run to verify it passes**
Run: `cd backend && python -m pytest tests/test_products_dialect_shopify.py -v`
Expected: PASS (6 tests)
- [ ] **Step 5: Commit**
```bash
git add backend/app/domains/products/dialect_shopify.py backend/tests/test_products_dialect_shopify.py
git commit -m "feat(products): Shopify dialect adapter — header detection + canonical mapping (SD-0002 §6.5.1, SLICE-8)"
```
---
### Task 2: Wire the adapter into the codec
**Files:**
- Modify: `backend/app/domains/products/codec.py:15-66`
- Test: `backend/tests/test_products_codec.py`
- [ ] **Step 1: Write the failing tests** (append to `test_products_codec.py`)
```python
def test_parse_detects_and_maps_shopify():
data = (
"Handle,Title,Body (HTML),Cost per item,Variant Price,Variant Grams,Type,Gift Card\n"
"mug,Moon Mug,<p>Grey</p>,9.50,18.00,300,Drinkware,false\n"
).encode("utf-8")
parsed = parse_csv(data)
assert parsed.dialect == "shopify"
cells = parsed.rows[0].cells
assert cells["Description"] == "<p>Grey</p>"
assert cells["Variant Cost"] == "9.50"
assert cells["Variant Weight"] == "300"
assert cells["Variant Weight Unit"] == "g" # synthesized
# Type (free-text) and Gift Card warned, never mapped:
assert "Type" in parsed.unknown_columns
assert "Gift Card" in parsed.unknown_columns
assert "product_type" not in str(cells) # Type never reached canonical
def test_parse_canonical_unchanged():
data = b"Handle,Title,Description,Variant Price\nmug,Moon Mug,Grey,18.00\n"
parsed = parse_csv(data)
assert parsed.dialect == "canonical"
assert parsed.rows[0].cells["Description"] == "Grey"
assert parsed.unknown_columns == []
```
- [ ] **Step 2: Run to verify it fails**
Run: `cd backend && python -m pytest tests/test_products_codec.py -k "shopify or canonical_unchanged" -v`
Expected: FAIL — `parsed.dialect == "canonical"` (detection still stubbed) / `KeyError: 'Description'`
- [ ] **Step 3: Rewrite `detect_dialect` + `parse_csv`** in `codec.py`
Replace the `detect_dialect` stub and the header/cell-building block. New `codec.py` (lines from the import down through `parse_csv`):
```python
from .dialect_shopify import is_shopify_header, map_shopify_header
from .models import KNOWN_COLUMNS, MAX_DATA_ROWS, MAX_FILE_BYTES, ParsedFile, Row
_REQUIRED_HEADER_COLUMNS = ("Handle", "Title")
def detect_dialect(header: list[str]) -> str:
"""The INV-17 seam: recognize Shopify's header set, else canonical (§6.5.1)."""
return "shopify" if is_shopify_header(header) else "canonical"
def parse_csv(data: bytes) -> ParsedFile:
if len(data) > MAX_FILE_BYTES:
raise FileRejected("file_too_large", "This file is larger than 10 MB.")
try:
text = data.decode("utf-8-sig")
except UnicodeDecodeError:
raise FileRejected("not_csv", "This file isn't readable as CSV.") from None
reader = csv.reader(io.StringIO(text))
try:
try:
raw_header = next(reader)
except StopIteration:
raise FileRejected("not_csv", "This file isn't readable as CSV.") from None
header = [h.strip() for h in raw_header]
dialect = detect_dialect(header)
# INV-17: normalize the header to canonical names at the boundary. For Shopify,
# mapped[i] is the canonical name (or None when dropped); not_imported is the
# warning list. Canonical files map to themselves, unknowns warned as before.
if dialect == "shopify":
mapped, unknown = map_shopify_header(header)
else:
mapped = [c if c in KNOWN_COLUMNS else None for c in header]
unknown = [c for c in header if c and c not in KNOWN_COLUMNS]
for col in _REQUIRED_HEADER_COLUMNS:
if col not in (mapped):
raise FileRejected(
"missing_required_column",
f"This file is missing the required column '{col}'.",
)
# First occurrence of a duplicated canonical column wins.
col_index: dict[str, int] = {}
for i, name in enumerate(mapped):
if name and name not in col_index:
col_index[name] = i
# De-dup the warning list, order-preserving.
seen: set[str] = set()
unknown = [c for c in unknown if not (c in seen or seen.add(c))]
rows: list[Row] = []
for raw in reader:
if not any(cell.strip() for cell in raw):
continue
if len(rows) >= MAX_DATA_ROWS:
raise FileRejected(
"too_many_rows",
f"This file has more than {MAX_DATA_ROWS:,} rows — split it and import in parts.",
)
cells = {
c: (raw[col_index[c]].strip() if col_index[c] < len(raw) else "")
for c in col_index
}
# Shopify grams carry an implicit unit; canonical needs it explicit (§6.5.1).
if dialect == "shopify" and cells.get("Variant Weight"):
cells["Variant Weight Unit"] = "g"
rows.append(Row(line_number=reader.line_num, cells=cells))
except csv.Error:
raise FileRejected("not_csv", "This file isn't readable as CSV.") from None
return ParsedFile(dialect=dialect, header=header, unknown_columns=unknown, rows=rows)
```
Note: the required-column check now reads `mapped` (canonical names) so a Shopify file whose `Title`/`Handle` are present passes, and a file genuinely missing them still fails honestly. `Handle` and `Title` are direct pass-through names in both dialects, so `mapped` contains them when present.
- [ ] **Step 4: Run to verify it passes**
Run: `cd backend && python -m pytest tests/test_products_codec.py -v`
Expected: PASS (existing canonical tests + 2 new)
- [ ] **Step 5: Run the whole products suite (no regressions)**
Run: `cd backend && python -m pytest tests/ -q`
Expected: PASS — validate/diff/invariants unchanged (they consume canonical cells; INV-17 holds).
- [ ] **Step 6: Commit**
```bash
git add backend/app/domains/products/codec.py backend/tests/test_products_codec.py
git commit -m "feat(products): codec detects + maps Shopify dialect at the boundary (INV-17, SLICE-8)"
```
---
### Task 3: Exhaustive-mapping fixture test (the §6.5.1 pin)
**Files:**
- Create: `backend/tests/fixtures/shopify-export.csv`
- Test: `backend/tests/test_products_dialect_shopify.py` (append)
- [ ] **Step 1: Create a realistic full-width Shopify export fixture**
`backend/tests/fixtures/shopify-export.csv` — one product, two variants, one image-only row, exercising every mapped + several not-imported columns:
```csv
Handle,Title,Body (HTML),Vendor,Product Category,Type,Tags,Published,Status,Option1 Name,Option1 Value,Variant SKU,Variant Grams,Variant Inventory Tracker,Variant Inventory Qty,Variant Inventory Policy,Variant Fulfillment Service,Variant Price,Variant Compare At Price,Variant Requires Shipping,Variant Taxable,Variant Barcode,Image Src,Image Position,Image Alt Text,Gift Card,SEO Title,SEO Description,Google Shopping / MPN,Variant Image,Variant Weight Unit,Cost per item,Status
star-tee,Star Tee,<p>Soft cotton tee.</p>,Wiggle Goods,Apparel & Accessories > Clothing,Shirts,"apparel, tees",TRUE,active,Size,S,WG-TEE-S,180,shopify,12,deny,manual,24.00,30.00,TRUE,TRUE,0001,https://img.example.com/star-tee.jpg,1,Star Tee,FALSE,Star Tee | Wiggle,Soft tee,MPN-1,https://img.example.com/star-s.jpg,g,11.00,active
star-tee,,,,,,,,,,M,WG-TEE-M,180,shopify,18,deny,manual,24.00,30.00,TRUE,TRUE,0002,,,,FALSE,,,,,g,11.00,
star-tee,,,,,,,,,,,,,,,,,,,,,,https://img.example.com/star-back.jpg,2,Star Tee back,,,,,,,,
```
- [ ] **Step 2: Write the failing test**
```python
import csv as _csv
import io
from pathlib import Path
from app.domains.products.codec import parse_csv
_FIXTURE = Path(__file__).parent / "fixtures" / "shopify-export.csv"
def test_shopify_fixture_maps_exhaustively():
parsed = parse_csv(_FIXTURE.read_bytes())
assert parsed.dialect == "shopify"
first = parsed.rows[0].cells
# renamed
assert first["Description"] == "<p>Soft cotton tee.</p>"
assert first["Google Product Category"] == "Apparel & Accessories > Clothing"
assert first["Variant Cost"] == "11.00"
assert first["Variant Weight"] == "180"
assert first["Variant Weight Unit"] == "g"
# direct
assert first["Variant SKU"] == "WG-TEE-S"
assert first["Image Src"] == "https://img.example.com/star-tee.jpg"
# not imported — warned, none leaked into canonical cells
for col in ("Type", "Variant Compare At Price", "Gift Card", "SEO Title",
"Google Shopping / MPN", "Variant Weight Unit", "Variant Inventory Policy",
"Variant Fulfillment Service", "Variant Requires Shipping", "Variant Taxable"):
assert col in parsed.unknown_columns, col
assert "Variant Compare At Price" not in first
```
- [ ] **Step 3: Run — expect PASS** (codec from Task 2 already maps correctly)
Run: `cd backend && python -m pytest tests/test_products_dialect_shopify.py::test_shopify_fixture_maps_exhaustively -v`
Expected: PASS. If a column assertion fails, fix the mapping table in `dialect_shopify.py` to match this fixture — the fixture and table must agree.
- [ ] **Step 4: Commit**
```bash
git add backend/tests/fixtures/shopify-export.csv backend/tests/test_products_dialect_shopify.py
git commit -m "test(products): exhaustive Shopify->canonical mapping fixture (SD-0002 §6.5.1, SLICE-8)"
```
---
### Task 4: INV-10 over a partial Shopify import (never deletes)
**Files:**
- Test: `backend/tests/test_products_invariants.py` (append)
- [ ] **Step 1: Inspect the existing INV-10 test** to reuse its catalog/diff helpers
Run: `cd backend && grep -n "def test_inv10\|compute_diff\|build_products\|catalog" tests/test_products_invariants.py | head`
Expected: shows the helper pattern (catalog fixture → parse → build_products → compute_diff → assert no deletes in plan).
- [ ] **Step 2: Write the failing test** (mirror the existing INV-10 test, swapping in a Shopify file that omits an existing product)
```python
def test_inv10_shopify_partial_never_deletes():
# An existing catalog of two products; a Shopify file mentioning only one.
catalog = _catalog_with(["star-tee", "moon-mug"]) # existing helper
data = (
"Handle,Title,Body (HTML),Variant Price,Variant Grams\n"
"star-tee,Star Tee,<p>x</p>,24.00,180\n"
).encode("utf-8")
parsed = parse_csv(data)
assert parsed.dialect == "shopify"
products = build_products(parsed)
result = compute_diff(catalog, products)
# INV-10: nothing in the plan deletes; moon-mug (unmentioned) is untouched.
assert all(op.kind != "delete" for op in result.plan)
assert "moon-mug" not in {p.handle for p in products}
```
Adapt `_catalog_with` / `op.kind` / `result.plan` to the actual symbols found in Step 1.
- [ ] **Step 3: Run to verify it passes**
Run: `cd backend && python -m pytest tests/test_products_invariants.py -k inv10_shopify -v`
Expected: PASS (INV-10 is dialect-agnostic; this proves it over Shopify).
- [ ] **Step 4: Commit**
```bash
git add backend/tests/test_products_invariants.py
git commit -m "test(products): INV-10 holds over a partial Shopify import (SLICE-8)"
```
---
### Task 5: Frontend — Shopify label + format help
**Files:**
- Modify: `frontend/src/productsApi.ts:52-54`
- Modify: `frontend/src/screens/products/ImportUpload.tsx:53`
- Modify: `frontend/src/screens/products/ProductsPage.tsx:114`
- Test: `frontend/src/productsApi.test.ts`
- [ ] **Step 1: Write the failing test** (create or append `frontend/src/productsApi.test.ts`)
```ts
import { describe, expect, it } from "vitest";
import { dialectLabel } from "./productsApi";
describe("dialectLabel", () => {
it("labels canonical", () => expect(dialectLabel("canonical")).toBe("Canonical format"));
it("labels shopify as mapped", () =>
expect(dialectLabel("shopify")).toBe("Shopify product CSV — mapped"));
});
```
- [ ] **Step 2: Run to verify it fails**
Run: `cd frontend && npm test -- productsApi`
Expected: FAIL — receives `"shopify"`, expected `"Shopify product CSV — mapped"`.
- [ ] **Step 3: Update `dialectLabel`**
```ts
// frontend/src/productsApi.ts
export function dialectLabel(d: string): string {
if (d === "canonical") return "Canonical format";
if (d === "shopify") return "Shopify product CSV — mapped";
return d;
}
```
- [ ] **Step 4: Run to verify it passes**
Run: `cd frontend && npm test -- productsApi`
Expected: PASS
- [ ] **Step 5: Update the upload-screen format help**`ImportUpload.tsx:53`
Replace the line `Works with the canonical format.{" "}` and the following sample link block so it reads (PUC-11, §5.4 wireframe text):
```tsx
Works with the canonical format or a Shopify product CSV.{" "}
<a href="/api/products/sample.csv" download>
Download a sample
</a>{" "}
·{" "}
<a href="/api/products/columns.md" target="_blank" rel="noopener">
Column reference
</a>
```
- [ ] **Step 6: Add the column-reference link beside the sample on the Products page**`ProductsPage.tsx:114`
After the existing `<a href="/api/products/sample.csv" download>` element, add:
```tsx
{" · "}
<a href="/api/products/columns.md" target="_blank" rel="noopener">
Column reference
</a>
```
- [ ] **Step 7: Build + typecheck + test**
Run: `cd frontend && npm run build && npm test`
Expected: PASS (no TS errors, vitest green).
- [ ] **Step 8: Commit**
```bash
git add frontend/src/productsApi.ts frontend/src/productsApi.test.ts frontend/src/screens/products/ImportUpload.tsx frontend/src/screens/products/ProductsPage.tsx
git commit -m "feat(products-ui): Shopify 'mapped' label + format help links to column reference (PUC-6/PUC-11, SLICE-8)"
```
---
### Task 6: DOC-2 column reference + route; finalize DOC-3
**Files:**
- Create: `backend/app/domains/products/columns.md`
- Modify: `backend/app/main.py` (after the `sample.csv` route, ~line 451)
- Modify: `backend/app/domains/products/__init__.py` (export `COLUMNS_MD_PATH` next to `SAMPLE_CSV_PATH`)
- Test: the existing products API test module (find it in Step 4)
- [ ] **Step 1: Write the DOC-2 column reference**`backend/app/domains/products/columns.md`
A complete merchant-facing reference: a short intro (canonical = Shopify-flavored superset; auto-detection; blank-vs-absent semantics), then a table of **every canonical column** (name · level · required · accepted values), then a **Shopify dialect notes** section reproducing the Task-1 mapping table (renamed, direct, not-imported). Source the canonical column rows from spec §6.5.1 (the table at lines 852-873) and the dialect notes from §6.5.1 (lines 880-889). Keep it plain Markdown — no app-specific build step.
- [ ] **Step 2: Export the path**`backend/app/domains/products/__init__.py`
Add beside the existing `SAMPLE_CSV_PATH`:
```python
COLUMNS_MD_PATH = _HERE / "columns.md"
```
(Use the same `_HERE` / `Path(__file__).parent` idiom already present for `SAMPLE_CSV_PATH`.)
- [ ] **Step 3: Add the route**`backend/app/main.py`, immediately after `products_sample_csv`
```python
@app.get("/api/products/columns.md")
def products_columns_md():
"""The DOC-2 column reference. Documentation, so no auth gate (§6.4)."""
return PlainTextResponse(
products.COLUMNS_MD_PATH.read_text(),
media_type="text/markdown; charset=utf-8",
)
```
- [ ] **Step 4: Write the failing route test** — find the API test module first
Run: `cd backend && ls tests/ | grep -i "api\|main"`
Append to the module that tests the other unauthenticated doc routes:
```python
def test_columns_md_served(client):
resp = client.get("/api/products/columns.md")
assert resp.status_code == 200
assert "text/markdown" in resp.headers["content-type"]
body = resp.text
assert "Body (HTML)" in body # dialect notes present
assert "Handle" in body # canonical columns present
```
- [ ] **Step 5: Run to verify it passes**
Run: `cd backend && python -m pytest tests/ -k "columns_md or sample" -v`
Expected: PASS
- [ ] **Step 6: Verify DOC-3 sample.csv is final** — it already carries a variant product (`star-tee`, S/M/L) and image rows. Confirm it parses clean as canonical:
Run: `cd backend && python -c "from app.domains.products.codec import parse_csv; from app.domains.products import SAMPLE_CSV_PATH; p=parse_csv(SAMPLE_CSV_PATH.read_bytes()); print(p.dialect, len(p.rows), p.unknown_columns)"`
Expected: `canonical 5 []` (no unknown columns; 5 data rows). If `unknown_columns` is non-empty, fix the sample header to canonical names.
- [ ] **Step 7: Commit**
```bash
git add backend/app/domains/products/columns.md backend/app/domains/products/__init__.py backend/app/main.py backend/tests/
git commit -m "docs(products): DOC-2 column reference served at /api/products/columns.md (SLICE-8)"
```
---
### Task 7: DOC-4 dialect-adapter dev notes
**Files:**
- Modify: `docs/products-domain.md`
- [ ] **Step 1: Add a "Dialects (INV-17)" subsection** documenting: detection by signature columns; the `dialect_shopify` adapter; the rename/drop/passthrough algorithm; the grams→weight transform; that all downstream stages are dialect-agnostic. Point to `dialect_shopify.py` and the fixture as the source of truth (documentation-leads-automation, §4.1).
- [ ] **Step 2: Commit**
```bash
git add docs/products-domain.md
git commit -m "docs(products): DOC-4 dialect-adapter notes (SLICE-8)"
```
---
### Task 8: E2E — `e2e_import_shopify_dialect`
**Files:**
- Create: `e2e/fixtures/shopify-export.csv` (copy the Task-3 fixture)
- Create: `e2e/tests/import-shopify-dialect.spec.ts`
- [ ] **Step 1: Add the fixture** — same content as `backend/tests/fixtures/shopify-export.csv`.
- [ ] **Step 2: Read an existing import E2E spec** to reuse the sign-up + upload helpers
Run: `sed -n '1,60p' e2e/tests/import-preview-confirm.spec.ts`
Expected: shows the page-object / sign-up helper and the upload→preview→confirm flow to mirror.
- [ ] **Step 3: Write `e2e/tests/import-shopify-dialect.spec.ts`** (mirroring that helper)
```ts
import { test, expect } from "@playwright/test";
import { signUpAndOpenImport } from "./helpers"; // use the actual helper from Step 2
test("e2e_import_shopify_dialect: a Shopify export imports directly (PUC-6)", async ({ page }) => {
await signUpAndOpenImport(page);
await page.getByLabel(/choose|upload|file/i).setInputFiles("fixtures/shopify-export.csv");
// Preview shows the mapped banner + not-imported warning
await expect(page.getByText("Shopify product CSV — mapped")).toBeVisible();
await expect(page.getByText(/not imported/i)).toBeVisible();
await expect(page.getByText("Type")).toBeVisible(); // a warned column
// Confirm and land on the run detail
await page.getByRole("button", { name: /^Import \d/ }).click();
await expect(page).toHaveURL(/\/products\/imports\/runs\/\d+/);
await expect(page.getByText("Shopify product CSV — mapped")).toBeVisible();
});
```
Adapt selectors to the real upload control + helper names found in Step 2.
- [ ] **Step 4: Run the E2E suite**
Run: `./scripts/e2e.sh`
Expected: all specs PASS including `import-shopify-dialect` (7 prior + 1 new = 8).
- [ ] **Step 5: Commit**
```bash
git add e2e/fixtures/shopify-export.csv e2e/tests/import-shopify-dialect.spec.ts
git commit -m "test(e2e): e2e_import_shopify_dialect — Shopify export imports directly (PUC-6, SLICE-8)"
```
---
### Task 9: Traceability audit, version bump, full green
**Files:**
- Modify: version source (`backend/app/__init__.py` or `app.json`) + any `CHANGELOG`
- Read-only: spec §12 traceability matrix
- [ ] **Step 1: Audit §12 traceability** — confirm PUC-6, PUC-11, BUC-1 (Shopify), DOC-2, DOC-3 now have shipping code + tests. Note any gap in the transcript `## Deferred decisions`. (Spec audit is read-only; do not edit the content repo here — finalize submits the plan.)
- [ ] **Step 2: Bump the version to v0.8.0**
Run: `cd backend && grep -rn "0.7.0" app/__init__.py app.json 2>/dev/null`
Update the version string to `0.8.0` wherever the prior slice set it (mirror the SLICE-7 v0.7.0 bump).
- [ ] **Step 3: Full local gate**
Run: `./scripts/check.sh && ./scripts/e2e.sh` (or the repo's canonical localhost gate)
Expected: backend pytest, frontend build+vitest, import-linter, and Playwright all PASS.
- [ ] **Step 4: Commit**
```bash
git add -A
git commit -m "chore(products): SLICE-8 complete — bump v0.8.0; §12 traceability audited (SD-0002)"
```
---
## Post-plan (session flow, not subagent tasks)
After all tasks are green on the branch:
1. **PR → merge to `main`** (branch→PR→merge; §5.4).
2. **PPE deploy** via flotilla + E2E green on `https://ecomm-ppe.wiggleverse.org`; stamp the `release/<ts>` tag (§9.1). Prod promotion stays the operator's gate.
3. **Close story #13** (SLICE-5/6/7/8 all done) with a pointer to the merge + PPE release tag.
4. **Finalize** the session (`wgl-session-finalize`) — archives this plan to the content repo `plans/`, updates memory, publishes the transcript.
---
## Self-Review
**Spec coverage (SLICE-8 §7.2 DoD):**
- Header-set dialect detection → Task 1 (`is_shopify_header`) + Task 2 (`detect_dialect`). ✓
- Shopify→canonical mapping + exhaustive fixture → Task 1 (table) + Task 3 (fixture pin). ✓
- Preview "mapped" banner + not-imported warnings → Task 5 (label) + already-built `unknown_columns` banner (`ImportPreview.tsx:288`). ✓
- DOC-2 column reference (published) → Task 6. ✓
- DOC-3 final sample CSV → Task 6 Step 6 (verify; already has variants+images). ✓
- `e2e_import_shopify_dialect` green → Task 8. ✓
- BUC-1 acceptance for an unmodified Shopify export → Task 3 fixture + Task 8 E2E. ✓
- §12 traceability audited → Task 9. ✓
- INV-10 over Shopify → Task 4. ✓
**Placeholder scan:** every code step shows concrete code; selectors/helpers in Tasks 4/6/8 are explicitly flagged to adapt to symbols discovered in a named inspection step (not silent TBDs).
**Type consistency:** `is_shopify_header` / `map_shopify_header` signatures match between Task 1 (def), Task 2 (import + call), and Task 3 (test). `dialectLabel("shopify")` string is identical in Task 5 def, Task 5 test, and Task 8 E2E assertion (`"Shopify product CSV — mapped"`). `COLUMNS_MD_PATH` defined (Task 6 Step 2) before use (Task 6 Step 3).
**Deferred decisions (logged to transcript):**
- `Variant Grams``Variant Weight` value with unit forced to `g`; Shopify's own `Variant Weight Unit` display column is *not imported* (warned). Lossless in mass (grams is exact); chosen over carrying a possibly-inconsistent unit. Revisit if merchants need kg/lb display fidelity.
- DOC-2 served as raw Markdown (`text/markdown`), mirroring how DOC-3 `sample.csv` is served — not a styled HTML page. Satisfies "app-served, source in framework repo"; styled rendering is a future enhancement.
- Ambiguous header (only shared columns) defaults to **canonical** rather than a distinct `unknown_dialect` state — shared columns map identically so it never misparses (§7.4 intent); avoids a third dialect state the model doesn't carry.