◐ Off-By-One · answer catalog

board-duckdb-migration-parser-drop

2 answer(s)godockergodocker

TASKIDRE = re.compile(r"[A-Za-z]A-Za-z0-9-\d+[A-Za-z]")

📦 Source in repository (JSON)

Answer 1

The silent drops came from three parser defects in migrate-board-to-duckdb.py. The fix (full file: ~/board-migration/migrate-board-to-duckdb.py) makes every bullet a first-class row, drives completion from the status column, then verifies with a diff, backfills, and audits.

1. Lenient task-ID extraction (fixes BUG-E2E-001a-style drops)

# OLD (buggy):  TASK_ID_RE = re.compile(r"^([A-Z]+-\d+)")   # rejects E2E, 001a
TASK_ID_RE = re.compile(r"[A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)*-\d+[A-Za-z]*")
FALLBACK_ID_RE = re.compile(r"[A-Za-z0-9]+(?:-[A-Za-z0-9]+)+")   # last resort

def extract_id(body: str) -> Optional[str]:
    m = TASK_ID_RE.match(body)
    if m: return m.group(0)
    m = FALLBACK_ID_RE.match(body)
    return m.group(0) if m else None

# ID-less rows are never dropped — they get a generated placeholder
if task_id is None:
    task_id = f"UNPARSED-{slugify(section)}-{counter:03d}"   # + generated=True

2. Parse every ## section, not just Active* (fixes ## Dogfood Findings)

if stripped.startswith("## "):
    section = stripped[3:].strip()   # any section; name stored on the row
    continue
if section and is_row_bullet(line):
    # capture bullet + its sub-lines ("- status: …", nested bullets) into a block

is_row_bullet requires a checkbox or a leading machine ID, so - status: fields and - sub detail bullets are never mistaken for rows:

def is_row_bullet(line: str) -> bool:
    if not BULLET_RE.match(line) or FIELD_RE.match(line): return False
    body = BULLET_RE.sub("", line).strip()
    if CHECKBOX_RE.match(body): return True
    if len(line) - len(line.lstrip()) > 2: return False
    return extract_id(re.sub(r"^[^\w-]+", "", body)) is not None

3. Completion comes from the status: column; ✅ is only a signal (fixes silent "complete")

COMPLETE_STATUSES = {"done","closed","complete","resolved","shipped","fixed","verified"}

@dataclass
class Row:
    ...
    @property
    def complete(self) -> bool:            # truth = board's status column
        if not self.status: return False
        head = self.status.strip().rstrip(".!").lower().split()[0]
        return head in COMPLETE_STATUSES
    @property
    def mismatch(self) -> bool:            # ✅/checkbox disagrees -> eyeball it
        return (self.checked or self.emoji_done) != self.complete

4. Post-migration verification: diff → backfill → audit

# diff: DB task counts vs manual per-section bullet count straight from the board
def diff_report(con, board_text):
    manual = manual_section_counts(board_text)          # human-style bullet count
    db = dict(con.execute("SELECT section, COUNT(DISTINCT task_id) FROM tasks GROUP BY section"))
    ...  # prints  OK/MISMATCH per section, Active* sections highlighted

# backfill: re-insert any board row whose ID is missing, with an audit-trail entry
def backfill_missing(con, rows):
    existing = {r[0] for r in con.execute("SELECT task_id FROM tasks")}
    missing = [r for r in rows if r.task_id not in existing]
    ...  # INSERT ... ON CONFLICT (task_id) DO UPDATE, backfilled=TRUE
         # + INSERT INTO migration_audit (task_id, reason, source_text)

# audit: eyeball every non-complete / mismatch / generated row with raw source text
def audit_report(rows):
    for r in [r for r in rows if not r.complete or r.mismatch or r.generated]:
        print(f"  [{r.section}] {r.task_id}  ({'; '.join(reasons)})")
        for src in r.raw.splitlines(): print(f"      {src}")

migrate() also runs ALTER TABLE tasks ADD COLUMN … for any missing columns, so an existing DB produced by the old script is upgraded in place instead of erroring.

Evidence & signatures

