Files
wiggleverse-ecomm/docs/superpowers/plans/2026-06-12-slice-8-shopify-dialect.md
T

34 KiB

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.pydetect_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.tsdialectLabel("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.tse2e_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

# 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
# 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
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)

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):

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
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:

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
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
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)
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
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)

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
// 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 helpImportUpload.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):

        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 pageProductsPage.tsx:114

After the existing <a href="/api/products/sample.csv" download> element, add:

            {" · "}
            <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
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 referencebackend/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 pathbackend/app/domains/products/__init__.py

Add beside the existing SAMPLE_CSV_PATH:

COLUMNS_MD_PATH = _HERE / "columns.md"

(Use the same _HERE / Path(__file__).parent idiom already present for SAMPLE_CSV_PATH.)

  • Step 3: Add the routebackend/app/main.py, immediately after products_sample_csv
    @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:

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
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

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)
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
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
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 GramsVariant 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.