Skip to content

merge main into development#2549

Merged
mikib0 merged 89 commits into
developmentfrom
main
Jun 2, 2026
Merged

merge main into development#2549
mikib0 merged 89 commits into
developmentfrom
main

Conversation

@mikib0

@mikib0 mikib0 commented Jun 2, 2026

Copy link
Copy Markdown
Collaborator

Summary by CodeRabbit

  • New Features

    • Added comprehensive coverage ratcheting system for quality gates.
    • Enhanced ETL pipeline with improved validation, error handling, and admin endpoints for catalog re-ingestion and reconciliation.
    • Added support for JSONL catalog uploads alongside CSV format.
    • Catalog items now support unknown weight values.
    • Added structured logging and observability enhancements.
  • Chores

    • Bumped all packages to version 2.0.27.
    • Migrated testing documentation and updated CI workflows.

andrew-bierman and others added 30 commits May 19, 2026 21:51
Adds scripts/lint/no-weak-assertions.ts mirroring the no-raw-typeof.ts
shape. Catches four coverage-theater patterns in *.test.ts files:

  - assertion-free-test    : it() / test() blocks with no expect() or
                             expect-helper calls (expectUnauthorized,
                             expectJsonResponse, etc.)
  - only-tobedefined       : every direct expect() ends in a non-specific
                             matcher (.toBeDefined / .toBeTruthy /
                             .toBeFalsy / .not.toBeUndefined / .not.toBeNull)
                             AND no expect-helper is called.
                             (.toBeUndefined() and .toBeNull() alone are
                             NOT flagged — they assert specific return
                             values.)
  - bare-tohavebeencalled  : .toHaveBeenCalled() without a matching
                             .toHaveBeenCalledWith / .toHaveBeenCalledTimes
                             in the same block.
  - large-snapshot         : toMatchInlineSnapshot() body > 50 lines.

File-level escape hatch: `// no-weak-assertions: disable` in the first 5
lines of a file skips the entire file.

Add `bun lint:weak-assertions` and `bun test:scripts` plus a
`scripts/vitest.config.ts` for the scripts test suite. The check is NOT
wired into `scripts/check-all.ts` yet — running it against the repo
surfaces ~27 pre-existing violations (mostly .toHaveBeenCalled() and
.toBeDefined()-only blocks). Wiring into check-all.ts will follow once
those are addressed in a separate cleanup PR.

Includes 16 unit tests for the analyzer covering each rule's positive
and negative cases, the disable comment, helper-function detection, and
the it.todo / it.skip allowance.
Supersedes the 2026-05-17 plan. The threshold ramp (its U2) was
obsoleted by upstream work landed between 2026-05-17 and 2026-05-19
(api/expo/analytics/mcp/overpass are now at 95%+ thresholds with
refined exclude lists and added middleware/image/storage tests).

The remaining novel work — ratchet (U6) + assertion-strength lint (U9)
+ docs migration (U1) — survives unchanged in intent; baselines must
be re-captured against upstream's current configs.
Move the testing guide out of the repo root per the "no random md in
root" convention (only CLAUDE.md + README.md belong there). The new
docs/testing.md is rewritten around current reality:

- Per-workspace Vitest thresholds (95%+ across packages/api, apps/expo,
  packages/mcp; 80% on packages/{analytics,overpass}; 100% on
  packages/units).
- The coverage ratchet (docs reference; the script itself lands in the
  next commit).
- The assertion-strength lint (docs the four rules + escape hatch).
- The two-layer enforcement model: Vitest thresholds enforce the floor,
  the ratchet enforces no-regression toward target.

Update CLAUDE.md with a Testing Policy summary linking to the new doc.
Update copilot-instructions.md, README.md, and the android-textinput
solutions doc that referenced the old path. Delete the dead
test:api-client:types script that points at a non-existent vitest
config.
The coverage ratchet is the in-repo gate that fails CI if any tracked
workspace's coverage drops below its baseline (modulo a small epsilon
to absorb v8 instrumentation noise). Pairs with the per-config Vitest
thresholds that upstream landed — thresholds enforce the floor, the
ratchet enforces no-regression toward the tier target and prevents
the "lower threshold to unblock, never re-raise" pattern.

New files:
- coverage-baselines.json  : committed source of truth at repo root.
                             Keyed by workspace path; records the
                             summaryPath, tier, four-metric floor, and
                             recordedAt. Initial baselines captured
                             from actual coverage runs on 2026-05-19:
                             packages/api     98.31 / 95.43 / 100    / 98.31
                             apps/expo        97.36 / 95.00 / 100    / 97.36
                             packages/mcp     98.87 / 98.38 / 100    / 98.87
                             packages/analytics 84.48 / 83.33 / 89.13 / 84.48
                             packages/overpass 100   / 95.65 / 100    / 100
                             packages/units   100   / 100   / 100    / 100
                             Epsilon 0.5 absorbs v8 jitter.
- scripts/lint/coverage-ratchet.ts          : the gate. Exit 1 on any
                                              regression, missing
                                              summary, or malformed
                                              summary. Exit 0 on
                                              equality / improvement.
- scripts/lint/coverage-baseline-update.ts  : CI-only on `main` — bumps
                                              baselines upward after a
                                              green run. Never lowers.
- scripts/lint/__tests__/coverage-ratchet.test.ts : 13 tests covering
                                              the compare logic,
                                              missing/invalid summaries,
                                              epsilon noise tolerance,
                                              multi-metric regressions,
                                              and loadBaseline parsing.

New package.json scripts:
- bun check:coverage         : run the ratchet against current summaries
- bun check:coverage:update  : bump baselines (used by the consolidated
                               coverage CI workflow once it lands)

NOT wired into scripts/check-all.ts in this commit — running the
ratchet requires per-workspace coverage data to exist on disk first.
The consolidated coverage CI workflow (follow-up plan) runs all
matrix coverage jobs before invoking the ratchet; locally, contributors
run `bun check:coverage` explicitly after a coverage suite.
bun format:package-json moves check:coverage / check:coverage:update
and lint:weak-assertions / test:scripts into alphabetical position
within the scripts block. No behaviour change.
Replaces unit-tests.yml with a single Coverage workflow that runs
coverage for every tracked workspace in a matrix, posts per-workspace
PR comments via davelosert/vitest-coverage-report-action, aggregates
the summaries, and runs `bun check:coverage` as a hard gate against
regression.

Workflow shape:

  coverage (matrix)  → run vitest --coverage per workspace
        │              upload coverage-summary.json artifact
        ▼              post PR comment with delta
  ratchet           → download all summaries
                       run `bun check:coverage` against
                       coverage-baselines.json
                       fails on any regression / missing summary
        │
        ▼ (main only)
  bump-baseline    → re-run check:coverage:update
                       auto-commit any baseline improvements

Plus a scripts-tests job that runs `bun test:scripts` (16 lint analyzer
tests + 13 ratchet tests) on every PR.

Tracked workspaces in the matrix: packages/api (unit), apps/expo,
packages/mcp, packages/analytics, packages/overpass, packages/units.
fail-fast: false so one workspace's regression doesn't mask another's.