**Reproduced the old drops first** (test `test_old_regex_would_have_dropped_them`): on the sample board, the old parser (`^([A-Z]+-\d+)` + Active-only sections) kept exactly `[TASK-0012, TASK-0044, TASK-0055, BUG-2002]` — 5 of 9 rows silently dropped, including all of `## Dogfood Findings`.

**After the fix** — CLI run on `board.md`:

```
DIFF (Tasks found vs manual row count)
  OK   Active Sprint [Active*]: found=5 manual=5
  OK   Dogfood Findings: found=3 manual=3
  OK   Backlog: found=1 manual=1
  => all sections in sync
```

DB contents: all **9/9 rows** present; `BUG-E2E-001a` and `BUG-E2E-002b` migrated; `TASK-0044` (✅ in title, `status: In Progress`) is `complete=False` with `mismatch=True`; `TASK-0055` (`[x]` but no status) is flagged for eyeball; the ID-less row became `UNPARSED-Dogfood-Findings-001`.

**Backfill recovery demo** (DB that already lost rows to the old parser, old 2-column schema): migrate upgraded the schema, upserted all 9; after a simulated re-drop of `TASK-0099`, `backfill_missing` restored it and wrote an `migration_audit` row — total back to 9, diff clean.

**Edge cases covered by the 22 passing pytest tests** (`~/board-migration/test_migration.py`):
- lowercase + alpha-suffix IDs (`BUG-E2E-001a`, `BUG-E2E-002b`, `TASK-001a`)
- ✅ before the ID (`✅ BUG-E2E-009c`) and after the ID
- non-Active sections parsed with section names stored; empty sections count 0
- ✅ in title vs status column (not marked complete; mismatch flagged)
- `[x]` without status → audit; genuinely Done rows not flagged
- ID-less rows get placeholders, never dropped
- duplicate IDs across sections counted distinctly
- CRLF endings + trailing whitespace; nested sub-bullets not mistaken for rows
- idempotency: migrate/backfill run twice → no duplicates (upsert)
{"model": "deepseek-v4-flash", "problem_class": "board-duckdb-migration-parser-drop", "result": "passed", "tests": 22}

Answer 2

The silent drops came from three parser defects in migrate-board-to-duckdb.py. The fix (full file: ~/board-migration/migrate-board-to-duckdb.py) makes every bullet a first-class row, drives completion from the status column, then verifies with a diff, backfills, and audits.

1. Lenient task-ID extraction (fixes BUG-E2E-001a-style drops)

# OLD (buggy):  TASK_ID_RE = re.compile(r"^([A-Z]+-\d+)")   # rejects E2E, 001a
TASK_ID_RE = re.compile(r"[A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)*-\d+[A-Za-z]*")
FALLBACK_ID_RE = re.compile(r"[A-Za-z0-9]+(?:-[A-Za-z0-9]+)+")   # last resort

def extract_id(body: str) -> Optional[str]:
    m = TASK_ID_RE.match(body)
    if m: return m.group(0)
    m = FALLBACK_ID_RE.match(body)
    return m.group(0) if m else None

# ID-less rows are never dropped — they get a generated placeholder
if task_id is None:
    task_id = f"UNPARSED-{slugify(section)}-{counter:03d}"   # + generated=True

2. Parse every ## section, not just Active* (fixes ## Dogfood Findings)

if stripped.startswith("## "):
    section = stripped[3:].strip()   # any section; name stored on the row
    continue
if section and is_row_bullet(line):
    # capture bullet + its sub-lines ("- status: …", nested bullets) into a block

is_row_bullet requires a checkbox or a leading machine ID, so - status: fields and - sub detail bullets are never mistaken for rows:

def is_row_bullet(line: str) -> bool:
    if not BULLET_RE.match(line) or FIELD_RE.match(line): return False
    body = BULLET_RE.sub("", line).strip()
    if CHECKBOX_RE.match(body): return True
    if len(line) - len(line.lstrip()) > 2: return False
    return extract_id(re.sub(r"^[^\w-]+", "", body)) is not None

3. Completion comes from the status: column; ✅ is only a signal (fixes silent "complete")

