doxa-maps/internal/check-csv-export-shape.mjs
Doxa Maps 15820f1432 Dashboard kit, landing 3: the camera and the CSV exporter
Both are zero-domain in the sense the spec meant: they import only `vue` and read
rows through accessors. Bodies are what research-dashboard shipped apart from the
two parameters the spec named and one policy object, and the old paths re-export
for one cycle.

★ THE FIRST STEP FOUND TWO GATES THAT READ THESE FILES BY PATH, which is why it is
now the first step. Landing 1 nearly disarmed check-facet-cascade the same way.
  internal/check-csv-export-shape.mjs does not import useCsvExport; it READS the
  source and extracts esc() out of it. Left at the plugin path it would have read
  the one-line shim, found no esc(), and failed with "could not extract esc()" — a
  gate accusing a working exporter because its subject moved.
  check-filter-multivalue.mjs — MINE — lists the same file in its PANELS array.
  Left at the plugin path it would have read the shim, found no predicate to
  object to, and PASSED VACUOUSLY. That is worse: a gate going quiet about the
  thing it guards. Both move in this commit.

THE COORDINATE IS A PARAMETER; WHAT MAKES A COORDINATE VALID IS NOT. `coords`
replaces only the READ of row.longitude / row.latitude, which is pray-tools'
spelling and not a universal one. Every guard stays shared: Number() on API
strings, 0 accepted per axis, the exact 0,0 null-island sentinel rejected, the
out-of-range check. A kit that let each caller re-implement that validation would
hand the next dashboard the same four bugs to rediscover.

★ AND THE OPTION WAS DEAD WHEN I FIRST WROTE IT. `coords` was accepted in the
signature and never passed to selectionBounds, so a caller could set it,
type-check clean, build clean, and watch it do nothing. This tree has paid for
that shape before — a defineProps on a component the custom element never binds —
and it is worse than a missing feature because it READS as supported. Nobody files
a bug against a knob they assume they misused. check:kit-options-reachable now
asserts, across all four shared composables, that every option accepted is used.

THE CAMERA'S EIGHT CONSTANTS ARE ONE DECISION, so CAMERA_POLICY exports them
together. The failure that prevents is a second dashboard re-picking them one at a
time and arriving at a camera that feels different from Big Picture's for no reason
anybody wrote down. It is read-only and deliberately not yet overridable: making
each a per-caller option means threading eight of them through every use, and no
consumer has asked for a different camera. Exporting the decision so it can be
cited is the half with a use today; saying so is better than shipping eight
options nobody passes.

THE EXPORTER GAINS exportLabel and sources. "Research Dashboard" was this
dashboard's name, and the provenance line said nothing about where the data came
from — which is what a provenance line is for. Both default to today's output, to
the byte.

★ AND THE NEW GATE HAD THE COMMENT HOLE TWICE IN ONE SITTING. First it searched
the raw body, and the comment beside the fixed call site says "`coords` IS PASSED
HERE" — so the mutant that removed the argument left it green. Then, stripping
comments only per-part, it split the signature's comment PROSE on commas and
reported that useCsvExport accepts `and`. Comments are text; every text search has
to say so, in both directions. That is the fourth variant of one mistake this
week, and all four were caught by running the mutant rather than by reading.

Library index regenerated in the same commit, 103 items. Source only.
STUDIO: needs a rebuild of research-dashboard and dashboard-plus-grid. The
internal/ hunk is one line in check-csv-export-shape.mjs.
2026-09-27 01:16:52 +00:00

179 lines
10 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)), '..')
// ★ FOLLOWS THE FILE (dashboard-kit landing 3). This gate does not import useCsvExport — it READS
// the source and extracts esc() out of it. Left pointing at the plugin path after the move it would
// have read the one-line re-export shim, found no esc() to extract, and failed with "could not
// extract esc() from useCsvExport.js" — a gate accusing a working exporter because its subject moved.
// That is the same defect landing 1 nearly shipped with check-facet-cascade's BUS_SRC, which is why
// looking for path-reading gates is now the FIRST step of a landing rather than a discovery during it.
const SRC = join(ROOT, 'library/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.')