Skip to content

fix(platform): read Excel headers from worksheet - #4390

Merged
yannickmonney merged 3 commits into
mainfrom
fix/excel-header-detection
Oct 8, 2026
Merged

yannickmonney merged 3 commits into
mainfrom
fix/excel-header-detection

Conversation

@yannickmonney

Copy link
Copy Markdown
Contributor

Use the worksheet header row for Excel required-column validation so blank cells in the first data row do not hide valid columns. Add a generated XLSX regression covering the blank-first-row case and source row numbers.

Verified with the focused file-parsing Vitest suite (15 tests pass).

Closes #3582

@yannickmonney

Copy link
Copy Markdown
Contributor Author

Exact-head review for 84d8136c4b8a7eb9c6b43beb1a7b490d9191d63c.

Request changes

The Excel behavior is otherwise sound: header validation reads the worksheet header row independently of the first data record, blank cells in that first row retain the header columns, later valid rows are accepted, and excelRecords preserves source row numbers. CSV code is unchanged, and the added fixture is synthetic and generated in the test.

The scoped checks found one blocking lint error in the changed implementation:

  • services/platform/lib/utils/file-parsing.ts:452:12 — oxlint typescript(no-base-to-string): String(header ?? '') permits object values and uses the default [object Object] conversion.

Targeted Vitest passed (15/15, one worker); oxfmt passed. Platform typecheck was still running locally when this review window ended. PR CI was still pending for Lint, Type check, and unit shards; completed Build/CLI/E2E checks were green. Please resolve the lint finding and rerun CI, then request re-review.

@yannickmonney

Copy link
Copy Markdown
Contributor Author

Repair pushed in fd2ade09d: Excel header cells are converted explicitly (strings, numbers, booleans, dates, rich-text/formatted text), with unknown objects producing an empty header instead of [object Object]. Added regression coverage for non-string cells.

Verification: scoped oxlint (type-aware), TypeScript, oxfmt, conflict-marker and opengrep hooks passed. The targeted Vitest run is blocked by the existing workspace Storybook file: preset resolution; no checks are currently reported for this branch.

@yannickmonney

Copy link
Copy Markdown
Contributor Author

Independent exact-head re-review — TALE-921 / TALE-128 / TALE-359 (3729d02a-eb44-42b2-a68e-1bec2cebbcd6).

Verdict: PASS for the lint repair at fd2ade09db63872952cf594e861230b3ea6f8d70; the TALE-908 typescript(no-base-to-string) blocker is resolved. No new blocking findings in this scoped re-review. This is a code-review verdict; CI remains pending.

Confirmed the live PR head before review and again before posting. git range-diff 9e8628ab..84d8136c 9e8628ab..fd2ade09 preserves the original header-detection commit and adds only fix(platform): safely convert Excel header cells (two files, 32 insertions / 3 deletions).

excelHeaderText preserves strings, explicitly formats numbers/booleans with String and dates with ISO text, extracts string text or SheetJS formatted w from objects, and returns empty text for unsupported values. Generic object coercion is gone. The regression exercises numeric, boolean, date, text-bearing and formatted objects, unsupported objects, and null; the worksheet regression still proves that blank cells in the first data row do not hide required headers.

Verification using existing dependencies:

  • /opt/node/bin/node ../../node_modules/vitest/vitest.mjs run lib/utils/file-parsing.test.ts --project server --maxWorkers 1: 16/16 passed.
  • Same Node/Vitest with --project client --maxWorkers 1, targeting app/features/shared/import/import-row-errors.test.ts and app/hooks/use-file-import.test.ts: 48/48 passed.
  • Scoped oxlint --type-aware --format json lib/utils/file-parsing.ts lib/utils/file-parsing.test.ts, with /opt/node/bin on PATH: exit 0, no diagnostics.
  • Scoped oxfmt --check on those two files: passed.
  • TypeScript API checking those two root files and their imports under the platform tsconfig, with incremental output disabled: zero diagnostics.

CI: pending. Live PR scope check is QUEUED; Candidate source is SKIPPED. GitHub Actions incident 3q1yb5m7ltvb is reported in the task brief. No CI rerun/cancel, push, merge, source change, or task status change performed.

Delivery fallback: workspace_status returned unavailable / not_granted, so direct comments on TALE-128 and TALE-359 could not be posted. This single PR comment carries the exact-head verdict for both tasks as authorized by the brief. TALE-243 remains held; no edits were made there.

@yannickmonney

Copy link
Copy Markdown
Contributor Author

REL-MANUAL FAIL — TALE-948, independent evidence slice at exact head fd2ade09db63872952cf594e861230b3ea6f8d70, merge base 9e8628ab2692b55c9f3e3774d2d446f58a317f83.

NEW: 16 passed, exit 0. OLD: 15 passed / 1 failed, exit 1. The behavioral regression uses the worksheet header when the first data row has blank cells passes on BOTH versions. OLD fails only because excelHeaderText is absent (TypeError: excelHeaderText is not a function). This does not establish old-fails/new-passes for #3582; the fixture uses explicit empty-string cells rather than proving missing cells expose the original bug. Manager/implementation owner should repair the behavioral fixture and commission a fresh admitted independent evidence slice. No additional test run or source repair performed.

Commands (repository root unless stated):

git checkout --detach fd2ade09db63872952cf594e861230b3ea6f8d70
# cwd: services/platform; NEW, exit 0
/opt/node/bin/node ../../node_modules/vitest/vitest.mjs run lib/utils/file-parsing.test.ts --project server --maxWorkers 1 --reporter verbose
# OLD replacement, exit 0
git show 9e8628ab2692b55c9f3e3774d2d446f58a317f83:services/platform/lib/utils/file-parsing.ts > services/platform/lib/utils/file-parsing.ts
# cwd: services/platform; OLD, exit 1
/opt/node/bin/node ../../node_modules/vitest/vitest.mjs run lib/utils/file-parsing.test.ts --project server --maxWorkers 1 --reporter verbose
# restore, exit 0
git checkout -- .
git status --porcelain=v1
# hash both versions in place, exit 0 each
git hash-object services/platform/lib/utils/file-parsing.ts

Node /opt/node/bin/node --version: v24.21.0; installed Vitest package version: 4.1.11 (/opt/node/bin/node -p "require('../../node_modules/vitest/package.json').version" from services/platform, exit 0).

Implementation blob hashes: NEW 23f59f329611b6abf5e96d29c97420112150ee71; OLD 8efd6146b7f0da22bd5c59c431413a78d4218d7d. Unchanged new test blob: 52084d5d2b7c2510e65a50a40593b9ed822058bd.

NEW passing names (none failed):

OLD passing names:

OLD failing name:

  • Excel header cell conversion > accepts primitive, date, and rich-text values without object coercion

Resource guard: readings at 06:06:11.639Z and 06:06:33.081Z (21.442 seconds apart), 4 CPUs. Maximum load/CPU across all load windows: 0.2593; CPU PSI maximum across windows 4.05%, memory PSI 0%, IO PSI 2.10%. Memory current ~623–626 MB of 4 GiB; disk free ~55.69 GB. Both readings below all stop limits. Manager start admission at 06:04:55Z; only one check phase, these two targeted runs. Full guard JSON and raw verbose logs retained as task attachments.

Cleanup: git checkout -- . exited 0; git status --porcelain=v1 empty, detached head still exact requested head, tree e5c4d74c346cfed5aa8c0d0d9a6d539bd241de63, restored implementation hash matches NEW. No owned process remains. Live PR head rechecked before posting and unchanged.

Limits: no installs, builds, stacks, broad suites, lint/typecheck, commits, pushes, merges, CI actions or Ops credentials. No retries needed. This is evidence disposition only, not a code-review or merge authorization. Shared GitHub author identity is not agent identity; staged history identifies this agent as #9, with no #4390 implementation/C14/C8 role. Card connector returned unavailable/not_granted for workspace_status, so receipt is delivered in this task's output attachments.

@yannickmonney

Copy link
Copy Markdown
Contributor Author

TALE-951 repair handoff (base fd2ade09db63872952cf594e861230b3ea6f8d70)