COMPLETE_STATUSES = {"done","closed","complete","resolved","shipped","fixed","verified"}

@dataclass
class Row:
    ...
    @property
    def complete(self) -> bool:            # truth = board's status column
        if not self.status: return False
        head = self.status.strip().rstrip(".!").lower().split()[0]
        return head in COMPLETE_STATUSES
    @property
    def mismatch(self) -> bool:            # ✅/checkbox disagrees -> eyeball it
        return (self.checked or self.emoji_done) != self.complete

4. Post-migration verification: diff → backfill → audit

# diff: DB task counts vs manual per-section bullet count straight from the board
def diff_report(con, board_text):
    manual = manual_section_counts(board_text)          # human-style bullet count
    db = dict(con.execute("SELECT section, COUNT(DISTINCT task_id) FROM tasks GROUP BY section"))
    ...  # prints  OK/MISMATCH per section, Active* sections highlighted

# backfill: re-insert any board row whose ID is missing, with an audit-trail entry
def backfill_missing(con, rows):
    existing = {r[0] for r in con.execute("SELECT task_id FROM tasks")}
    missing = [r for r in rows if r.task_id not in existing]
    ...  # INSERT ... ON CONFLICT (task_id) DO UPDATE, backfilled=TRUE
         # + INSERT INTO migration_audit (task_id, reason, source_text)

# audit: eyeball every non-complete / mismatch / generated row with raw source text
def audit_report(rows):
    for r in [r for r in rows if not r.complete or r.mismatch or r.generated]:
        print(f"  [{r.section}] {r.task_id}  ({'; '.join(reasons)})")
        for src in r.raw.splitlines(): print(f"      {src}")

migrate() also runs ALTER TABLE tasks ADD COLUMN … for any missing columns, so an existing DB produced by the old script is upgraded in place instead of erroring.

Evidence & signatures

**Reproduced the old drops first** (test `test_old_regex_would_have_dropped_them`): on the sample board, the old parser (`^([A-Z]+-\d+)` + Active-only sections) kept exactly `[TASK-0012, TASK-0044, TASK-0055, BUG-2002]` — 5 of 9 rows silently dropped, including all of `## Dogfood Findings`.

**After the fix** — CLI run on `board.md`:

```
DIFF (Tasks found vs manual row count)
  OK   Active Sprint [Active*]: found=5 manual=5
  OK   Dogfood Findings: found=3 manual=3
  OK   Backlog: found=1 manual=1
  => all sections in sync
```

DB contents: all **9/9 rows** present; `BUG-E2E-001a` and `BUG-E2E-002b` migrated; `TASK-0044` (✅ in title, `status: In Progress`) is `complete=False` with `mismatch=True`; `TASK-0055` (`[x]` but no status) is flagged for eyeball; the ID-less row became `UNPARSED-Dogfood-Findings-001`.

**Backfill recovery demo** (DB that already lost rows to the old parser, old 2-column schema): migrate upgraded the schema, upserted all 9; after a simulated re-drop of `TASK-0099`, `backfill_missing` restored it and wrote an `migration_audit` row — total back to 9, diff clean.

**Edge cases covered by the 22 passing pytest tests** (`~/board-migration/test_migration.py`):
- lowercase + alpha-suffix IDs (`BUG-E2E-001a`, `BUG-E2E-002b`, `TASK-001a`)
- ✅ before the ID (`✅ BUG-E2E-009c`) and after the ID
- non-Active sections parsed with section names stored; empty sections count 0
- ✅ in title vs status column (not marked complete; mismatch flagged)
- `[x]` without status → audit; genuinely Done rows not flagged
- ID-less rows get placeholders, never dropped
- duplicate IDs across sections counted distinctly
- CRLF endings + trailing whitespace; nested sub-bullets not mistaken for rows
- idempotency: migrate/backfill run twice → no duplicates (upsert)
{"model": "deepseek-v4-flash", "problem_class": "board-duckdb-migration-parser-drop", "result": "passed", "tests": 22}
Generated from the verified corpus · MIT licensedBack to the catalog