Some checks are pending
build-gate / build (push) Waiting to run
Five lanes had 465 insertions across 18 files sitting uncommitted, some for 15 minutes. That is the state that silently lost the shell-scroll fix earlier today: a verified fix existed only in the working tree, a second agent's edit to the same file overwrote it, and the reverted version got committed and described as the fix. Nothing here is finished and no card is being closed on it. This is a snapshot so the work survives an overwrite, taken at a moment when every file had been quiet 15s or more, so no agent was mid-write. Verified coherent before staging rather than committed blind: template-literal, mjs-parse, sfc-parse, no-orphan-gates, kpi-unit, composite-reg, script-flags, quoted-scripts and backlog-status all PASS. check:synced-css fails on PeopleGroupDetail.vue and doxa-simple-map.vue only -- the same two files and the same counts as before the swarm started, both untouched by any agent, both already carded. Two untracked files included because both are load-bearing: internal/ check-facet-one-field.mjs is wired into package.json with a self-test, and library/utils/viewportScrim.js is imported by DataTable, SemanticTreeLegend and PosterDialog -- a shared helper for the modal-scrim fix rather than six copies of the same workaround.
173 lines
9.5 KiB
JavaScript
173 lines
9.5 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* check-csv-export-shape.mjs — the exported CSV must OPEN correctly in a spreadsheet.
|
|
*
|
|
* THE DEFECT THIS CATCHES (Driver-reported 2026-09-22: "this is me exporting the CSV directly without
|
|
* making any edits... it does look a little bit glitched out"). The generator emitted a one-column
|
|
* provenance comment ABOVE the header:
|
|
* line 0 ... 1 column "# Research Dashboard export — scope: ..."
|
|
* line 1 ... N columns slug,name,religion,...
|
|
* Every spreadsheet importer treats the FIRST line as the header, so the scope sentence became the
|
|
* column names and the real header became data row 1. A 1-column line above a 68-column table is
|
|
* exactly what "glitched" looks like on screen.
|
|
*
|
|
* ★ WHAT WAS *NOT* WRONG, AND MUST NOT BE "FIXED" ON SUSPICION — three plausible theories, all
|
|
* refuted by measurement before a line was changed:
|
|
* - "it is tab-separated" — FALSE. The generator joins with commas; the tabs in the Driver's paste
|
|
* were his terminal rendering, not the bytes.
|
|
* - "quoting is broken" — FALSE. esc() is genuinely RFC-4180: it quotes when , " CR or LF appear and
|
|
* doubles internal quotes. Embedded commas, quotes AND newlines survive a strict parser round trip.
|
|
* - "CSV injection" — already handled; a leading = + - @ is prefixed with a single quote.
|
|
* Only the POSITION was wrong. Chasing the other three would have produced churn and no fix.
|
|
*
|
|
* ★ WHY NO EXISTING GATE SAW IT. 46 gate-ish scripts exist and not one parses the export. The bug is
|
|
* invisible to every DOM check (the button works, the file downloads, the bytes are valid UTF-8) and
|
|
* only appears when something tries to READ the file as a table. So this gate parses.
|
|
*
|
|
* WHAT IT ASSERTS, against the real buildCsv() logic extracted from the composable:
|
|
* 1. line 0 is the HEADER — not a comment, not data;
|
|
* 2. every data row has exactly the header's column count;
|
|
* 3. a field containing a comma, a double quote or a newline survives a strict RFC-4180 round trip;
|
|
* 4. the provenance comment still EXISTS (it is load-bearing: it records which filters produced the
|
|
* file) and sits at the END, where a trailing 1-column row is harmless.
|
|
*
|
|
* ★ THE NEGATIVE TEST IS RUNNABLE, NOT A CLAIM IN A COMMENT: `--self-test`.
|
|
* This header used to assert "NEGATIVE-TESTED" with no way to re-run it, which is the one thing
|
|
* this repo has learned to distrust — a gate that cannot fail is worse than no gate, and a gate
|
|
* whose negative test exists only in prose is indistinguishable from one that never had one.
|
|
* `node internal/check-csv-export-shape.mjs --self-test` re-runs every assertion below against a
|
|
* DELIBERATELY BROKEN assembly (provenance hoisted back to line 0) held IN MEMORY — the shipped
|
|
* source file is never written to — and exits 0 only if the gate correctly REJECTED it.
|
|
*/
|
|
import { readFileSync, existsSync } from 'node:fs'
|
|
import { resolve, dirname, join } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
|
const SRC = join(ROOT, 'plugin-designs/research-dashboard/src/composables/useCsvExport.js')
|
|
|
|
if (!existsSync(SRC)) {
|
|
console.error('CSV-SHAPE: FAIL — useCsvExport.js not found at ' + SRC.replace(ROOT + '/', ''))
|
|
process.exit(1)
|
|
}
|
|
const src = readFileSync(SRC, 'utf8')
|
|
|
|
// Extract the REAL esc() rather than re-implementing it — a gate that restates the rule can agree
|
|
// with itself while the product disagrees.
|
|
const escMatch = src.match(/function esc\(v\)\s*\{([\s\S]*?)\n \}/)
|
|
if (!escMatch) {
|
|
console.error('CSV-SHAPE: FAIL — could not extract esc() from useCsvExport.js.')
|
|
console.error(' The gate must test the SHIPPED escaper, not a copy of the rule.')
|
|
process.exit(1)
|
|
}
|
|
let esc
|
|
try { esc = new Function('v', escMatch[1]) }
|
|
catch (e) { console.error('CSV-SHAPE: FAIL — extracted esc() does not parse: ' + e.message); process.exit(1) }
|
|
|
|
// Extract the line-assembly order, which is the thing that actually broke.
|
|
const linesMatch = src.match(/const lines = \[([^\]]*)\]/)
|
|
if (!linesMatch) {
|
|
console.error('CSV-SHAPE: FAIL — could not find the `const lines = [...]` assembly in buildCsv().')
|
|
process.exit(1)
|
|
}
|
|
const order = linesMatch[1].replace(/\s+/g, ' ').trim()
|
|
// --self-test FORCES the broken order in memory, so every assertion below runs against a file
|
|
// that is known-bad. The shipped source is read-only here and is never modified on disk.
|
|
const SELF_TEST = process.argv.includes('--self-test')
|
|
const provenanceFirst = SELF_TEST || /^\s*provenance\s*,/.test(order)
|
|
|
|
const cols = ['slug', 'name', 'religion', 'imb_people_description']
|
|
const rows = [
|
|
{ slug: 'alas', name: 'Alas', religion: 'Islam - Sunni',
|
|
imb_people_description: 'The Alas of Indonesia, numbering approximately 268,000 people. They say "hello".' },
|
|
{ slug: 'kluet', name: 'Kluet', religion: 'Islam - Sunni',
|
|
imb_people_description: 'Line one\nLine two, with a comma' },
|
|
{ slug: 'inj', name: '=SUM(A1)', religion: 'Islam - Sunni', imb_people_description: 'injection probe' },
|
|
]
|
|
const provenance = '# Research Dashboard export — scope: filter result; filters: religion=Islam - Sunni; rows: 3'
|
|
const body = [cols.map(esc).join(','), ...rows.map((r) => cols.map((c) => esc(r[c])).join(','))]
|
|
const csv = (provenanceFirst ? [provenance, ...body] : [...body, provenance]).join('\r\n')
|
|
|
|
// Strict RFC-4180 parser — the only honest way to judge "does this open as a table".
|
|
function parse(t) {
|
|
const out = []; let row = [], f = '', q = false
|
|
for (let i = 0; i < t.length; i++) {
|
|
const c = t[i]
|
|
if (q) { if (c === '"') { if (t[i + 1] === '"') { f += '"'; i++ } else q = false } else f += c }
|
|
else if (c === '"') q = true
|
|
else if (c === ',') { row.push(f); f = '' }
|
|
else if (c === '\r' && t[i + 1] === '\n') { row.push(f); out.push(row); row = []; f = ''; i++ }
|
|
else f += c
|
|
}
|
|
if (f !== '' || row.length) { row.push(f); out.push(row) }
|
|
return out
|
|
}
|
|
|
|
const parsed = parse(csv)
|
|
const fail = (msg, detail) => {
|
|
// Under --self-test a rejection is the DESIRED outcome: the gate just proved it can fail.
|
|
if (SELF_TEST) {
|
|
console.log('CSV-SHAPE SELF-TEST: PASS — the gate REJECTED the deliberately broken export.')
|
|
console.log(' It said: ' + msg)
|
|
console.log(' So a real regression of this shape would be caught, not waved through.')
|
|
process.exit(0)
|
|
}
|
|
console.error('CSV-SHAPE: FAIL — ' + msg)
|
|
if (detail) console.error(detail)
|
|
process.exit(1)
|
|
}
|
|
|
|
// 1 — line 0 must be the header
|
|
const first = parsed[0] || []
|
|
if (first.length === 1 && String(first[0]).startsWith('#')) {
|
|
fail('line 0 is a one-column "#" comment, so a spreadsheet will use it AS THE HEADER.',
|
|
' The scope sentence becomes the column names and the real header becomes data row 1 — which is\n' +
|
|
' what the Driver saw as a "glitched" export. Put the provenance line LAST; a trailing one-column\n' +
|
|
' row is harmless, a leading one destroys the column mapping for the whole sheet.')
|
|
}
|
|
if (first[0] !== cols[0]) {
|
|
fail(`line 0 does not start with the first column ("${cols[0]}") — got "${String(first[0]).slice(0, 40)}".`)
|
|
}
|
|
|
|
// 2 — every data row matches the header width
|
|
const dataRows = parsed.slice(1).filter((r) => !(r.length === 1 && String(r[0]).startsWith('#')))
|
|
const wrong = dataRows.filter((r) => r.length !== first.length)
|
|
if (wrong.length) {
|
|
fail(`${wrong.length} of ${dataRows.length} data row(s) do not have the header's ${first.length} columns.`,
|
|
' A field containing a comma, a quote or a newline is not being quoted, so it shifts every later\n' +
|
|
' column on that row. esc() must quote when , " CR or LF appear and double the internal quotes.')
|
|
}
|
|
|
|
// 3 — the hard fields survived intact
|
|
const desc = dataRows[1] && dataRows[1][3]
|
|
if (!desc || !desc.includes('\n') || !desc.includes(',')) {
|
|
fail('a field containing BOTH a newline and a comma did not survive the round trip.',
|
|
' Round-tripped value: ' + JSON.stringify(String(desc).slice(0, 60)))
|
|
}
|
|
const quoted = dataRows[0] && dataRows[0][3]
|
|
if (!quoted || !quoted.includes('"hello"')) {
|
|
fail('an embedded double quote did not survive the round trip.',
|
|
' Round-tripped value: ' + JSON.stringify(String(quoted).slice(0, 60)))
|
|
}
|
|
|
|
// 4 — provenance still exists, and is last
|
|
const last = parsed[parsed.length - 1] || []
|
|
if (!(last.length === 1 && String(last[0]).startsWith('#'))) {
|
|
fail('the provenance comment is missing from the end of the file.',
|
|
' It is load-bearing: it records WHICH FILTERS produced this file, which is the difference\n' +
|
|
' between a dataset and an anecdote. Keep it — just keep it last.')
|
|
}
|
|
|
|
// Reaching here under --self-test means the gate WAVED THROUGH a known-broken export.
|
|
if (SELF_TEST) {
|
|
console.error('CSV-SHAPE SELF-TEST: FAIL — the gate PASSED a deliberately broken export.')
|
|
console.error(' The provenance comment was forced back onto line 0 and no assertion objected, so')
|
|
console.error(' this gate cannot catch the regression it exists to prevent. Fix the gate, not the')
|
|
console.error(' self-test: a check that always passes is worse than no check, because it is trusted.')
|
|
process.exit(1)
|
|
}
|
|
|
|
console.log(`CSV-SHAPE: PASS — header on line 0 (${first.length} cols), ${dataRows.length} data row(s) all matching,`)
|
|
console.log(' embedded comma/quote/newline round-trip intact, provenance preserved as a trailing comment.')
|
|
console.log(' This parses the export as a spreadsheet would; no DOM check can see this defect, because')
|
|
console.log(' the button works, the file downloads, and the bytes are valid — only the shape is wrong.')
|