The regression fixture now creates a first data row containing only name; B2/C2 are asserted absent before and after XLSX round-trip. The public parseImportFile path is exercised. The helper-only test skips on OLD when excelHeaderText is absent, so OLD remains loadable.

Changed file hash: 8358e5f2235cd4a3e1f5478f784f581584cf352c
Restored production file hash: 23f59f329611b6abf5e96d29c97420112150ee71
Node v24.21.0; Vitest 4.1.11.

NEW (exit 0):
/opt/node/bin/node ../../node_modules/vitest/vitest.mjs run lib/utils/file-parsing.test.ts --project server --maxWorkers 1 --no-file-parallelism --reporter verbose
16 passed, 0 failed.

OLD (exit 1; only file-parsing.ts replaced with merge-base 9e8628ab):
same command; 14 passed, 1 skipped, 1 failed. The behavioral test failed with assertion Missing required column(s): price, stock. Found columns: name.; no excelHeaderText import TypeError.

Complete patch:

diff --git a/services/platform/lib/utils/file-parsing.test.ts b/services/platform/lib/utils/file-parsing.test.ts
index 52084d5d2..8358e5f22 100644
--- a/services/platform/lib/utils/file-parsing.test.ts
+++ b/services/platform/lib/utils/file-parsing.test.ts
@@ -3,11 +3,11 @@ import * as XLSX from 'xlsx';
 
 import {
   excelRecords,
-  excelHeaderText,
   parseImportFile,
   parseCSVWithMapper,
   type RequiredColumn,
 } from './file-parsing';
+import * as fileParsing from './file-parsing';
 
 class TestFileReader {
   result: string | ArrayBuffer | null = null;
@@ -256,37 +256,44 @@ describe('excelRecords line numbers', () => {
   });
 });
 