Path filters are loose by design — any change under apps/** or
packages/** triggers the matrix. The narrow path filters in the
previous unit-tests.yml were how 12 workspaces ended up untracked
in CI. The matrix's own job-level conditions skip work cheaply when
nothing to run.

Permissions: contents:write for the main-only baseline auto-commit;
pull-requests:write for the coverage report comments. The PR-time
coverage step explicitly guards on \`github.event_name == 'pull_request'\`
so push events don't try to comment on nothing.

Deletes unit-tests.yml — fully subsumed by the new workflow.

Coordinate the branch-protection required-check rename from
"Unit Tests" → "Coverage / Coverage Ratchet" before merging.
The vitest-coverage-report-action step joins working-directory with
json-summary-path internally, so passing a repo-relative summary path
produced './apps/expo/apps/expo/coverage/unit/coverage-summary.json'
and failed every matrix job with ENOENT. The test commands themselves
ran successfully and the artifacts uploaded — only the PR comment step
errored.

Split the matrix into two path sets:
- summary_path / final_path : repo-relative (artifact upload + ratchet)
- summary_relative / final_relative : working-directory-relative (action)
Mark the 2026-05-19 audit-remediation plan as superseded and replace it
with a Workflows-based plan that natively provides the durable-step +
idempotency + retry + state semantics the prior plan reconstructed
manually on top of Queues + Postgres.

Audit findings about CSV correctness, validator hardening, observability,
retention, and the operational runbook carry into the new plan; the
queue-as-state-machine subplot is dropped. Net unit count drops from
15 to 9.

Also includes the underlying audit (docs/audits/2026-05-16-etl-audit.md)
that grounds both plans.
Validates the integration before committing to the Workflows migration:
R2 byte-range reads, csv-parse inside step.do, Drizzle Neon HTTP query,
durable step.sleep, and step result persistence. Workflow takes
{ objectKey, source } params; trigger via wrangler workflows trigger
spike-etl-workflow ... --env=dev and observe in the dashboard.

Adds:
- packages/api/src/workflows/spike-etl-workflow.ts (the workflow class)
- packages/api/src/index.ts exports SpikeEtlWorkflow
- packages/api/wrangler.jsonc declares the workflows[] binding

Per the plan (docs/plans/2026-05-20-001-fix-etl-pipeline-workflows-migration-plan.md
U1), this is throwaway. Delete the workflow file, the index.ts export,
and the wrangler binding after the GO/NO-GO decision lands U3's
production CatalogEtlWorkflow.
Pivoted from the in-app spike to a standalone worker because the dev
deploy of packrat-api requires Docker (App Container) and Docker is
not installed locally. Standalone worker has zero container surface
and only the bindings the spike actually exercises.

Spike rewritten to use the native R2 binding (env.PACKRAT_SCRAPY_BUCKET)
instead of the AWS S3 client — removes the R2_ACCESS_KEY_ID secret
dependency. Drizzle/Neon validation deferred to U3 (validates on the
production worker that already has NEON_DATABASE_URL).

Result on real prod data (cotopaxi_2026-05-14T16-54-05.csv, 698 KB):
  status=complete, duration=7s
  1-r2-head: size=698620 etag=4397... ok
  2-r2-range-read: 698134 bytes
  3-csv-parse: 100 rows
  4a/4b/4c-sleep: Δ=5043ms (5s sleep + ~40ms wake overhead)
  5-memoize-marker: persisted in instance history

GO. Workflows host R2 + csv-parse + step.sleep + step result persistence
cleanly inside step.do. Proceed to U2 (Drizzle migration 0048).

Adds:
- packages/api/wrangler.spike.jsonc (standalone worker config)
- packages/api/src/spike-entry.ts (thin /trigger endpoint)
- packages/api/src/workflows/spike-etl-workflow.ts rewritten

The standalone worker packrat-etl-spike.orange-frost-d665.workers.dev
should be deleted via `wrangler delete --config=wrangler.spike.jsonc`
after U3 lands the production CatalogEtlWorkflow.
Adds eight columns to etl_jobs for the Workflows-based ETL:
- workflow_instance_id (nullable text) — links the etl_jobs row to its
  Workflows instance for admin dashboards
- verified_at, verified_row_count (nullable) — post-ingestion R2-source
  row-count verification
- total_embedding_failures (integer DEFAULT 0 NOT NULL) — observable
  degradation signal when the embedding service fails inside a chunk
- superseded_by_job_id (FK to etl_jobs.id, ON DELETE SET NULL) +
  superseded_at — preserves the audit trail when an operator triggers
  repair-from-scratch
- source_etag, source_last_modified — captured at job start, compared by
  the repair endpoint to fail closed when the R2 source has been
  overwritten

Constraints + indexes:
- CHECK etl_jobs_no_self_supersede prevents a row from superseding itself
- Index etl_jobs_workflow_instance_id_idx (admin lookups)
- Index etl_jobs_superseded_by_idx (repair-chain lookups)
- UNIQUE catalog_item_etl_jobs_catalog_job_idx (catalog_item_id, etl_job_id)
  so retried chunk upserts can use ON CONFLICT DO NOTHING and not
  accumulate duplicate provenance rows

Also fixes the long-standing stale drizzle.config.ts schema path
(./src/db/schema.ts → ../db/src/schema.ts); the schema was extracted
to @packrat/db in merge b14f4db but the config pointer was not
updated, so db:generate failed before this commit.

The Workflows binding is the source of truth for chunk lifecycle and
retry semantics; the columns above are only DB-side denormalization
for admin queries.

Verification:
- drizzle-kit check: Everything's fine
- scripts/lint/check-drizzle-migrations.ts: Drizzle migration checks passed
- biome lint: clean

Schema smoke test at packages/api/test/db-schema-etl.test.ts asserts the
columns + indexes + CHECK constraint + UNIQUE index against the Docker
Postgres wsproxy. Run via bun test:api once docker-compose.test.yml is up.
Closes audit P1 #3, #4, #5 — the chunk boundary bugs where a CSV row
spanning a 20 MB byte-range chunk would be either dropped, invalidated,
or duplicated. The new helper snaps each chunk's byteEnd to the byte
immediately before a newline by reading a small (64 KiB default) tail
window and locating the last \n. Throws ChunkBoundaryError if the peek
window has no newline so a row wider than 64 KiB fails loudly.

Tail peek reads are issued in parallel via Promise.all so the producer
endpoint's CPU budget stays bounded on multi-GB files. Single-object-
parameter shape matches existing ETL functions.

5 unit tests cover: small-file single-chunk; multi-chunk newline
alignment; concatenation completeness; ChunkBoundaryError on no-newline;
row-boundary preservation across chunks. All pass via bun test:unit.

Used by the new CatalogEtlWorkflow and by the retry / repair-from-scratch
admin endpoints (next units).
Replaces packages/api/src/services/etl/processCatalogEtl.ts +
queue.ts as the catalog ingest engine. Producer cutover lands next
(separate commit) — for now both paths coexist; the queue handler in
src/index.ts still routes to processQueueBatch for ?engine=queue
callers during the bake window.

Workflow structure per source CSV:
  for each chunk in params.chunks:
    step.do('chunk-N', { retries: 3, backoff: exp, timeout: 5min },
            () => processChunk(...))
  step.do('aggregate')  -> UPDATE etl_jobs totals from memoized chunk results
  step.do('reconcile')  -> csv-parse the R2 source for logical row count
  step.do('reconcile-write') -> UPDATE verified_at + verified_row_count
  step.do('finalize')   -> UPDATE status='completed', completedAt

Audit closures inherited via the chunkCsvForR2 helper:
- P0 #1 (premature completion) — workflow instance state IS job state;
  the finalize step is the single transition to 'completed'
- P0 #2 (swallowed errors) — Workflows surface failed steps with full
  retry history; no DLQ table needed
- P1 #3/#4/#5 (chunk boundary bugs) — closed by the producer using
  newline-aligned ChunkSpec; consumer drops skipPartialRow
- P1 #1/#2 (retry endpoint, stuck-job sweep) — closed by workflow
  instance lifecycle (retry endpoints trigger new instances; stuck
  detection is via dashboard, not a wall-clock cron)
- P1 #3 specifically — header re-fetch uses a bounded 4K → 16K → 64K
  expand loop, throws EtlHeaderError if no newline anywhere in 64 KiB

Counter writes inside the chunk step (via existing
processValidItemsBatch / processLogsBatch) may double-count on a
chunk retry; the aggregate step at the end writes the authoritative
totals from memoized chunk results, overriding any retry drift.

wrangler.jsonc workflows binding switched from the throwaway
SPIKE_ETL_WORKFLOW to ETL_WORKFLOW (class CatalogEtlWorkflow). The
standalone spike worker (wrangler.spike.jsonc) is untouched and can
be torn down independently via wrangler delete --config=wrangler.spike.jsonc.

Test stub at src/__test-stubs__/cloudflare-workers.ts extended with
minimal WorkflowEntrypoint / WorkflowStep types so unit tests can
import workflow code without the real Cloudflare runtime.

Verification:
- All 17 unit-test files pass (304 tests) including the chunker tests
- biome check clean on all touched files
- Runtime verification (full deploy + trigger) blocked on Docker daemon
  for the production worker; can be exercised once Docker is up.
Modifies POST /catalog/etl to trigger a CatalogEtlWorkflow instance per
source CSV by default. The query parameter ?engine=queue keeps the
legacy queue path available so operators can roll back if the workflow
path misbehaves in production.

Workflow path:
- Calls chunkCsvForR2 per source object to produce newline-aligned
  ChunkSpec[] (closes audit P1 #3, #4, #5 on the retry surface as well
  as the initial ingest surface).
- Captures source_etag + source_last_modified from the first object's
  R2 head and persists them on the etl_jobs row. The admin
  repair-from-scratch endpoint (U5) compares the stored etag against
  the live R2 head to fail closed when a source has been overwritten.
- Generates a deterministic Workflows instance ID `${source}-${filename}`
  so duplicate triggers for the same file return the existing instance
  rather than producing parallel ingests.

Queue path:
- Unchanged from existing behavior — same 20 MB byte-range splits and
  queue.sendBatch.
- Kept until the workflow path bakes for at least a week in production
  (per migration plan rollout); removal in a follow-up PR.

Env type extended in env-validation.ts to expose ETL_WORKFLOW: Workflow
so the route handler can type-check the env.ETL_WORKFLOW.create call.

Unit tests still pass (17 files, 304 tests). The full end-to-end
verification (POST /catalog/etl?engine=workflow → workflow instance →
DB rows → reconcile → finalize) requires the production worker deploy,
which is gated on Docker for the AppContainer build — that path is
unchanged by this commit.
…gaps

Closes audit P3 #2. The previous CatalogItemValidator.isValidUrl
accepted anything new URL() parsed — including javascript:, mailto:,
data:, file:, and any private/loopback IP. Catalog URLs render in the
mobile app and the guides site, so a scraper bug or supply-chain
compromise could trick the UI into rendering a homograph phishing
link or a server-side fetch into hitting internal infrastructure.

Validator now rejects:
- Schemes other than http: and https:
- URLs > 2048 chars
- Loopback (localhost, 127.x.x.x, ::1), RFC-1918 (10/8, 172.16-31/12,
  192.168/16), link-local (169.254/16), IPv6 link-local (fe80:), and
  ULA (fc00:/fd00:) hostnames — string-level pattern match only, no
  DNS resolution (DNS resolution would itself be an SSRF vector)
- Hostnames containing non-ASCII characters that survive WhatWG URL
  encoding (IDN homograph defense in depth)

Length caps on prose fields:
- name 500, description 50,000, brand 200, category 200
- SKU 200 chars + /^[A-Za-z0-9_./-]+$/ charset

15 unit tests cover every reject path plus the boundary-allowed cases.
All 319 tests in the unit suite pass.
…v.dev

The spike worker (packrat-etl-spike) was deleted from the Cloudflare
account; the throwaway files referencing it no longer have a deployed
counterpart, so removing them keeps the worktree clean and the PR
diff focused on the production migration.

env.dev workflows binding added so the dev deploy of packrat-api
actually receives ETL_WORKFLOW. Top-level workflows[] does not
inherit into envs that explicitly redeclare other bindings
(wrangler 4.92 behavior).
Strict cast linter (check:casts:strict) rejects unchecked `as Error`
even when narrowing unknown from a catch. Replace with a clean
`instanceof Error ? err : new Error(String(err))` guard so the
parser.destroy call always receives a real Error.
…ures

Narrows U2's schema additions from 8 columns to 2 after PR-shaping
discussion. Most of the originally-scoped columns existed to support
audit findings whose consumers ship in later PRs:

  - verified_at / verified_row_count — reconcile UI / U10
  - superseded_by_job_id / superseded_at — repair endpoint / U5
  - source_etag / source_last_modified — fail-closed repair guard / U5

Adding them now would create dead schema with no reader, so each
follow-up unit adds its column when it lands. Net change: zero indexes,
zero CHECK constraints, zero UNIQUE constraints, no FK self-reference.
This is about as low-risk as a migration can be.

What stays (both load-bearing from day one):
- workflow_instance_id text — admin/debug link from etl_jobs to the
  CF Workflows instance; null on legacy queue-path rows, set on
  workflow-path rows
- total_embedding_failures integer DEFAULT 0 NOT NULL — observable
  embedding-fallback degradation counter (audit P2 #3)
- etl_jobs_workflow_instance_id_idx — supports the lookup pattern

Workflow simplifications follow:
- Dropped the reconcile + reconcile-write steps (no verified_* columns
  to write into); workflow now runs chunk-N × N → aggregate → finalize
- Dropped reconcileSourceRowCount helper (orphaned with the steps)
- Dropped source_etag / source_last_modified capture in the producer

Plan doc updated with a scope-adjustment note on U2 explaining the
narrowing; original 8-column rationale preserved for context.

Verification:
- drizzle-kit check ✓
- check-drizzle-migrations.ts ✓
- 18 unit-test files, 319 tests, all pass
- biome check clean on all touched files
Bounded-batch DELETE for invalid_item_logs older than 90 days, wired
to a daily 09:00 UTC CF Cron Trigger via a new scheduled handler arm
in src/index.ts.

Why batched: a naive single-statement DELETE on a table that has
been accumulating for months would acquire row-level locks on
millions of rows in one statement, hit Neon's statement timeout,
and roll back having pruned nothing. The loop deletes 10k-row
chunks via WHERE id IN (SELECT id ... LIMIT N) RETURNING id and
counts the returned rows. Stops on empty batch. Caps at 100
iterations (1M rows / run) so a first-run with months of backlog
can't monopolize the daily window — the cap is reported in the
RetentionResult so operators can see when more rows remain.

Defaults are sensible: 90-day window, 10k batch, 100-iter cap.
Overridable per-call via options.

Wrangler config gets a top-level + env.dev triggers.crons entry.
First cron in this worker, so the scheduled() handler in src/index.ts
is brand new — dispatches on controller.cron string and throws on
unknown crons so a misconfigured trigger fails loudly.

5 unit tests cover empty-first-batch, multi-batch accumulation, the
iteration cap, and the retentionDays fallback. All 324 tests in
the unit suite pass.

Real-DB integration coverage deferred to U9 (needs Docker Postgres).
Splits U6's "Sentry wiring + structured logger + error propagation"
deliverable. This PR ships the parts that don't need a new dependency:

  - Thin structured logger at packages/api/src/utils/logger.ts emits
    JSON lines with { level, event, ts, ...ctx }. To log an error,
    attach it under ctx.err — the emit boundary unpacks errorName /
    errorMessage / errorStack so the contract that error stacks never
    contain raw CSV row data is enforceable by code review at one
    site (the logger), not every call site
  - processLogsBatch rethrows on DB failure (audit P2 #2) — silently
    swallowing meant the only forensic record of validation failures
    could disappear without anyone noticing
  - processValidItemsBatch embedding-fallback path atomically
    increments etl_jobs.total_embedding_failures (audit P2 #3) so
    operators see degradation in the admin endpoint without trawling
    logs; warning log at the call site for the per-batch event
  - All console.log calls in the touched files replaced with
    logger.info / logger.warn / logger.error

Sentry wiring (@sentry/cloudflare with withSentry({ fetch, workflow,
queue, scheduled })) is deferred to a follow-up PR. Justification:
adding a new dep changes the lockfile, adds ~30 KB to the bundle, and
needs compat verification against the mobile app's @sentry/react-native.
Reviewers should see that as its own concern, not bundled with
correctness fixes. The logger's emit() boundary is the wire-up point
when the follow-up lands — each call site upgrades for free.

Verification: 19 unit-test files, 324 tests pass. biome clean.
…oints

Adds the two operator-facing surfaces that close the gap left by the
plan's U5 scope-down. Defers repair-from-scratch and ETag fail-closed
verification to follow-up PRs — workflow retry is enough to re-ingest
the 7 historical false-failures from 2026-05-14, and ETag verification
is defense in depth that operators can do manually for the one-time
recovery.

Migration 0049 adds two columns:
  - verified_at timestamp (nullable)
  - verified_row_count integer (nullable)

Both written exclusively by the new reconcile endpoint.

POST /admin/etl/:jobId/retry — rewritten to trigger a CatalogEtlWorkflow
instance instead of a queue message. Works for both legacy queue-era
failed jobs and workflow-era failed jobs (the new instance always uses
chunkCsvForR2 for newline-aligned chunks). Instance ID is suffixed with
the new jobId so duplicate retries don't collide. Response now includes
workflowInstanceId so the admin UI can deep-link to the dashboard.

POST /admin/etl/:jobId/reconcile — synchronously counts logical rows
in the R2 source via csv-parse (NOT raw \n counting; quoted multi-line
fields would skew that) and persists the result on verified_at +
verified_row_count. Returns expectedRowCount / actualRowCount / delta.
Large files may exceed the fetch budget — async-via-workflow is a
follow-up if needed.

EtlRetrySchema gets a workflowInstanceId field; EtlReconcileSchema is
new. Both in @packrat/schemas/admin.

Verification: drizzle-kit check + custom migration linter clean,
check-casts:strict clean, biome clean, 19 unit-test files / 324 tests
all pass.

Reset-stuck endpoint (POST /admin/etl/reset-stuck) is unchanged — its
wall-clock-based design is wrong (closed by the audit P1 #2) but the
fix is to delete it once Workflows is the only ingest path. Deferred
to the queue-path-removal PR.
New runbook at docs/runbooks/etl-pipeline.md covering:

- Architecture (producer → workflow → DB; cron arms)
- The ?engine=workflow|queue flag + coexistence-window context
- How to trigger an ETL run
- How to inspect workflow instances (wrangler workflows commands)
- How to retry a failed job
- How to reconcile a job's row count against R2
- DLQ / forensic record (the Workflows dashboard is the record;
  no DLQ table)
- The 7-job historical recovery procedure with SQL + curl
- Invalid-item-logs retention (daily 09:00 UTC sweep)
- Draining the legacy queue path when ready for deletion
- Admin dashboard field semantics under the Workflows architecture
  (workflow_instance_id, verified_*, total_embedding_failures, etc.)
- Accepted limitations (no soft-delete, success_rate quirk on failed
  jobs, sync reconcile bounded by fetch budget, no ETag fail-closed
  on retry yet, embedding cost on chunk retry)
- Historical recoveries appendix (stub for the 2026-05-14 recovery
  to be filled in when executed post-deploy)
- References (audit, plans, CF docs)

First runbook in docs/runbooks/ — establishes the convention.
Three real fixes plus one coverage exclusion:

1. packages/db/src/schema.ts: restore the `AnyPgColumn` type import.
   It was dropped when U2 was slimmed (the FK self-reference on
   `superseded_by_job_id` went away with it), but `post_comments`
   still uses AnyPgColumn for its own parent_comment_id self-reference.

2. invalidLogRetention.ts: drop the `.returning({ id: ... })` typed
   shape and use bare `.returning()`. The union of three Drizzle
   driver types (neon-http / neon-serverless / node-postgres) accepts
   only the no-arg overload at the intersection; the typed shape
   tripped TS2554. Row count is computed from `.length` anyway.

3. invalidLogRetention.test.ts: replace the `__mockDb` cross-module
   handle (TS2305: not an export) with vi.hoisted() state shared
   between the mock factory and the tests. Cleaner and type-safe.

4. vitest.unit.config.ts: add `src/workflows/catalog-etl-workflow.ts`
   to coverage exclude. The chunker sibling (src/workflows/shared/) is
   still covered (5 unit tests at 100%). The workflow class needs the
   real CF Workflows runtime for end-to-end execution; integration tests
   in /test pick it up when Docker Postgres is wired (deferred per the
   PR's "deferred to follow-up" list).

Plus: new unit tests for `logger.ts` (10 tests, 100% coverage) so the
new file doesn't drop the coverage threshold by itself.

Coverage now at 98.63% statements / 95.33% branches (was 76.76% / 95.16%).
20 unit-test files, 331 tests, all pass. bun check-types clean.

Does not address:
- `api-tests` install failure (Fail extracting tarball for
  @sentry/cli-linux-x64) — that's a transient registry / CI runner
  issue, not something this PR can fix. A retry should clear it.
…dit trail

Adds the columns + endpoints that were originally part of U5's full
scope but deferred during the U2 slim-down. Now landing together so
the post-merge operational story is complete.

Migration 0050 adds to etl_jobs:
- source_etag text — captured by the producer from r2.head().etag
- source_last_modified timestamp — same; redundant with etag but cheap
- superseded_by_job_id text — FK to etl_jobs.id (ON DELETE SET NULL),
  written by retry + repair endpoints to link the new job back to the
  original
- superseded_at timestamp — when the supersession was recorded
- CHECK constraint etl_jobs_no_self_supersede prevents a row from
  superseding itself
- Index etl_jobs_superseded_by_idx supports the dashboard's
  "show me the repair chain for cotopaxi" lookup

Producer (POST /catalog/etl):
- Captures sourceEtag + sourceLastModified from the first object's
  chunkCsvForR2 head; writes to etl_jobs on insert

Retry (POST /admin/etl/:jobId/retry):
- Refactored into a shared reingestJob() helper used by retry +
  repair-from-scratch
- Before triggering the new workflow, calls r2.head() and compares
  live etag against the stored sourceEtag — returns 409
  ETL_ETAG_MISMATCH unless ?force=true. Skips the check when the
  stored etag is NULL (legacy queue-era rows, including the 7
  false-failures from 2026-05-14)
- New job row carries supersededByJobId pointing at the original
  + supersededAt timestamp

New endpoint POST /admin/etl/:jobId/repair-from-scratch:
- Same shape as retry but accepts completed jobs too. Use case:
  operator suspects an originally-completed job under-counted (the
  audit's R8 "trace the repair chain" requirement)

Also adds @sentry/cloudflare ^10.37.0 to packages/api/package.json
(install lands in this commit but wiring is in the next one).

Verification: drizzle-kit check + custom linter clean,
check-casts:strict clean, biome clean, 20 unit-test files / 331
tests pass, tsc clean.
Closes the Sentry deferral from U6 part 1 now that the dependency is
installed. Wires Sentry into three surfaces:

1. Worker default export wrapped with withSentry(optionsCallback, handler)
   — initializes Sentry on first invocation; uncaught fetch / queue /
   scheduled exceptions land in Sentry with request + queue + cron context
   attached automatically by the SDK.

2. CatalogEtlWorkflow wrapped with instrumentWorkflowWithSentry — every
   step.do span + any uncaught throw inside a step lands in Sentry with
   workflow name + instance id + step name + attempt count attached.

3. logger.ts emit() boundary forwards to Sentry when isInitialized():
   - logger.info/warn → Sentry.addBreadcrumb (correlated with next captureException)
   - logger.error({ err }) → Sentry.captureException with ctx fields as tags/extras
   - logger.error without err → Sentry.captureMessage(level=error)
   Forwarding is best-effort and try/catch-wrapped — failures here never
   break the call site (JSON console line is the durable record).

Sentry options shared between handler + workflow:
   { dsn: env.SENTRY_DSN, environment: env.ENVIRONMENT,
     tracesSampleRate: 0.1, release: env.CF_VERSION_METADATA?.id }

wrangler.jsonc:
- Adds `nodejs_als` compatibility flag (required by @sentry/cloudflare's
  AsyncLocalStorage-based context propagation across awaits)
- Adds `upload_source_maps: true` so wrangler deploy uploads sourcemaps
  to Cloudflare — unminified stack traces in wrangler tail and the
  Workers dashboard. Sentry-side symbolication is a separate
  @sentry/cli sourcemaps upload step (documented in runbook; no CI
  deploy pipeline exists today to automate it).

Runbook updated with a new "Sentry observability" section documenting
wiring, tag conventions, and the optional Sentry-side sourcemap upload.

Verification:
- 20 unit-test files, 331 tests pass (logger tests still pass; Sentry
  isInitialized() returns false in tests so forwarding is silently
  skipped — JSON output to console unchanged)
- bun check-types clean
- biome check clean
- check-casts:strict clean
…typeof

Pre-push no-raw-typeof linter rejected the raw `typeof v === ...` chain
introduced in the Sentry forwarder. Replaced with isString/isNumber/
isBoolean from @packrat/guards, matching the rest of the codebase.

No behavior change; same path classification (primitives → Sentry tags,
objects/arrays → Sentry extras).
radash (which @packrat/guards re-exports) provides isString, isNumber,
isObject, etc. but not isBoolean. Use a direct === true || === false
check instead — passes the no-raw-typeof linter and reads cleaner than
inventing a wrapper.
Single-query per-source data-quality audit served from the API instead of
requiring scrapyd (or any other consumer) to talk to the DB directly. The
SQL stays where the schema lives; consumers authenticate with the existing
admin JWT and never need NEON_DATABASE_URL.

Flags surfaced per source (computed server-side from threshold constants
returned alongside the report):
- decimal_bug: count of prices < $10 with 3+ decimal places (the
  "1,299 → 1.299" parser bug from the existing scrapyd audit)
- low_median: median < $20 on a non-allowlisted source
- high_null:<field>: > 30% NULL on price / brand / description / weight /
  images / availability
- bad_weight: count of weights < 1g or > 100kg
- empty_name: count of empty / null names
- stale: source has no completed ETL in 30+ days

Query is a single CTE-based GROUP BY (DISTINCT ON for most-recent
ingest source per item, then aggregate). One round-trip for all
sources; ?source=<name> filters to one for ad-hoc debugging.

Response schema CatalogAuditSchema lives in @packrat/schemas/admin so
Eden Treaty consumers get end-to-end types.

Verification: 20 unit-test files / 331 tests pass, tsc clean, biome
clean, check-casts:strict clean.

Used by scripts/audit_db_catalog.py in PackRat-ScrapyD#129 (next commit
on that PR drops the direct-DB approach in favor of this endpoint).
Previous fix pointed drizzle.config.ts at ../db/src/schema.ts (relative
path crossing the workspace boundary). Cleaner: add an in-package
re-export at src/db/schema.ts that re-exports from @packrat/db/schema,
and point drizzle.config back to ./src/db/schema.ts.

drizzle-kit + any other drizzle-aware tooling now stays scoped to
packages/api and is insulated from workspace layout changes. Schema
source of truth still lives in packages/db/src/schema.ts.
…d migration

Previous: three migrations (0048_etl_workflow_columns, 0049_etl_verification_cols,
0050_etl_etag_and_supersession) all generated by drizzle-kit but renamed
post-generation, with hand-edited journal tags to match. That made the
migrations look hand-authored and the rename+edit pattern is brittle.

Now: one migration with whatever name drizzle-kit emits — the additive
column changes (workflow_instance_id, total_embedding_failures, verified_at,
verified_row_count, source_etag, source_last_modified, superseded_by_job_id,
superseded_at + FK + indexes + check constraint) collapse cleanly into a
single migration. Net diff impact: ~4,600 fewer lines (3 snapshots → 1).

Updates CLAUDE.md with explicit migration discipline so this doesn't recur:
- always generate via drizzle-kit
- keep the random auto-generated name (do not rename)
- never hand-edit journal / snapshots / SQL
- collapse additive changes into one migration when they ship together
- verify with drizzle-kit check before pushing

Schema content is identical; verified via drizzle-kit check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
andrew-bierman and others added 26 commits May 22, 2026 22:42
…search

fix(api): guard catalog vector search embeddings
Resolved conflicts:
- packages/api/package.json: take @sentry/cloudflare ^10.37.0 from main
- packages/api/src/auth/index.ts: keep our ttl !== undefined KV guard + comment
- packages/api/src/index.ts: merge captureApiException (ours) with
  CatalogEtlWorkflow + instrumentWorkflowWithSentry + scheduled handler (main)
- bun.lock: keep ours (development is authoritative for this release)
…ntain permissions'

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
The max-params migration (PR #2422) left stale positional calls that broke
on main: json-utils mapJsonRowToItem called parseWeight(str, unit) instead
of parseWeight({ weightStr, unitStr }) (3 sites → 4 test failures + 3 type
errors), and embeddingHelper/auth tests still called getEmbeddingText/
timingSafeEqual positionally. Convert to single-object form.

api unit tests: 392 passed (was 7 failing).
…nature

call() was migrated to call({ promise, ...options }) but the tests still
invoked it positionally (call(mockPromise[, opts])), so promise was
undefined → "Cannot read properties of undefined". Convert all 19 sites.

mcp tests: 63 passed (was 18 failing).
…CF contracts

Completes the max-params migration for the functions main was red on:

- Convert owned functions to single-object params (+ all call sites incl.
  tests): logger.info/warn/error, sweepInvalidItemLogs, fetchHeaderRow,
  ChunkBoundaryError constructor.
- Exempt genuine framework signatures in the checker, narrowly:
  - add `scheduled` to FRAMEWORK_METHOD_NAMES (Worker handler, like fetch/queue)
  - exempt `run` ONLY on classes extending WorkflowEntrypoint (not every `run`)
  - exclude /__test-stubs__/ (mirrors CF's positional API, like /mocks/)

no-owned-max-params: clean. api unit tests: 392 passed.
Same migration breakage as api/mcp: client.test called queryOverpass('ql')
positionally (now queryOverpass({ ql, config? })), so the Coverage(overpass)
job errored before emitting coverage. Convert 10 call sites; bridge the
vitest fetch mock via the standard `as unknown` cast.

overpass tests: 44 passed.
Restores analytics coverage above the ratchet baseline (lines 84.13→84.67%,
branches 82.73→83.57%) by covering cache-metadata.ts dbPath() and the
JSON-parse error branch in loadMetadata().
sentry.ts (captureApiException, apiAddBreadcrumb, setApiUser, clearApiUser)
was 0% covered — the bulk of api's drop below the 98.31% ratchet baseline.
Add unit tests mocking @sentry/cloudflare. api lines 96.51 → 98.77%.
Restores expo branch coverage above the ratchet baseline (93.65 → 95.23%)
by covering getRelativeTime's Date-object input, invalid-Date-object, and
null/undefined-without-translator branches.
The cache-metadata tests alone left analytics just under the ratchet
baseline in CI. Cover the SpecParser DB-query methods (getProductSpecs,
filterProducts with-filters and defaults) via a mocked DuckDBConnection,
lifting analytics lines 83.87 → 93.68% with comfortable margin.
runAndReadAll.mock.calls[0][0] tripped noUncheckedIndexedAccess (Object
possibly undefined) in the Checks tsc step. Use String(...?.[0]).
…string

Addresses Copilot review on #2530: includes('WorkflowEntrypoint') would
also exempt run() on NotWorkflowEntrypoint / WorkflowEntrypointStub. Match
the heritage expression exactly (allowing a namespace qualifier) so the
enforcement rule can't be bypassed.
…ation

🐛 fix(ci): complete max-params migration — green CI on main (api, mcp, checks)
@cloudflare-workers-and-pages

Copy link
Copy Markdown
Contributor

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
packrat-api 2964e6b Jun 01 2026, 03:12 AM

@coderabbitai

coderabbitai Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Introduces Cloudflare Workflows-based catalog ETL with chunking, JSONL/CSV parsing, admin retry/repair/reconcile/audit routes, structured logger and Sentry wiring, invalid log retention sweep with cron, schema/migration updates, CI coverage ratchet and baseline updater, test/doc updates, and UI/type changes for nullable item weights.

Changes

Workflow ETL, retention, and coverage gates

Layer / File(s) Summary
Workflow entrypoints and admin routes
packages/api/src/index.ts, packages/api/src/routes/catalog/index.ts, packages/api/src/routes/admin/analytics/catalog.ts
Exports Sentry-instrumented CatalogEtlWorkflow, adds cron-based invalid-log sweep, and implements ETL engine switch (workflow/queue) with admin reingest, reconcile, and audit endpoints.

Sequence Diagram(s)

sequenceDiagram
  participant Admin
  participant API
  participant Workflow
  participant R2
  participant DB
  Admin->>API: POST /admin/catalog/etl?engine=workflow
  API->>R2: head/get → chunk plan
  API->>Workflow: create(instanceId, params)
  loop per chunk
    Workflow->>R2: ranged get
    Workflow->>DB: upsert valid items, log invalid
  end
  Workflow->>DB: write totals, mark completed
  Admin->>API: POST /admin/catalog/etl/:id/reconcile
  API->>R2: stream parse CSV
  API->>DB: update verified counts
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

  • PackRat-AI/PackRat#2462 — Prior Cloudflare Workflows-based ETL migration touching the same workflow, chunker, and admin paths.
  • PackRat-AI/PackRat#2476 — Adds ETL job fields used here: failure/verification metrics in admin schemas and routes.
  • PackRat-AI/PackRat#2481 — Adjusts catalog schema/tests for nullable weight, matching the nullable weight changes in this PR.

Suggested reviewers

  • Isthisanmol
  • andrew-bierman
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch main
⚔️ Resolve merge conflicts
  • Resolve merge conflict in branch main

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 23

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/coverage.yml:
- Line 109: Several checkout steps in the CI workflow (the actions/checkout@v6
usages in the coverage, ratchet, and scripts-tests jobs) are leaving credentials
persisted; update each of those checkout steps to include persist-credentials:
false so they perform read-only checkouts and reduce supply-chain risk, while
leaving the bump-baseline job's checkout unchanged because it needs push
credentials.
- Around line 109-280: Replace all mutable action tags in the workflow with the
provided commit SHAs: change every occurrence of actions/checkout@v6 →
actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd, oven-sh/setup-bun@v2
→ oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6,
davelosert/vitest-coverage-report-action@v2 →
davelosert/vitest-coverage-report-action@02f3c2e641286b7fa308cd3e430783103ce6103b,
actions/upload-artifact@v4 →
actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02,
actions/download-artifact@v4 →
actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093, and
stefanzweifel/git-auto-commit-action@v6 →
stefanzweifel/git-auto-commit-action@778341af668090896ca464160c2def5d1d1a3eb0;
ensure you update every use of the symbols (each uses: line) in the file so no
mutable tags remain.
- Around line 36-38: Move the workflow-level permissions into each job: remove
the top-level "permissions: contents: write" and "pull-requests: write" and
instead add "permissions: contents: write" only to the bump-baseline job (and
guard that job to run on main), add "permissions: pull-requests: write" only to
the coverage job, and leave scripts-tests and ratchet jobs with no write
permissions (or with minimal default read-only permissions). Update the
bump-baseline and coverage job definitions to include these job-scoped
permissions so only those jobs have the elevated rights.

In `@apps/web/components/screens/packs-screen.tsx`:
- Line 635: The current payload line "weight: item.weight ?? 0" silently
converts null weights to 0; change behavior so items with item.weight === null
are not sent to the backend and the UI prevents adding them. Locate the place
building the pack-item payload (the line containing "weight: item.weight ?? 0"
inside the add/submit handler such as the function that adds a catalog item to a
pack) and instead short-circuit when item.weight === null (do not call the API)
and surface a UI state: disable the corresponding add button and show a tooltip
or label like "Weight required" (update the item's button/row rendering to check
item.weight === null and set disabled + explanatory text); if you prefer
prompting, replace the short-circuit with a modal/inline prompt that collects a
valid weight before constructing the payload.

In `@docs/audits/2026-05-16-etl-audit.md`:
- Around line 11-32: The fenced architecture diagram in
docs/audits/2026-05-16-etl-audit.md is missing a language identifier which fails
linting and renders less predictably; update the opening code fence for the ETL
diagram (the triple-backtick block that begins with "POST /api/catalog/etl ...")
to include a language tag such as text or mermaid (e.g., change ``` to ```text)
so the diagram is properly highlighted and the linter is satisfied.

In `@docs/plans/2026-05-19-001-fix-etl-pipeline-audit-remediation-plan.md`:
- Around line 1-10: This plan file (title "fix: ETL pipeline audit remediation",
field supersededBy:
docs/plans/2026-05-20-001-fix-etl-pipeline-workflows-migration-plan.md) is
marked superseded and should be relocated out of the active plans directory into
your archive/superseded area; move the file, keep the frontmatter (status:
superseded, supersededBy, supersededReason, date) intact, and update any plan
index/TOC or links that list active plans so this file is no longer shown as
current.

In `@packages/analytics/test/core/spec-parser.test.ts`:
- Around line 202-235: Update the tests to assert that user-controlled strings
are properly SQL-escaped and that sortBy is validated: in the getProductSpecs
test (using parser.getProductSpecs with query "tent's") assert
runAndReadAll.mock.calls[0][0] includes the escaped form "tent''s"
(SQLFragments.escapeSql behavior), and in the filterProducts test assert the
generated SQL contains "women''s" for gender and that ORDER BY uses an
allowed/whitelisted identifier (or assert the exact safe token produced) instead
of arbitrary interpolation; if filterProducts currently interpolates sortBy
directly, add or assert a runtime whitelist/validation for sortBy values before
interpolation to prevent SQL injection. Ensure you reference
parser.getProductSpecs, parser.filterProducts, SQLFragments.escapeSql and
runAndReadAll in the changes.

In `@packages/api/src/routes/admin/analytics/catalog.ts`:
- Around line 717-723: The catch block that handles ETL reconciliation failures
currently only console.errors and returns status(500); update it to use the
project's Sentry helpers: call the Sentry helper (e.g., captureException or the
repo-specific sentryCapture) with the caught error, add contextual data (job id,
CSV/R2 keys, and request metadata) via withScope or setExtras, and add a
breadcrumb indicating the failure source before returning status(500) with the
same payload; also ensure the Sentry helper is imported at top of the file and
avoid exposing sensitive data in extras.
- Around line 906-909: The catch block that currently does
console.error('Catalog audit error:', error) and returns status(500, { error:
'Failed to generate catalog audit', code: 'AUDIT_ERROR' }) needs Sentry
instrumentation: replace the console.error with using the Sentry helpers
(captureException and adding context) so the error is reported to Sentry with
relevant context (e.g., operation = "catalog_audit" and the AUDIT_ERROR code and
any request/user identifiers), then continue to return the same status(500, ...)
response; update the catch in the catalog audit handler to call
Sentry.captureException(error) (and optionally Sentry.setContext or
Sentry.setTag) before returning to ensure the error is recorded.
- Around line 135-147: The insert into etlJobs for the new job incorrectly sets
supersededByJobId to originalJobId (reversing the relationship); instead, remove
supersededByJobId/supersededAt from the new job insert (keep id=newJobId,
status='running', workflowInstanceId, etc.), and after the workflow is
successfully created/update persisted, update the existing original job row
(identified by originalJobId) to set supersededByJobId = newJobId and
supersededAt = new Date(); use the etlJobs table and the newJobId/originalJobId
identifiers to locate the records and perform the update.
- Around line 167-174: The catch block that logs ETL failures (the catch
handling "ETL ${mode} error") must be instrumented with Sentry: import the
Sentry helper functions (e.g., captureException, captureMessage, addBreadcrumb)
at the top of the file, then inside this catch call addBreadcrumb with context
about mode and any relevant variables before external ops, call
captureException(error) (and optionally captureMessage with a human-readable
string like `ETL ${mode} error`) so the error is sent to Sentry, and keep the
existing console.error and returned error payload; update the block that
currently returns the 500 payload in admin/analytics/catalog.ts accordingly.

In `@packages/api/src/routes/catalog/index.ts`:
- Around line 368-376: The catch block after calling env.ETL_WORKFLOW.create
should call captureApiException(err, { extra: { jobId, instanceId, params } })
before marking the job failed and rethrowing; update the catch in the route
handler to invoke captureApiException with the caught error and relevant context
(jobId, instanceId, params) and then proceed to await db.update(etlJobs)... and
throw err so Sentry captures the failure for env.ETL_WORKFLOW.create.

In `@packages/api/src/services/etl/processCatalogEtl.ts`:
- Around line 85-184: The JSONL branch in useJsonl (looping over
streamToText(r2Object.body), JSON.parse, mapJsonRowToItem,
validator.validateItem, and batch flushes via
processValidItemsBatch/processLogsBatch) lacks Sentry instrumentation; add
apiAddBreadcrumb calls at the start of the R2 stream processing, before each
batch flush, and before key async operations, and call captureApiException in
every catch block (the JSON.parse catch and the final lastLine parse catch) to
record errors; ensure breadcrumbs include context (jobId, rowIndex, batch sizes)
and that captureApiException is invoked with the caught error and contextual
info whenever pushing to invalidItemsBatch or when
processValidItemsBatch/processLogsBatch throws.
- Around line 88-106: The variable firstLineSkipped is initialized true which
makes the subsequent "discard partial row" branch never run; either remove the
dead-code variable and the if-block that skips the first line inside the
for-await loop (the firstLineSkipped flag and the branch that does "continue; //
discard partial row") if JSONL chunks never require skipping, or change the
initialization to false and adopt the same chunk-boundary behavior as the CSV
path (see skipPartialRow logic used later) so non-first chunks correctly drop a
partial row; update streamToText loop logic and tests accordingly to reflect the
chosen behavior.

In `@packages/api/src/services/etl/processValidItemsBatch.ts`:
- Around line 83-89: The DB update that increments
etlJobs.totalEmbeddingFailures should be moved out of the finally block and into
the catch block so failures are only counted when an exception occurs: in the
catch handler of processValidItemsBatch (where you currently handle embedding
errors), call createDbClient(env) and run the update on etlJobs (using
eq(etlJobs.id, jobId)) to set totalEmbeddingFailures =
COALESCE(totalEmbeddingFailures, 0) + items.length, then remove that update from
the finally block so successful runs do not increment the counter.

In `@packages/api/src/utils/__tests__/auth.test.ts`:
- Around line 67-71: Add two unit tests for timingSafeEqual: one that asserts it
returns true when both inputs are identical (e.g., pass { a: 'test-api-key', b:
'test-api-key' } to timingSafeEqual and expect true), and one that asserts it
returns false for same-length differing strings (e.g., two strings of equal
length but different characters, pass { a: 'test-api-xxxx', b: 'test-api-yyyy' }
and expect false); place these alongside the existing test in
packages/api/src/utils/__tests__/auth.test.ts and use the timingSafeEqual
function name to locate where to add the new it blocks.

In `@packages/api/src/utils/embeddingHelper.ts`:
- Around line 24-29: The variants mapping currently filters falsy values only in
the scalar branch, causing empty entries like "Color: Black, , " to appear;
update the normalization in the mapping over item.variants so you first coerce
v.values into an array (e.g., ensure an array regardless of scalar or array
input) and then apply .filter(Boolean) to remove empty/falsy entries before
joining; change the logic around v.values and v.attribute in the map used for
embeddings (and apply the same fix to the analogous block at the other
occurrence) so both branches normalize then filter consistently.

In `@packages/api/src/utils/json-utils.ts`:
- Around line 123-133: Add a clarifying comment above the numeric-weight branch
to explain why we require rawWeight > 0 (so zero intentionally falls through and
item.weight remains unset) and note that the string branch mirrors the same
positive-only validation; reference the variables/constructs involved
(rawWeight, parseWeight, item.weight, WeightUnitSchema) so maintainers
understand the intent of the isNumber(rawWeight) && rawWeight > 0 check and that
zero is an explicit "ignore" case.
- Around line 136-157: The code unsafely casts raw arrays (rawVariants,
rawLinks, rawReviews, rawQas) to NewCatalogItem types without validating inner
shapes; update the parsing in json-utils.ts to perform runtime
validation/filtering of each array before assigning to item. For each symbol
(rawVariants -> item.variants, rawLinks -> item.links, rawReviews ->
item.reviews, rawQas -> item.qas) iterate the array, ensure each entry has the
required fields and correct types (e.g., variant objects have expected keys like
color/size, link objects have url as string, etc.), skip or reject invalid
entries (or return a validation error) and only then assign the
filtered/validated array to the corresponding item property; alternatively, call
a reusable validator function (e.g.,
validateVariant/validateLink/validateReview/validateQa) to centralize checks.

In `@packages/api/src/workflows/catalog-etl-workflow.ts`:
- Around line 126-140: The firstLineSkipped dead-code should be removed: delete
the let firstLineSkipped = true declaration and remove the conditional block
that checks if (!firstLineSkipped) { firstLineSkipped = true; continue; } inside
the loop in the catalog ETL workflow (in
packages/api/src/workflows/catalog-etl-workflow.ts, locate the for await (const
text of streamToText(obj.body)) loop and the inner for (const line of lines)
block); also remove any other references to firstLineSkipped later in the file
(e.g., the reference mentioned around the later lines) so the chunk-processing
logic simply trims/continues on empty lines without the unreachable skip logic.

In `@packages/api/test/catalog.test.ts`:
- Around line 57-65: The test currently queries
apiWithAuth(`/catalog?sort=${sort}`) and searches data.items for seededItem,
which is brittle if pagination moves the item off page 1; update the request to
be deterministic by adding an explicit limit parameter (e.g., &limit=100) or add
a filter query that targets a unique property of seededItem (e.g.,
&name=<uniqueName>), then call expectJsonResponse(res, ['items']) and assert
against the returned items for seededItem.id; keep references to seededItem,
apiWithAuth, and expectJsonResponse so reviewers can locate the change.

In `@packages/api/test/db-schema-etl.test.ts`:
- Around line 41-63: The test only asserts two of the eight columns added by
Migration 0047; update the test in db-schema-etl.test.ts to either document that
it is intentionally a smoke test or add assertions for the remaining columns
(`verified_at`, `verified_row_count`, `source_etag`, `source_last_modified`,
`superseded_by_job_id`, `superseded_at`) using the existing helpers
`describeColumns('etl_jobs')` to check data_type/is_nullable/column_default as
appropriate, and add an assertion that `describeIndexes('etl_jobs')` includes
the `superseded_by_idx` index (in addition to the existing
`etl_jobs_workflow_instance_id_idx` check). Ensure each new assertion mirrors
the style of the existing tests for `workflow_instance_id` and
`total_embedding_failures`.

In `@scripts/lint/coverage-ratchet.ts`:
- Around line 172-207: renderReport currently hard-codes a 0.05 threshold when
showing metric improvements which can differ from the actual epsilon used by
compareWorkspace; update renderReport(signature) to accept an epsilon parameter
and use that instead of 0.05 when deciding which improvements to print, then
update the caller that invokes renderReport (the call that builds the final
report after compareWorkspace runs) to pass through the same epsilon value used
during comparison so displayed improvement thresholds match actual logic.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 38756c05-1ec0-4509-a7ca-8dc6d8db8c86

📥 Commits

Reviewing files that changed from the base of the PR and between cb9f64d and 2964e6b.

📒 Files selected for processing (98)
  • .github/workflows/coverage.yml
  • .github/workflows/eas-update.yml
  • .github/workflows/unit-tests.yml
  • CLAUDE.md
  • README.md
  • TESTING.md
  • apps/admin/package.json
  • apps/expo/app.config.ts
  • apps/expo/features/catalog/types.ts
  • apps/expo/lib/utils/__tests__/getRelativeTime.test.ts
  • apps/expo/package.json
  • apps/guides/package.json
  • apps/landing/package.json
  • apps/trails/package.json
  • apps/web/components/screens/catalog-screen.tsx
  • apps/web/components/screens/packs-screen.tsx
  • apps/web/lib/types.ts
  • copilot-instructions.md
  • coverage-baselines.json
  • docs/audits/2026-05-16-etl-audit.md
  • docs/plans/2026-05-19-001-chore-coverage-ratchet-and-quality-gates-plan.md
  • docs/plans/2026-05-19-001-fix-etl-pipeline-audit-remediation-plan.md
  • docs/plans/2026-05-20-001-fix-etl-pipeline-workflows-migration-plan.md
  • docs/runbooks/etl-pipeline.md
  • docs/solutions/ui-bugs/android-textinput-keyboard-focus-loss.md
  • docs/testing.md
  • package.json
  • packages/analytics/package.json
  • packages/analytics/test/core/cache-metadata.test.ts
  • packages/analytics/test/core/spec-parser.test.ts
  • packages/api-client/package.json
  • packages/api/container_src/package.json
  • packages/api/drizzle.config.ts
  • packages/api/drizzle/0047_clear_monster_badoon.sql
  • packages/api/drizzle/meta/0047_snapshot.json
  • packages/api/drizzle/meta/_journal.json
  • packages/api/package.json
  • packages/api/src/__test-stubs__/cloudflare-workers.ts
  • packages/api/src/db/schema.ts
  • packages/api/src/index.ts
  • packages/api/src/routes/admin/analytics/catalog.ts
  • packages/api/src/routes/catalog/__tests__/instanceId.test.ts
  • packages/api/src/routes/catalog/index.ts
  • packages/api/src/services/catalogService.ts
  • packages/api/src/services/etl/CatalogItemValidator.ts
  • packages/api/src/services/etl/__tests__/CatalogItemValidator.test.ts
  • packages/api/src/services/etl/processCatalogEtl.ts
  • packages/api/src/services/etl/processLogsBatch.ts
  • packages/api/src/services/etl/processValidItemsBatch.ts
  • packages/api/src/services/imageDetectionService.ts
  • packages/api/src/services/retention/__tests__/invalidLogRetention.test.ts
  • packages/api/src/services/retention/invalidLogRetention.ts
  • packages/api/src/utils/__tests__/auth.test.ts
  • packages/api/src/utils/__tests__/csv-utils.test.ts
  • packages/api/src/utils/__tests__/embeddingHelper.test.ts
  • packages/api/src/utils/__tests__/json-utils.test.ts
  • packages/api/src/utils/__tests__/logger.test.ts
  • packages/api/src/utils/__tests__/sentry.test.ts
  • packages/api/src/utils/embeddingHelper.ts
  • packages/api/src/utils/env-validation.ts
  • packages/api/src/utils/json-utils.ts
  • packages/api/src/utils/logger.ts
  • packages/api/src/workflows/catalog-etl-workflow.ts
  • packages/api/src/workflows/shared/__tests__/chunk-csv-for-r2.test.ts
  • packages/api/src/workflows/shared/chunkCsvForR2.ts
  • packages/api/test/catalog.test.ts
  • packages/api/test/db-schema-etl.test.ts
  • packages/api/vitest.unit.config.ts
  • packages/api/wrangler.jsonc
  • packages/app/package.json
  • packages/checks/package.json
  • packages/cli/package.json
  • packages/config/package.json
  • packages/constants/package.json
  • packages/db/package.json
  • packages/db/src/schema.ts
  • packages/env/package.json
  • packages/guards/package.json
  • packages/mcp/package.json
  • packages/mcp/src/__tests__/client.test.ts
  • packages/osm-db/package.json
  • packages/osm-import/package.json
  • packages/overpass/package.json
  • packages/overpass/src/client.test.ts
  • packages/schemas/package.json
  • packages/schemas/src/admin.ts
  • packages/schemas/src/catalog.ts
  • packages/types/package.json
  • packages/ui/package.json
  • packages/units/package.json
  • packages/web-ui/package.json
  • scripts/lint/__tests__/coverage-ratchet.test.ts
  • scripts/lint/__tests__/no-weak-assertions.test.ts
  • scripts/lint/coverage-baseline-update.ts
  • scripts/lint/coverage-ratchet.ts
  • scripts/lint/no-owned-max-params.ts
  • scripts/lint/no-weak-assertions.ts
  • scripts/vitest.config.ts
💤 Files with no reviewable changes (2)
  • TESTING.md
  • .github/workflows/unit-tests.yml

Comment on lines +36 to +38
permissions:
contents: write # for baseline auto-commit on main
pull-requests: write # for vitest-coverage-report-action comments

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Scope permissions to jobs, not workflow-level.

Workflow-level contents: write and pull-requests: write grant excessive permissions to all jobs. Only bump-baseline needs contents: write (and only on main), and only coverage needs pull-requests: write (for PR comments). The scripts-tests and ratchet jobs don't need write permissions at all.

🔒 Recommended fix: move permissions to job level
-permissions:
-  contents: write       # for baseline auto-commit on main
-  pull-requests: write  # for vitest-coverage-report-action comments
+permissions:
+  contents: read

 jobs:
   coverage:
     name: Coverage (${{ matrix.name }})
     runs-on: ubuntu-latest
+    permissions:
+      contents: read
+      pull-requests: write  # for vitest-coverage-report-action
     strategy:
   ratchet:
     name: Coverage Ratchet
     runs-on: ubuntu-latest
+    permissions:
+      contents: read
     needs: coverage
   bump-baseline:
     name: Bump Coverage Baselines
     runs-on: ubuntu-latest
+    permissions:
+      contents: write  # for git-auto-commit-action
     needs: ratchet
   scripts-tests:
     name: Scripts Tests
     runs-on: ubuntu-latest
+    permissions:
+      contents: read
     steps:

As per coding guidelines: minimize permissions scope to reduce supply-chain attack surface.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
permissions:
contents: write # for baseline auto-commit on main
pull-requests: write # for vitest-coverage-report-action comments
permissions:
contents: read
jobs:
coverage:
name: Coverage (${{ matrix.name }})
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write # for vitest-coverage-report-action
strategy:
🧰 Tools
🪛 zizmor (1.25.2)

[error] 37-37: overly broad permissions (excessive-permissions): contents: write is overly broad at the workflow level

(excessive-permissions)


[error] 38-38: overly broad permissions (excessive-permissions): pull-requests: write is overly broad at the workflow level

(excessive-permissions)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/coverage.yml around lines 36 - 38, Move the workflow-level
permissions into each job: remove the top-level "permissions: contents: write"
and "pull-requests: write" and instead add "permissions: contents: write" only
to the bump-baseline job (and guard that job to run on main), add "permissions:
pull-requests: write" only to the coverage job, and leave scripts-tests and
ratchet jobs with no write permissions (or with minimal default read-only
permissions). Update the bump-baseline and coverage job definitions to include
these job-scoped permissions so only those jobs have the elevated rights.

vite_config_path: ./vitest.config.ts
working_directory: ./packages/units
steps:
- uses: actions/checkout@v6

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add persist-credentials: false to read-only checkouts.

Three checkout actions in jobs that don't push changes should disable credential persistence to minimize supply-chain risk.

🔐 Recommended fix
       - uses: actions/checkout@v6
+        with:
+          persist-credentials: false

Apply to the checkouts in coverage, ratchet, and scripts-tests jobs. The bump-baseline job's checkout (line 212) correctly preserves credentials since it needs to push.

Also applies to: 150-150, 271-271

🧰 Tools
🪛 zizmor (1.25.2)

[warning] 109-109: credential persistence through GitHub Actions artifacts (artipacked): does not set persist-credentials: false

(artipacked)


[error] 109-109: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)

(unpinned-uses)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/coverage.yml at line 109, Several checkout steps in the CI
workflow (the actions/checkout@v6 usages in the coverage, ratchet, and
scripts-tests jobs) are leaving credentials persisted; update each of those
checkout steps to include persist-credentials: false so they perform read-only
checkouts and reduce supply-chain risk, while leaving the bump-baseline job's
checkout unchanged because it needs push credentials.

Comment on lines +109 to +280
- uses: actions/checkout@v6

- uses: oven-sh/setup-bun@v2
with:
bun-version: latest

- name: Install dependencies
env:
PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN: ${{ secrets.PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN }}
run: bun install --frozen-lockfile

- name: Run coverage for ${{ matrix.name }}
run: ${{ matrix.test_command }}

- name: Report coverage on PR
if: always() && github.event_name == 'pull_request'
uses: davelosert/vitest-coverage-report-action@v2
with:
name: ${{ matrix.name }}
json-summary-path: ${{ matrix.summary_relative }}
json-final-path: ${{ matrix.final_relative }}
vite-config-path: ${{ matrix.vite_config_path }}
working-directory: ${{ matrix.working_directory }}

- name: Upload coverage summary artifact
if: always()
uses: actions/upload-artifact@v4
with:
name: coverage-summary-${{ matrix.artifact_slug }}
path: ${{ matrix.summary_path }}
if-no-files-found: error
retention-days: 7

# Aggregate every workspace's coverage-summary.json and run the ratchet.
# Fails the workflow if any workspace dropped below its baseline.
ratchet:
name: Coverage Ratchet
runs-on: ubuntu-latest
needs: coverage
if: always()
steps:
- uses: actions/checkout@v6

- uses: oven-sh/setup-bun@v2
with:
bun-version: latest

- name: Install dependencies
env:
PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN: ${{ secrets.PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN }}
run: bun install --frozen-lockfile

- name: Download all coverage summaries
uses: actions/download-artifact@v4
with:
pattern: coverage-summary-*
path: artifacts

- name: Restore summaries to their workspace paths
run: |
set -euo pipefail
# Each artifact arrives as artifacts/coverage-summary-<slug>/<path-without-leading-dir>
# actions/download-artifact@v4 unzips a single-file artifact into a directory
# named after the artifact, preserving the source file's basename.
# Copy each back to its expected location so coverage-baselines.json's
# summaryPath entries resolve.
declare -A targets=(
[packages-api]=packages/api/coverage/unit/coverage-summary.json
[apps-expo]=apps/expo/coverage/unit/coverage-summary.json
[packages-mcp]=packages/mcp/coverage/coverage-summary.json
[packages-analytics]=packages/analytics/coverage/coverage-summary.json
[packages-overpass]=packages/overpass/coverage/coverage-summary.json
[packages-units]=packages/units/coverage/coverage-summary.json
)
for slug in "${!targets[@]}"; do
target="${targets[$slug]}"
src_dir="artifacts/coverage-summary-${slug}"
if [ ! -d "$src_dir" ]; then
echo "::warning::missing artifact for $slug — coverage job may have failed"
continue
fi
mkdir -p "$(dirname "$target")"
# Find the single JSON file inside (path may be flat or preserved).
src_file=$(find "$src_dir" -name 'coverage-summary.json' | head -n1)
if [ -z "$src_file" ]; then
echo "::warning::no coverage-summary.json inside artifacts/coverage-summary-${slug}"
continue
fi
cp "$src_file" "$target"
echo "restored $slug → $target"
done

- name: Run coverage ratchet
run: bun check:coverage

# On a green push to main, auto-bump coverage-baselines.json upward.
# Never runs on PRs — PRs cannot edit the baseline file silently.
bump-baseline:
name: Bump Coverage Baselines
runs-on: ubuntu-latest
needs: ratchet
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
steps:
- uses: actions/checkout@v6
with:
# Need full token to push the auto-commit back to main.
token: ${{ secrets.GITHUB_TOKEN }}

- uses: oven-sh/setup-bun@v2
with:
bun-version: latest

- name: Install dependencies
env:
PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN: ${{ secrets.PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN }}
run: bun install --frozen-lockfile

- name: Download all coverage summaries
uses: actions/download-artifact@v4
with:
pattern: coverage-summary-*
path: artifacts

- name: Restore summaries to their workspace paths
run: |
set -euo pipefail
declare -A targets=(
[packages-api]=packages/api/coverage/unit/coverage-summary.json
[apps-expo]=apps/expo/coverage/unit/coverage-summary.json
[packages-mcp]=packages/mcp/coverage/coverage-summary.json
[packages-analytics]=packages/analytics/coverage/coverage-summary.json
[packages-overpass]=packages/overpass/coverage/coverage-summary.json
[packages-units]=packages/units/coverage/coverage-summary.json
)
for slug in "${!targets[@]}"; do
target="${targets[$slug]}"
src_dir="artifacts/coverage-summary-${slug}"
if [ ! -d "$src_dir" ]; then
continue
fi
mkdir -p "$(dirname "$target")"
src_file=$(find "$src_dir" -name 'coverage-summary.json' | head -n1)
if [ -n "$src_file" ]; then
cp "$src_file" "$target"
fi
done

- name: Compute baseline updates
run: bun check:coverage:update

- name: Commit baseline updates
uses: stefanzweifel/git-auto-commit-action@v6
with:
commit_message: "chore(coverage): bump baselines after green main"
file_pattern: coverage-baselines.json

# The scripts test suite — verifies the ratchet and assertion-lint analyzers
# themselves on every PR that touches them or their tests.
scripts-tests:
name: Scripts Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
env:
PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN: ${{ secrets.PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN }}
run: bun install --frozen-lockfile
- name: Run scripts test suite
run: bun test:scripts

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Fetch latest commit SHAs for the action versions currently in use

echo "actions/checkout@v6:"
gh api repos/actions/checkout/commits/refs/tags/v6 --jq '.sha'

echo "oven-sh/setup-bun@v2:"
gh api repos/oven-sh/setup-bun/commits/refs/tags/v2 --jq '.sha'

echo "davelosert/vitest-coverage-report-action@v2:"
gh api repos/davelosert/vitest-coverage-report-action/commits/refs/tags/v2 --jq '.sha'

echo "actions/upload-artifact@v4:"
gh api repos/actions/upload-artifact/commits/refs/tags/v4 --jq '.sha'

echo "actions/download-artifact@v4:"
gh api repos/actions/download-artifact/commits/refs/tags/v4 --jq '.sha'

echo "stefanzweifel/git-auto-commit-action@v6:"
gh api repos/stefanzweifel/git-auto-commit-action/commits/refs/tags/v6 --jq '.sha'

Repository: PackRat-AI/PackRat

Length of output: 495


Pin GitHub Actions in .github/workflows/coverage.yml to commit SHAs.

All uses: entries reference mutable tags; pin them to the following SHAs (update every occurrence):

  • actions/checkout@v6actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
  • oven-sh/setup-bun@v2oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6
  • davelosert/vitest-coverage-report-action@v2davelosert/vitest-coverage-report-action@02f3c2e641286b7fa308cd3e430783103ce6103b
  • actions/upload-artifact@v4actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
  • actions/download-artifact@v4actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
  • stefanzweifel/git-auto-commit-action@v6stefanzweifel/git-auto-commit-action@778341af668090896ca464160c2def5d1d1a3eb0
🧰 Tools
🪛 zizmor (1.25.2)

[warning] 109-109: credential persistence through GitHub Actions artifacts (artipacked): does not set persist-credentials: false

(artipacked)


[warning] 150-150: credential persistence through GitHub Actions artifacts (artipacked): does not set persist-credentials: false

(artipacked)


[warning] 212-215: credential persistence through GitHub Actions artifacts (artipacked): does not set persist-credentials: false

(artipacked)


[warning] 271-271: credential persistence through GitHub Actions artifacts (artipacked): does not set persist-credentials: false

(artipacked)


[warning] 121-121: code injection via template expansion (template-injection): may expand into attacker-controllable code

(template-injection)


[error] 109-109: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)

(unpinned-uses)


[error] 111-111: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)

(unpinned-uses)


[error] 125-125: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)

(unpinned-uses)


[error] 135-135: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)

(unpinned-uses)


[error] 150-150: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)

(unpinned-uses)


[error] 152-152: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)

(unpinned-uses)


[error] 162-162: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)

(unpinned-uses)


[error] 212-212: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)

(unpinned-uses)


[error] 217-217: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)

(unpinned-uses)


[error] 227-227: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)

(unpinned-uses)


[error] 260-260: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)

(unpinned-uses)


[error] 271-271: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)

(unpinned-uses)


[error] 272-272: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)

(unpinned-uses)


[info] 260-260: action functionality is already included by the runner (superfluous-actions): use git add, git commit, and git push in a script step

(superfluous-actions)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/coverage.yml around lines 109 - 280, Replace all mutable
action tags in the workflow with the provided commit SHAs: change every
occurrence of actions/checkout@v6 →
actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd, oven-sh/setup-bun@v2
→ oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6,
davelosert/vitest-coverage-report-action@v2 →
davelosert/vitest-coverage-report-action@02f3c2e641286b7fa308cd3e430783103ce6103b,
actions/upload-artifact@v4 →
actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02,
actions/download-artifact@v4 →
actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093, and
stefanzweifel/git-auto-commit-action@v6 →
stefanzweifel/git-auto-commit-action@778341af668090896ca464160c2def5d1d1a3eb0;
ensure you update every use of the symbols (each uses: line) in the file so no
mutable tags remain.

body: {
name: item.name,
weight: item.weight,
weight: item.weight ?? 0,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Defaulting null weight to 0 creates poor UX.

When a catalog item has null weight, this code sends weight: 0 to the backend. This will either fail validation (if pack items require positive weight) or create a pack item with 0g weight.

Either case provides poor user experience - the user won't understand why the item couldn't be added, or they'll have incorrect data in their pack.

Consider one of these alternatives:

  • Disable the item button and show "Weight required" if item.weight === null
  • Show a prompt for the user to enter weight before adding
  • Display a clear error message explaining the issue
Proposed fix to disable items without weight
 <button
   type="button"
   key={item.id}
+  disabled={item.weight === null}
   onClick={() => {
+    if (item.weight === null) return;
     addItem.mutate(
       {
         packId,
         body: {
           name: item.name,
-          weight: item.weight ?? 0,
+          weight: item.weight,
           weightUnit: 'g',
           catalogItemId: item.id,
         },
       },
       { onSuccess: onClose },
     );
   }}
-  className="w-full rounded-xl bg-card border border-border p-3 text-left hover:border-primary/50 transition-colors"
+  className={cn(
+    "w-full rounded-xl bg-card border border-border p-3 text-left transition-colors",
+    item.weight === null 
+      ? "opacity-50 cursor-not-allowed"
+      : "hover:border-primary/50"
+  )}
 >

And update the display to show why it's disabled:

 <div className="text-right">
   <p className="text-sm font-semibold">
     {item.weight === null ? 'Unknown' : fw(item.weight)}
   </p>
+  {item.weight === null && (
+    <p className="text-xs text-destructive">Weight required</p>
+  )}
   {item.price && <p className="text-xs text-muted-foreground">${item.price}</p>}
 </div>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/components/screens/packs-screen.tsx` at line 635, The current
payload line "weight: item.weight ?? 0" silently converts null weights to 0;
change behavior so items with item.weight === null are not sent to the backend
and the UI prevents adding them. Locate the place building the pack-item payload
(the line containing "weight: item.weight ?? 0" inside the add/submit handler
such as the function that adds a catalog item to a pack) and instead
short-circuit when item.weight === null (do not call the API) and surface a UI
state: disable the corresponding add button and show a tooltip or label like
"Weight required" (update the item's button/row rendering to check item.weight
=== null and set disabled + explanatory text); if you prefer prompting, replace
the short-circuit with a modal/inline prompt that collects a valid weight before
constructing the payload.

Comment on lines +11 to +32
```
POST /api/catalog/etl ── api-key auth
│ body: { filename, chunks[], source, scraperRevision }
1. INSERT etl_jobs (status='running')
2. For each objectKey: R2.head() → split into 20 MB byte-range chunks
3. queueCatalogETL → ETL_QUEUE.sendBatch (one message per chunk, same jobId)

ETL_QUEUE (max_batch_size=1, max_concurrency=1)
processQueueBatch
processCatalogETL ── per chunk
├── R2.get(key, {range}) ── stream body
├── if non-first chunk: GET first 4 KB → extract header → inject; skip partial row
├── csv-parse stream w/ backpressure (parser.write returns false → wait 'drain')
├── yield every 100 rows (setTimeout(1))
├── flush at BATCH_SIZE=100 rows:
│ valid → processValidItemsBatch → mergeBySku → embeddings → catalogService.upsert → updateEtlJobProgress
│ invalid → processLogsBatch → invalid_item_logs → updateEtlJobProgress
└── on success: UPDATE etl_jobs SET status='completed' ◀── PROBLEM with multi-chunk jobs
on throw: UPDATE etl_jobs SET status='failed' + rethrow

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | 💤 Low value

Add language identifier to code fence.

The architecture diagram code fence should specify a language identifier (e.g., text or mermaid) for better markdown rendering and to satisfy linting rules.

-```
+```text
 POST /api/catalog/etl            ── api-key auth
    │  body: { filename, chunks[], source, scraperRevision }
🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 11-11: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/audits/2026-05-16-etl-audit.md` around lines 11 - 32, The fenced
architecture diagram in docs/audits/2026-05-16-etl-audit.md is missing a
language identifier which fails linting and renders less predictably; update the
opening code fence for the ETL diagram (the triple-backtick block that begins
with "POST /api/catalog/etl ...") to include a language tag such as text or
mermaid (e.g., change ``` to ```text) so the diagram is properly highlighted and
the linter is satisfied.

Comment on lines +136 to +157
const rawVariants = obj.variants;
if (Array.isArray(rawVariants)) {
item.variants = rawVariants as NewCatalogItem['variants'];
}

// --- links: passthrough ---
const rawLinks = obj.links;
if (Array.isArray(rawLinks)) {
item.links = rawLinks as NewCatalogItem['links'];
}

// --- reviews: passthrough ---
const rawReviews = obj.reviews;
if (Array.isArray(rawReviews)) {
item.reviews = rawReviews as NewCatalogItem['reviews'];
}

// --- qas: passthrough ---
const rawQas = obj.qas;
if (Array.isArray(rawQas)) {
item.qas = rawQas as NewCatalogItem['qas'];
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Unsafe type assertions on array fields.

Lines 138, 144, 150, 156 use as NewCatalogItem['variants'] (and similar) to cast raw arrays without validating their internal structure. If the JSONL source contains malformed objects in these arrays, downstream code expecting { color: string } for variants or { url: string } for links could crash on missing/wrong-typed fields.

Either add runtime validation for these arrays (check object shapes, required fields) or document that the validator is responsible for rejecting malformed structures in a later stage.

🛡️ Example validation for variants
 const rawVariants = obj.variants;
 if (Array.isArray(rawVariants)) {
-  item.variants = rawVariants as NewCatalogItem['variants'];
+  // Validate each variant has expected shape before accepting
+  const validVariants = rawVariants.filter((v): v is Record<string, unknown> => 
+    isObject(v) && Object.keys(v).length > 0
+  );
+  if (validVariants.length > 0) {
+    item.variants = validVariants as NewCatalogItem['variants'];
+  }
 }

Apply similar filtering to links, reviews, and qas.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/api/src/utils/json-utils.ts` around lines 136 - 157, The code
unsafely casts raw arrays (rawVariants, rawLinks, rawReviews, rawQas) to
NewCatalogItem types without validating inner shapes; update the parsing in
json-utils.ts to perform runtime validation/filtering of each array before
assigning to item. For each symbol (rawVariants -> item.variants, rawLinks ->
item.links, rawReviews -> item.reviews, rawQas -> item.qas) iterate the array,
ensure each entry has the required fields and correct types (e.g., variant
objects have expected keys like color/size, link objects have url as string,
etc.), skip or reject invalid entries (or return a validation error) and only
then assign the filtered/validated array to the corresponding item property;
alternatively, call a reusable validator function (e.g.,
validateVariant/validateLink/validateReview/validateQa) to centralize checks.

Comment on lines +126 to +140
let firstLineSkipped = true;

for await (const text of streamToText(obj.body)) {
buffer += text;
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';

for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;

if (!firstLineSkipped) {
firstLineSkipped = true;
continue; // discard partial row at chunk boundary
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Remove dead code: firstLineSkipped logic is unreachable.

firstLineSkipped is initialized to true (line 126), so the condition if (!firstLineSkipped) (line 137) can never execute. This entire block is unreachable dead code.

The comment on lines 123-124 confirms: "The chunker snaps boundaries to newlines, so every chunk starts at a clean line boundary — no partial first-line skip needed for any chunk." The firstLineSkipped logic should be removed entirely.

🧹 Proposed fix
     let buffer = '';
-    let firstLineSkipped = true;
 
     for await (const text of streamToText(obj.body)) {
       buffer += text;
       const lines = buffer.split('\n');
       buffer = lines.pop() ?? '';
 
       for (const line of lines) {
         const trimmed = line.trim();
         if (!trimmed) continue;
 
-        if (!firstLineSkipped) {
-          firstLineSkipped = true;
-          continue; // discard partial row at chunk boundary
-        }
-
         if (rowIndex % 100 === 0) {

Then remove the reference on line 196:

     // Flush remaining buffer line (last line without trailing newline)
     const lastLine = buffer.trim();
-    if (lastLine && firstLineSkipped) {
+    if (lastLine) {
       try {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let firstLineSkipped = true;
for await (const text of streamToText(obj.body)) {
buffer += text;
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
if (!firstLineSkipped) {
firstLineSkipped = true;
continue; // discard partial row at chunk boundary
}
let buffer = '';
for await (const text of streamToText(obj.body)) {
buffer += text;
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
if (rowIndex % 100 === 0) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/api/src/workflows/catalog-etl-workflow.ts` around lines 126 - 140,
The firstLineSkipped dead-code should be removed: delete the let
firstLineSkipped = true declaration and remove the conditional block that checks
if (!firstLineSkipped) { firstLineSkipped = true; continue; } inside the loop in
the catalog ETL workflow (in packages/api/src/workflows/catalog-etl-workflow.ts,
locate the for await (const text of streamToText(obj.body)) loop and the inner
for (const line of lines) block); also remove any other references to
firstLineSkipped later in the file (e.g., the reference mentioned around the
later lines) so the chunk-processing logic simply trims/continues on empty lines
without the unreachable skip logic.

Comment on lines +57 to +65
const sort = encodeURIComponent(JSON.stringify({ field: 'createdAt', order: 'desc' }));

const res = await apiWithAuth(`/catalog?sort=${sort}`);

expect(res.status).toBe(200);
const data = await expectJsonResponse(res, ['items']);
const item = data.items.find(
(catalogItem: { id: number }) => catalogItem.id === seededItem.id,
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Make this assertion independent of the first page.

This only searches data.items from the default paginated response. If other seeded rows exist, the new item can fall off page 1 and this test flakes even though null weights are returned correctly. Pass an explicit limit or filter the request by a unique field from the seeded row before asserting.

As per coding guidelines, "Tests must be deterministic — mock all external services and clocks."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/api/test/catalog.test.ts` around lines 57 - 65, The test currently
queries apiWithAuth(`/catalog?sort=${sort}`) and searches data.items for
seededItem, which is brittle if pagination moves the item off page 1; update the
request to be deterministic by adding an explicit limit parameter (e.g.,
&limit=100) or add a filter query that targets a unique property of seededItem
(e.g., &name=<uniqueName>), then call expectJsonResponse(res, ['items']) and
assert against the returned items for seededItem.id; keep references to
seededItem, apiWithAuth, and expectJsonResponse so reviewers can locate the
change.

Comment on lines +41 to +63
describe('Migration 0047 — ETL workflow columns', () => {
it('adds workflow_instance_id as nullable text', async () => {
const cols = await describeColumns('etl_jobs');
const col = cols.find((c) => c.column_name === 'workflow_instance_id');
expect(col).toBeDefined();
expect(col?.data_type).toBe('text');
expect(col?.is_nullable).toBe('YES');
});

it('adds total_embedding_failures as integer NOT NULL DEFAULT 0', async () => {
const cols = await describeColumns('etl_jobs');
const col = cols.find((c) => c.column_name === 'total_embedding_failures');
expect(col).toBeDefined();
expect(col?.data_type).toBe('integer');
expect(col?.is_nullable).toBe('NO');
expect(col?.column_default).toBe('0');
});

it('adds the workflow_instance_id index', async () => {
const indexes = await describeIndexes('etl_jobs');
const names = new Set(indexes.map((i) => i.indexname));
expect(names.has('etl_jobs_workflow_instance_id_idx')).toBe(true);
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | 💤 Low value

Test coverage is partial — only 2 of 8 new columns checked.

Migration 0047 adds workflow_instance_id, total_embedding_failures, verified_at, verified_row_count, source_etag, source_last_modified, superseded_by_job_id, and superseded_at. This test only verifies the first two.

If this is intentional (smoke test for critical columns), document that choice. Otherwise, consider adding assertions for the remaining columns and the superseded_by_idx index.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/api/test/db-schema-etl.test.ts` around lines 41 - 63, The test only
asserts two of the eight columns added by Migration 0047; update the test in
db-schema-etl.test.ts to either document that it is intentionally a smoke test
or add assertions for the remaining columns (`verified_at`,
`verified_row_count`, `source_etag`, `source_last_modified`,
`superseded_by_job_id`, `superseded_at`) using the existing helpers
`describeColumns('etl_jobs')` to check data_type/is_nullable/column_default as
appropriate, and add an assertion that `describeIndexes('etl_jobs')` includes
the `superseded_by_idx` index (in addition to the existing
`etl_jobs_workflow_instance_id_idx` check). Ensure each new assertion mirrors
the style of the existing tests for `workflow_instance_id` and
`total_embedding_failures`.

Comment on lines +172 to +207
function renderReport(report: RatchetReport): string {
const lines: string[] = [];
const DIVIDER = '─'.repeat(60);
lines.push('\nCoverage Ratchet');
lines.push(DIVIDER);
for (const c of report.checks) {
if (c.status === 'ok') {
lines.push(`✅ ${c.workspace} — baseline met`);
} else if (c.status === 'improvement') {
lines.push(`📈 ${c.workspace} — coverage improved (CI on main will bump baseline)`);
if (c.before && c.after) {
for (const m of METRICS) {
if (c.after[m] - c.before[m] > 0.05) {
lines.push(` ${m}: ${fmtPct(c.before[m])} → ${fmtPct(c.after[m])}`);
}
}
}
} else if (c.status === 'regression' && c.regressions) {
lines.push(`❌ ${c.workspace} — REGRESSION:`);
for (const r of c.regressions) {
lines.push(` ${r.metric}: ${fmtPct(r.before)} → ${fmtPct(r.after)}`);
}
} else if (c.status === 'missing-summary' || c.status === 'invalid-summary') {
lines.push(`❌ ${c.workspace} — ${c.message}`);
}
}
lines.push(DIVIDER);
if (report.passed) {
lines.push('All workspaces ≥ baseline.');
} else {
lines.push('One or more workspaces regressed. Run the workspace coverage');
lines.push('script locally to reproduce, add tests for the affected files,');
lines.push('and commit until the ratchet passes. See docs/testing.md.');
}
return lines.join('\n');
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Pass epsilon parameter to renderReport for consistent improvement thresholds.

Line 184 hard-codes 0.05 for improvement display, but the actual comparison in compareWorkspace uses the configurable epsilon. If the baseline file sets _epsilon: 0.1, improvements will be detected with the 0.1 threshold but displayed with a 0.05 threshold, causing inconsistency.

🔧 Proposed fix
-function renderReport(report: RatchetReport): string {
+function renderReport(report: RatchetReport, epsilon: number): string {
   const lines: string[] = [];
   const DIVIDER = '─'.repeat(60);
   lines.push('\nCoverage Ratchet');
   lines.push(DIVIDER);
   for (const c of report.checks) {
     if (c.status === 'ok') {
       lines.push(`✅  ${c.workspace} — baseline met`);
     } else if (c.status === 'improvement') {
       lines.push(`📈  ${c.workspace} — coverage improved (CI on main will bump baseline)`);
       if (c.before && c.after) {
         for (const m of METRICS) {
-          if (c.after[m] - c.before[m] > 0.05) {
+          if (c.after[m] - c.before[m] > epsilon) {
             lines.push(`     ${m}: ${fmtPct(c.before[m])} → ${fmtPct(c.after[m])}`);
           }
         }
       }

And update the call site at line 230:

-  console.log(renderReport(report));
+  console.log(renderReport(report, epsilon));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/lint/coverage-ratchet.ts` around lines 172 - 207, renderReport
currently hard-codes a 0.05 threshold when showing metric improvements which can
differ from the actual epsilon used by compareWorkspace; update
renderReport(signature) to accept an epsilon parameter and use that instead of
0.05 when deciding which improvements to print, then update the caller that
invokes renderReport (the call that builds the final report after
compareWorkspace runs) to pass through the same epsilon value used during
comparison so displayed improvement thresholds match actual logic.

@mikib0 mikib0 merged commit 612c501 into development Jun 2, 2026
27 of 29 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants