Use an ASCII separator: ExprTK string literals are byte-wise isprint

"01 · Prior Year" would not parse -- Invalid `expressions` "Bucket": Invalid
string token: 01. ExprTK's string scanner validates each *byte*:

    is_valid_string_char(c) = isprint((unsigned char) c) || is_whitespace(c)

"·" is U+00B7, two bytes 0xC2 0xB7 in UTF-8, and isprint(0xC2) is false in
the C locale. The literal fails at that byte and the parser reports from the
start of it, which is why the message names "01" rather than the character
it actually objected to.

" - " instead, which matches the "07 - Dec" already in the source data rather
than introducing a second convention.

The same rule applies to the label being compared, so a bucket or segment
named with anything outside printable ASCII is left unordered rather than
emitted into an expression that will not parse -- an unparseable expression
loses the whole column, not just that one case.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-09-17 13:36:55 -04:00
parent 916229bdab
commit 8c1f5b8f60
2 changed files with 22 additions and 3 deletions

View File

@ -426,7 +426,7 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
<span key={b} <span key={b}
className="inline-flex items-center gap-1 border border-gray-200 rounded className="inline-flex items-center gap-1 border border-gray-200 rounded
pl-2 pr-1 py-0.5 text-xs bg-white"> pl-2 pr-1 py-0.5 text-xs bg-white">
<span className="text-gray-400 tabular-nums">{String(i + 1).padStart(2, '0')}</span> <span className="text-gray-400 tabular-nums">{String(i + 1).padStart(2, '0')} -</span>
<span className="text-gray-700">{b}</span> <span className="text-gray-700">{b}</span>
<button <button
disabled={i === 0} disabled={i === 0}

View File

@ -50,8 +50,27 @@ function cleanLayout(cfg, validCols) {
// parts, and the mapping is known here anyway. // parts, and the mapping is known here anyway.
const ORDER_EXPR_NAMES = { bucket: 'Bucket', segment: 'Segment' } const ORDER_EXPR_NAMES = { bucket: 'Bucket', segment: 'Segment' }
// ASCII only, and " - " specifically because the source data already reads
// "07 - Dec". A middle dot was the first choice and ExprTK rejects it: its string
// scanner tests each *byte* with
//
// is_valid_string_char(c) = isprint((unsigned char) c) || is_whitespace(c)
//
// and "·" is U+00B7, two bytes 0xC2 0xB7 in UTF-8, of which isprint(0xC2) is
// false in the C locale. The literal fails at that byte and the parser reports
// from the start of it "Invalid string token: 01".
const ORDER_SEP = ' - '
function orderedLabel(ord, label) { function orderedLabel(ord, label) {
return `${String(ord).padStart(2, '0')} · ${label}` return `${String(ord).padStart(2, '0')}${ORDER_SEP}${label}`
}
// Same byte rule applies to the label being matched, so a bucket named with
// anything outside printable ASCII cannot appear in an expression at all. Such a
// label is left unordered rather than emitted into an expression that will not
// parse and take the whole column with it.
function isExprSafe(v) {
return /^[\x20-\x7e]*$/.test(String(v))
} }
function sqlSafe(v) { function sqlSafe(v) {
@ -63,7 +82,7 @@ function sqlSafe(v) {
// than silently landing first. // than silently landing first.
function buildOrderExpression(sourceCol, pairs) { function buildOrderExpression(sourceCol, pairs) {
const cases = pairs const cases = pairs
.filter(([label, ord]) => label && ord > 0) .filter(([label, ord]) => label && ord > 0 && isExprSafe(label))
.sort((a, b) => a[1] - b[1]) .sort((a, b) => a[1] - b[1])
.map(([label, ord]) => .map(([label, ord]) =>
`if ("${sourceCol}" == '${sqlSafe(label)}') { '${sqlSafe(orderedLabel(ord, label))}' }`) `if ("${sourceCol}" == '${sqlSafe(label)}') { '${sqlSafe(orderedLabel(ord, label))}' }`)