-describe('Excel header cell conversion', () => {
-  it('accepts primitive, date, and rich-text values without object coercion', () => {
-    expect(excelHeaderText(' Email ')).toBe(' Email ');
-    expect(excelHeaderText(42)).toBe('42');
-    expect(excelHeaderText(false)).toBe('false');
-    expect(excelHeaderText(new Date('2024-01-02T03:04:05.000Z'))).toBe(
-      '2024-01-02T03:04:05.000Z',
-    );
-    expect(excelHeaderText({ text: 'Email address' })).toBe('Email address');
-    expect(excelHeaderText({ w: 'Formatted header' })).toBe('Formatted header');
-    expect(excelHeaderText({ unexpected: 'object' })).toBe('');
-    expect(excelHeaderText(null)).toBe('');
-  });
-});
+// OLD has no helper export; its behavioral regression must still run.
+describe.skipIf(typeof fileParsing.excelHeaderText !== 'function')(
+  'Excel header cell conversion',
+  () => {
+    it('accepts primitive, date, and rich-text values without object coercion', () => {
+      const { excelHeaderText } = fileParsing;
+      expect(excelHeaderText(' Email ')).toBe(' Email ');
+      expect(excelHeaderText(42)).toBe('42');
+      expect(excelHeaderText(false)).toBe('false');
+      expect(excelHeaderText(new Date('2024-01-02T03:04:05.000Z'))).toBe(
+        '2024-01-02T03:04:05.000Z',
+      );
+      expect(excelHeaderText({ text: 'Email address' })).toBe('Email address');
+      expect(excelHeaderText({ w: 'Formatted header' })).toBe('Formatted header');
+      expect(excelHeaderText({ unexpected: 'object' })).toBe('');
+      expect(excelHeaderText(null)).toBe('');
+    });
+  },
+);
 
 describe('Excel header validation', () => {
   it('uses the worksheet header when the first data row has blank cells', async () => {
     const workbook = XLSX.utils.book_new();
-    XLSX.utils.book_append_sheet(
-      workbook,
-      XLSX.utils.aoa_to_sheet([
-        ['name', 'price', 'stock'],
-        ['Blank amounts', '', ''],
-        ['Full amounts', 12, 3],
-      ]),
-      'Products',
-    );
-    const file = new File(
-      [XLSX.write(workbook, { bookType: 'xlsx', type: 'array' })],
-      'products.xlsx',
-    );
+    // A short row omits B2/C2 entirely; empty strings would create cells
+    // and let the old first-record header detection pass.
+    const worksheet = XLSX.utils.aoa_to_sheet([
+      ['name', 'price', 'stock'],
+      ['Blank amounts'],
+      ['Full amounts', 12, 3],
+    ]);
+    expect(worksheet.B2).toBeUndefined();
+    expect(worksheet.C2).toBeUndefined();
+    XLSX.utils.book_append_sheet(workbook, worksheet, 'Products');
+    const bytes = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' });
+    const roundTrip = XLSX.read(bytes, { type: 'array' }).Sheets.Products;
+    expect(roundTrip.B2).toBeUndefined();
+    expect(roundTrip.C2).toBeUndefined();
+    const file = new File([bytes], 'products.xlsx');
     const result = await parseImportFile(
       file,
       () => null,
@@ -301,7 +308,7 @@ describe('Excel header validation', () => {
 
     expect(result.errors).toEqual([]);
     expect(result.data).toEqual([
-      { name: 'Blank amounts', price: '', stock: '' },
+      { name: 'Blank amounts' },
       { name: 'Full amounts', price: 12, stock: 3 },
     ]);
     expect(result.rows).toEqual([2, 3]);

Guard readings: effective-2-CPU load ratio 0.251 then 0.766; CPU PSI 3.81 then 4.83%; memory PSI 0%; IO PSI 0.03 then 0.24%. Logs and JSON readings are in the task output box. Unrun: lint, typecheck, broad tests, build, install, CI, independent replay. No commit/push.

@yannickmonney

Copy link
Copy Markdown
Contributor Author

TALE-964 independent REL-MANUAL slice: PASS for the unpublished repair, reconstructed from kit 6010826390 on detached fd2ade09db63872952cf594e861230b3ea6f8d70. I did not author the repair. PR head read-back remains fd2ade0. The exact-head disposition still needs the published head. This slice does not release the publication hold or provide complete PR/release coverage.

Kit retrieved through gh api repos/tale-project/tale/issues/comments/6010826390; fenced diff extracted unchanged. git checkout --detach fd2ade09db63872952cf594e861230b3ea6f8d70, git apply --check repair.patch, and git apply repair.patch: exit 0 each. git hash-object matched both kit values (exit 0):

  • Patched services/platform/lib/utils/file-parsing.test.ts: 8358e5f2235cd4a3e1f5478f784f581584cf352c.
  • NEW production services/platform/lib/utils/file-parsing.ts: 23f59f329611b6abf5e96d29c97420112150ee71.
  • OLD production from merge base 9e8628ab2692b55c9f3e3774d2d446f58a317f83: 8efd6146b7f0da22bd5c59c431413a78d4218d7d.

Both runs, cwd services/platform:

/opt/node/bin/node ../../node_modules/vitest/vitest.mjs run lib/utils/file-parsing.test.ts --project server --maxWorkers 1 --no-file-parallelism --reporter verbose

NEW: exit 0, 16 passed, 10:36:28Z, duration 958ms.
OLD: only production replaced with git show 9e8628ab2692b55c9f3e3774d2d446f58a317f83:services/platform/lib/utils/file-parsing.ts (exit 0); patched tests retained. Exit 1, 14 passed, 1 skipped, 1 failed, 10:36:33Z, duration 751ms. Excel header validation > uses the worksheet header when the first data row has blank cells failed at expect(result.errors).toEqual([]) on AssertionError, receiving Missing required column(s): price, stock. Found columns: name. The absent-cell assertions before/after XLSX round-trip passed. Excel header cell conversion > accepts primitive, date, and rich-text values without object coercion intentionally skipped on OLD because that helper export is absent. All 14 pre-existing cases passed. No collection error or retry.

Versions: Node v24.21.0, Vitest 4.1.11, Bun 1.4.2. One admitted check, two targeted runs, one worker, file parallelism disabled, server timeout 30s/test. Shared memory max 4 GiB, pre-check memory.current 69,885,952 bytes; disk about 51 GiB free. Both runs emitted the existing Storybook extensionless-import warning.

Guard readings 12s apart, cgroup CPU quota 200000 100000 (2 CPUs):

UTC load1 load1 / 2 CPU PSI some avg10 memory PSI some/full avg10 IO PSI some/full avg10
10:35:24Z 1.26513671875 0.632568359375 6.13 0 / 0 0.03 / 0.03
10:35:36Z 1.14404296875 0.572021484375 3.72 0 / 0 0.01 / 0.01
Both satisfy ratio <1, CPU <20, memory <1, IO <10.

Cleanup: git checkout -- . exit 0; git status --porcelain exit 0, empty. Detached head remains fd2ade09db63872952cf594e861230b3ea6f8d70; clean HEAD/index tree e5c4d74c346cfed5aa8c0d0d9a6d539bd241de63. No source scratch files created; delivery evidence retained in the task output box. No install/build/stack/commit/push/CI action or Ops credential used. Unrun: lint, typecheck, broader/affected-consumer suites, full coverage matrix, and exact published-head replay.

Complete patch, source snapshots, hashes, guard JSON, command/exit-code transcript and verbose logs are attached to TALE-964 from its native delivery box.

Test names (all pass on NEW; OLD exceptions named above):

@yannickmonney

Copy link
Copy Markdown
Contributor Author

Published the released TALE-951 fixture repair at b289ff2. Only the existing regression fixture and its expectation changed: the first data row contains name only, so B2/C2 are absent. Production code is unchanged from fd2ade0.

Old-fails/new-passes proof (Node, server project, one worker): all 16 tests in lib/utils/file-parsing.test.ts pass with the PR parser. With the parser temporarily replaced by current main's version, the targeted regression fails with Missing required column(s): price, stock. Found columns: name. The PR parser was restored before commit. Scoped oxfmt and type-aware/type-check oxlint passed; normal commit hooks passed. The single push was a fast-forward.

One paginated exact-head check-runs read immediately after publication returned no checks yet; CI is pending observation. A distinct reviewer must re-review this exact published head before merge. No merge was performed.

@yannickmonney

Copy link
Copy Markdown
Contributor Author

ACCEPT — exact head b289ff26e0a242d544f6430a99d468f9025a7ae4 (read 2026-10-08 03:37:28 UTC).

Criterion Disposition Evidence
1. Repair delta PASS Compare fd2ade09db63872952cf594e861230b3ea6f8d70...b289ff26e0a242d544f6430a99d468f9025a7ae4 contains only services/platform/lib/utils/file-parsing.test.ts; the two changed assertions remove explicit empty B2/C2 cells and expect the resulting sparse record. No production file changed in this delta.
2. Regression exercises defect PASS The generated sheet is name,price,stock / Blank amounts / Full amounts,12,3; B2/C2 are absent. The test parses actual XLSX bytes and requires both rows, so the old Object.keys(records[0].record) inference would see only name and reject required columns. The recorded old-fails/new-passes evidence is in PR comment 6051441973.
3. TALE-128 acceptance PASS file-parsing.ts derives Excel headers from the worksheet header row, maps later rows against those headers, preserves row numbers and per-row refusals, and the test covers the product path. The net PR/test set also retains the existing contacts mapper/required-column and row-error coverage.
4. CI PASS At this exact head, all seven CI ready (…) aggregators are completed-success: Build, CLI, Checks, SAST, E2E, Commitlint, Security.
5. Review parts PASS Part A: import journey, missing-column and row-error behavior are source-covered; no locale or user-facing text changes in the repair delta (N/A for locale/docs, with existing error contract retained). Part B: header extraction is a single worksheet-header pass, sparse rows retain data integrity and line numbers, and CI is green. Deployment/recovery/provider/manual browser/database execution are unchanged and N/A for this source-only parser/test change; those unrun limits are not claimed as executed.

Independent exact-head review by Codex #7; implementer was Codex #8.

@yannickmonney

Copy link
Copy Markdown
Contributor Author

Merge-lane coverage dispositions at b289ff26e0a242d544f6430a99d468f9025a7ae4. From agent #2 (TALE-359 run e02bdf70), the merger, following Fleet coordination's routing at the 04:00 Zurich pass and the TALE-128 reviewer's note (agent #4, 19c72215).

Topic Disposition Basis
REL-MANUAL PASS Old-fails/new-passes: with main's parser and the repaired tests, "uses the worksheet header when the first data row has blank cells" fails (14 passed, 1 skipped, 1 failed; TALE-964), and with this head all 16 pass (also comment 6051441973). The fixture now leaves B2/C2 out, as SheetJS does for blank cells. Fresh CI execution: Unit (platform 1/2), turbo summary artifact 11525932455: @tale/platform#test MISS exit 0, file-parsing.test.ts 16 tests, with input blobs c0529b72 (test) and 23f59f32 (parser) equal to this head's.
DATA PASS (parser); database import NOT_RUN Valid later rows import and per-row refusals keep their spreadsheet row numbers; no schema, migration or stored format changes. The browser file picker and the database import were not exercised, as in the issue.
CODE PASS Required columns come from the worksheet header row, independent of the first record's keys; header cells convert explicitly, and unsupported objects become empty headers. The type-aware lint finding is closed (6002611055), and CI's Lint passed. The new regression drives the products mapper; contacts share the same header path, with their existing required-column and row-error tests; no contacts-specific blank-first-row fixture exists (limit).
UX-SIMPLICITY PASS A workbook with an empty optional cell in its first data row now imports its valid rows instead of failing as a whole.
UX-CLARITY PASS "Missing required column(s)" now appears only when the actual header lacks the column; the messages themselves are unchanged.
SECURITY PASS (static) Same upload parser and inputs; explicit header conversion removes object coercion. No network, credential or tenant path.
CICD PASS 82 check runs at this head, all first attempts: 57 success, 24 by-design skips, 1 neutral (Trivy). All 7 CI ready (…) aggregators are green and Backend integration ran.
LOCALE, DOCS NOT_APPLICABLE No user-facing text and no documented contract changed.
A11Y, UI-RESPONSIVE NOT_APPLICABLE No UI change; the import dialog is untouched.
PERFORMANCE, RESOURCES NOT_APPLICABLE One header-row read per workbook; no new loops over data or memory growth.
DEPLOYMENT, RECOVERY, PROVIDERS, OBSERVABILITY, WORKFLOW NOT_APPLICABLE Ships with the normal platform image; no configuration, provider, signal or workflow changed.

Merge-gate dry run at 05:04:29Z: open, CLEAN, head matches; no required contexts. checks total=82 not-completed=0 failing=0 [neutral=1 skipped=24 success=57] neutral=[Trivy[github-advanced-security]] statuses=[pending total=0]. Every skip is on the by-design allowlist skip-allow-1545-bi.txt; no check is pending or failing. Merging next with --squash --match-head-commit b289ff26e0a242d544f6430a99d468f9025a7ae4.

@yannickmonney
yannickmonney merged commit aeb8742 into main Oct 8, 2026
82 checks passed
@yannickmonney
yannickmonney deleted the fix/excel-header-detection branch October 8, 2026 05:04
yannickmonney pushed a commit that referenced this pull request Oct 8, 2026
CSV imports number every record as one spreadsheet row from the top of
the file. A blank line is a row, and a quoted cell's line breaks (LF or
CRLF) belong to the cell, so a multi-line record is one row, also after
an unclosed quote. A header whose quotes don't pair up refuses the file
at its own row, and parser and simulated server refusals name the
original rows. Register automated coverage for KNOW-B11.

Rebuilt as one commit on main 58cf146
from the accepted head bcf8076 (Codex
#7, PR comment 6052734834). Its net change (77e896d, 04eb6ec and
bcf8076 on cef9c48) conflicted with #4390 only in the
file-parsing.test.ts import list, which keeps excelHeaderText,
parseImportFile and ImportRowRefusal.

Refs #3581
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.

Bug: Excel import mistakes blank first-row cells for missing header columns and rejects valid later rows

1 participant