diff --git a/.changeset/control-byte-gate-scans-del.md b/.changeset/control-byte-gate-scans-del.md new file mode 100644 index 0000000000..5a2da9497b --- /dev/null +++ b/.changeset/control-byte-gate-scans-del.md @@ -0,0 +1,22 @@ +--- +--- + +chore(scripts): `check:nul-bytes` 的扫描面纳入 DEL(0x7f),并转义 login.ts / register.ts 各一枚裸字节 (#5460) + +#5157 把这道门禁的扫描面从 NUL 扩到「C0 控制字符集去掉 tab/LF/CR」。**DEL(0x7f)不在那个区间里** —— 它不是 C0 控制符,而是单独坐在 ASCII 表末尾、印刷字符区之后的一枚控制字符,所以一个用连续区间表达的集合根本够不到它。 + +这个缺口是偶然而非深思熟虑,证据是剩下那两枚裸字节被发现的位置:#5157 转义了 CLI 密码输入里的一枚裸 0x03,而在 `login.ts` 和 `register.ts` **同一个 switch 里、往下九行**,各躺着一枚当 Backspace 键值的裸 0x7f,原封不动。转义后的 case 与仍是裸字节的 case 并排,前者读得出是个按键,后者渲染为空、读起来是 `case '':` —— 一个空串 case;区分二者的只有 ASCII 当年把这个字节放在了 0x1f 的哪一侧。 + +#5157 论证 C0 的三条危害,逐条原样落在 0x7f 上:同样渲染为空、同样两种拼写都搜不到(既 grep 不到 `\u007f` 这段文本,也没法把那个字节敲进搜索框)、同样出自「作者正在写关于这个字节的内容时,编辑工具把转义落成真字节」这一事故源 —— 而**该事故源不挑字节值**。本单 issue 正文的第一版自己就踩了这个坑(写着写着落进两枚真的 0x03),实施本 PR 的过程中又复现两次:一次落进临时脚本,一次落进本脚本头部那段正在描述该事故的注释里。多数语言对「控制字符」的定义(C 的 `iscntrl`、正则的 `\p{Cc}` 类)也都把 0x7f 算进去,「C0」是个更窄的读法。 + +扫描面现在是 `[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]`。 + +**二进制判据同步扩面,而且这一步同样是承重的。** 判据是「剔除全部扫描字节后整文件 UTF-8 严格解码」,现在 0x7f 也在剔除之列。理由与 #5157 对 0x01 的论证同构:`E4 B8 7F AD` 是「中」被塞进一枚 0x7f,只剔 C0 的话它解码失败 → 文件被判二进制 → 跳过 → **那枚 0x7f 成了自己的不在场证明**。自测里把这条钉住了:回退扩面后,该样本文件立刻从「文本」掉进「二进制」(断言 `exactly the 2 binary assets skip` 报 `got 3`)。反方向不会出错:扫描集全部 `<= 0x7f`,而合法 UTF-8 多字节序列只由 `>= 0x80` 的字节构成。 + +**实测全部 5456 个受追踪常规文件:扩面前后文本/二进制判定零变化**(仍是 4 个 PNG + 1 个 ICO 跳过)。那 4 个 PNG 和 1 个 ICO 里本就含大量裸 0x7f(其中一个 1317 枚),不受影响 —— 它们是整文件解码失败才判的二进制,与任何单个字节无关。 + +**行为不变,不发版。** `'\u007f'` 与那枚裸字节在运行时逐字节相同,两个 switch 的 Backspace 分支照旧命中。 + +`--self-test` 断言数 34 → 48。新增断言把两个方向都钉在代码旁边:正向(裸 0x7f 判红、报错报出 0x7f 与 `\u007f` 处方、多字节序列里的 0x7f 判红),反向(两个新样本**整个文件都不含 C0 字节**,所以前一版 C0-only 的扫描确实无事可做),外加一条「转义后的拼写保持绿」—— 门禁教作者写转义,那这条处方本身必须可测,否则只证明了什么会被拒、没证明照做会被接受。原先那条**故意**把 DEL 钉在扫描面之外的断言(`'tab / CR / LF / DEL are outside the scanned set and stay green'`)按裁定反转:它当年是用来说明这条边界是选出来的而不是漏掉的,这次边界被重新选择,选到了另一边。 + +脚本名与 `pnpm check:nul-bytes` 命令名依旧不变(理由见脚本头);语义变化仍写在脚本头、报错文案和 CI 步骤三处。工具链改动,不发版。 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index a599cfc5c1..7d485f78f4 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -76,9 +76,10 @@ jobs: - name: Slot-lookup ratchet run: pnpm check:slot-lookup - # Raw control-byte guard (#3127 / #4890 / #5157). Scans every tracked TEXT - # file for a raw C0 control byte — 0x00-0x08, 0x0b, 0x0c, 0x0e-0x1f, i.e. - # everything except tab/LF/CR. Two distinct harms, one gate: + # Raw control-byte guard (#3127 / #4890 / #5157 / #5460). Scans every + # tracked TEXT file for a raw ASCII control byte — 0x00-0x08, 0x0b, 0x0c, + # 0x0e-0x1f and 0x7f, i.e. everything except tab/LF/CR. Two distinct + # harms, one gate: # • A literal U+0000 makes grep/ripgrep treat the whole file as binary and # silently return ZERO matches — the file drops out of code search and # out of every grep-based lint, with no error saying so. Nothing else @@ -92,6 +93,13 @@ jobs: # Four tracked source files carried those past the NUL-only gate until # #5157 widened the scan surface; PR #5140 is the case that found it, # when a 0x01 sitting 14 bytes from a caught NUL went unfixed. + # • DEL (0x7f) is in the set for the same reason, added by #5460. It is + # not a C0 control — it sits alone past the printable range — so the + # C0-shaped set could not reach it, and two raw specimens survived in + # the CLI's password prompts nine lines below a 0x03 #5157 had just + # escaped, reading as `case ''`. The set is drawn by the accident + # source (a tool materialising an escape into its byte), and that + # source does not pick byte values. # The command name stays `check:nul-bytes` for continuity — see the script's # header for why. Authors must write the unicode escape instead of the byte. - name: Raw control-byte guard diff --git a/packages/cli/src/commands/login.ts b/packages/cli/src/commands/login.ts index c7da6466c7..d9c4b5e335 100644 --- a/packages/cli/src/commands/login.ts +++ b/packages/cli/src/commands/login.ts @@ -44,7 +44,7 @@ async function promptPassword(promptText: string): Promise { cleanup(); resolve(chars.join('')); break; - case '': // Backspace + case '\u007f': // Backspace if (chars.length > 0) { chars.pop(); process.stdout.clearLine(0); diff --git a/packages/cli/src/commands/register.ts b/packages/cli/src/commands/register.ts index 892ac0356d..a16a282d0a 100644 --- a/packages/cli/src/commands/register.ts +++ b/packages/cli/src/commands/register.ts @@ -40,7 +40,7 @@ async function promptPassword(promptText: string): Promise { cleanup(); resolve(chars.join('')); break; - case '': // Backspace + case '\u007f': // Backspace if (chars.length > 0) { chars.pop(); process.stdout.clearLine(0); diff --git a/scripts/check-nul-bytes.mjs b/scripts/check-nul-bytes.mjs index a6510ad1c9..51a73b446c 100644 --- a/scripts/check-nul-bytes.mjs +++ b/scripts/check-nul-bytes.mjs @@ -1,12 +1,18 @@ #!/usr/bin/env node // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. // -// check-nul-bytes -- rejects raw C0 control bytes in every tracked TEXT file. +// check-nul-bytes -- rejects raw ASCII control bytes in every tracked TEXT file. // -// Scanned set (#5157): 0x00-0x08, 0x0b, 0x0c, 0x0e-0x1f -- the whole C0 control -// range except the three bytes that ARE ordinary text structure: tab (0x09), -// LF (0x0a), CR (0x0d). Equivalently `[\x00-\x08\x0b\x0c\x0e-\x1f]`, the exact -// pattern #4890's own manual sweep used before this gate narrowed to NUL. +// Scanned set (#5157, #5460): 0x00-0x08, 0x0b, 0x0c, 0x0e-0x1f and 0x7f -- every +// ASCII control character except the three bytes that ARE ordinary text +// structure: tab (0x09), LF (0x0a), CR (0x0d). Equivalently +// `[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]`. +// +// #5157 drew that set as "C0 minus tab/LF/CR", matching the pattern #4890's own +// manual sweep used before this gate narrowed to NUL. #5460 added DEL (0x7f), +// which is a control character but NOT a C0 one -- it sits alone at the end of +// the ASCII table, outside any contiguous range, which is precisely how a +// range-shaped set missed it. See "Why DEL is in the set too" below. // // node scripts/check-nul-bytes.mjs // node scripts/check-nul-bytes.mjs --self-test # verify the checker itself @@ -73,6 +79,38 @@ // escape sequence is byte-identical at runtime and is the only spelling that a // reviewer, a grep and a diff can all see. // +// ## Why DEL (0x7f) is in the set too (#5460) +// +// #5157 drew its set as "the C0 controls", and DEL is not one: C0 is 0x00-0x1f, +// and 0x7f sits alone at the far end of the ASCII table. Nothing about that +// numbering is a reason to treat it differently -- it is an artifact of where +// ASCII put the byte, and a set expressed as a contiguous range simply could not +// reach it. +// +// The proof that the gap was arbitrary rather than considered is where the two +// remaining specimens were found. #5157 escaped a raw 0x03 in the CLI's password +// prompt; NINE LINES further down the SAME switch, in both login.ts and +// register.ts, sat a raw 0x7f as the Backspace key literal, untouched: +// +// case '<0x03>': // Ctrl+C <- escaped by #5157; reads as a key +// ... +// case '<0x7f>': // Backspace <- shows as: case '': +// +// One case in a switch reads as a key, the next reads as an empty-string case, +// and the only thing separating them is which side of 0x1f the byte landed on. +// +// Each of the three harms above lands on DEL unchanged: it renders as nothing +// (the `case '':` above), neither spelling can be searched for, and -- the +// decisive one -- the accident source does not pick byte values. Both specimens +// came from the same tool behaviour as #4763 / #4890 / PR #5140. So did the +// FIRST draft of #5460's own issue body, which materialised two real 0x03 bytes +// while describing this very defect; that is the third and fourth recorded +// instance of the source, and it is why the set is drawn by accident source +// rather than by byte semantics. +// +// The wider vocabulary agrees: C's `iscntrl` and the Unicode regex class +// \p{Cc} both count 0x7f as a control character. "C0" was the narrower reading. +// // ## Scope: the carrier, not the use (#4890) // // This guard used to scan JS/TS extensions only, on the theory that a raw NUL @@ -121,8 +159,11 @@ // dropped into the middle of it -- strip only NUL and that decodes as // invalid UTF-8, the file is skipped as binary, and the 0x01 is its own // alibi, one byte value over from the circularity this guard was built to -// break. Widening cannot go wrong in the other direction either: every -// scanned byte is <= 0x1f, while valid UTF-8 multi-byte sequences are built +// break. #5460 extended the same stripping to DEL, and it is load-bearing +// there for exactly the same reason: `E4 B8 7F AD` is 中 with a stray 0x7f +// dropped into it, and a C0-only strip reads that file as binary and hides +// the 0x7f. Widening cannot go wrong in the other direction either: every +// scanned byte is <= 0x7f, while valid UTF-8 multi-byte sequences are built // exclusively from bytes >= 0x80, so removing them can never break an // otherwise-valid sequence. // - The decode reads the ENTIRE file, not a leading window. git's 8000-byte @@ -132,9 +173,12 @@ // A new text file with an extension nobody has seen before therefore gets // scanned by default -- it decodes as UTF-8, so it is text. Only real binary // assets (the repo's 4 PNGs and 1 ICO today) fail rule 3 and drop out. Measured -// over all 5448 tracked paths when #5157 landed: the widened stripping moves -// exactly zero files between text and binary, so it buys the anti-circularity -// property above at no cost in false positives. +// over all 5448 tracked paths when #5157 landed, and again over all 5456 when +// #5460 added DEL: the widened stripping moves exactly zero files between text +// and binary, so it buys the anti-circularity property above at no cost in +// false positives. The 4 PNGs and the ICO carry raw 0x7f bytes in quantity +// (1317 in one of them) and stay binary regardless -- they fail rule 3 on their +// whole-file decode, not on any single byte. // // There is intentionally NO per-file exemption hatch. No tracked file in this // repo carries a legitimate raw control byte; if one ever genuinely needs to, @@ -146,16 +190,27 @@ import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; /** - * The scanned set as a 256-entry lookup: every C0 control except tab, LF and CR. - * A table rather than a regex, because the scan is one pass over BYTES and must - * never have to decode the file first -- a file this gate is interested in is - * precisely one that may not decode cleanly. + * The scanned set as a 256-entry lookup: every ASCII control character except + * tab, LF and CR. A table rather than a regex, because the scan is one pass over + * BYTES and must never have to decode the file first -- a file this gate is + * interested in is precisely one that may not decode cleanly. */ const IS_SCANNED = new Uint8Array(256); for (let b = 0x00; b <= 0x1f; b++) IS_SCANNED[b] = 1; IS_SCANNED[0x09] = 0; // tab -- ordinary text structure IS_SCANNED[0x0a] = 0; // LF IS_SCANNED[0x0d] = 0; // CR +// DEL (#5460). Not a C0 control -- it sits alone at the end of the ASCII table, +// which is exactly why the C0-shaped range above missed it, and why two raw +// specimens sat nine lines from a 0x03 this gate had just made #5157 escape. +// It is in the set because the set is drawn by the ACCIDENT SOURCE, and an +// editing tool materialising an escape into its byte does not pick byte values. +// Every consequence the C0 argument rests on holds for it verbatim: it renders +// as nothing, neither spelling can be searched for, and it can split a +// multi-byte sequence and become its own alibi. The usual definitions of +// "control character" agree -- C's `iscntrl` and the regex class \p{Cc} both +// include it. +IS_SCANNED[0x7f] = 1; /** * The escape an author should have written for a byte, as TEXT. @@ -314,12 +369,12 @@ function main() { const { offenders } = result; if (offenders.length === 0) { - console.log(`check-nul-bytes: OK (${summarise(result)}; no raw C0 control bytes).`); + console.log(`check-nul-bytes: OK (${summarise(result)}; no raw ASCII control bytes).`); process.exit(0); } const plural = offenders.length === 1 ? 'file contains' : 'files contain'; - console.error(`check-nul-bytes: ${offenders.length} ${plural} a raw C0 control byte\n`); + console.error(`check-nul-bytes: ${offenders.length} ${plural} a raw ASCII control byte\n`); for (const o of offenders) { const times = o.count === 1 ? '1 occurrence' : `${o.count} occurrences`; const which = o.bytes.map(hex).join(', '); @@ -333,7 +388,7 @@ function main() { console.error(` The resulting string is byte-identical at runtime, so behaviour does not change. -Why every C0 control byte and not only NUL (#5157): +Why every ASCII control byte and not only NUL (#5157, #5460): • A raw NUL makes grep/ripgrep treat the entire file as binary and silently return ZERO matches, so the file drops out of code search and out of every @@ -347,8 +402,10 @@ Why every C0 control byte and not only NUL (#5157): escape text (the file holds a byte) and not the byte (nobody can type it). • Every occurrence in this repo came from an editing tool materialising an escape into the real byte while someone was writing ABOUT the byte (#4763, - #4890, PR #5140). That slip does not pick byte values, so neither does this - gate. + #4890, PR #5140, and #5460's own issue body). That slip does not pick byte + values, so neither does this gate -- which is why DEL (0x7f) is scanned too + even though it is not a C0 control (#5460): it is the byte the C0-shaped + range could not reach, and it was sitting nine lines from one that was. That harm is not any one language's, so this guard covers every tracked TEXT file -- markdown and agent instructions under .claude/ included (#4890), not @@ -386,6 +443,7 @@ function selfTest() { const NUL = byte(0x00); const SOH = byte(0x01); // the PR #5140 specimen const ETX = byte(0x03); // Ctrl+C, as a CLI key literal + const DEL = byte(0x7f); // Backspace, as a CLI key literal -- the #5460 specimen const dir = mkdtempSync(join(tmpdir(), 'check-nul-bytes-selftest-')); const write = (rel, contents) => { const full = join(dir, rel); @@ -436,6 +494,25 @@ function selfTest() { 'docs/split-sequence.md', Buffer.concat([Buffer.from('head '), byte(0xe4), byte(0xb8), SOH, byte(0xad), Buffer.from(' tail\n')]), ); + // #5460 specimen: a raw DEL as a Backspace key literal, the shape login.ts + // and register.ts both carried nine lines below the 0x03 #5157 escaped. + // Deliberately holds NO C0 byte at all, so the "green before / red after" + // proof below is about DEL and not about some other byte riding along. + write('packages/cli/src/prompt.ts', Buffer.concat([Buffer.from(" case '"), DEL, Buffer.from("': // Backspace\n")])); + // #5460, the anti-circularity case for DEL, mirroring the 0x01 one above: + // E4 B8 AD is 中; with a 0x7f in the middle, stripping only the C0 set + // leaves invalid UTF-8, the file reads as "binary", and the DEL becomes its + // own alibi. Stripping DEL as well is what keeps it visible. + write( + 'docs/split-sequence-del.md', + Buffer.concat([Buffer.from('head '), byte(0xe4), byte(0xb8), DEL, byte(0xad), Buffer.from(' tail\n')]), + ); + // The cure, as a fixture: the same key literal written as the escape TEXT + // stays green. This is the state the two CLI files are left in by #5460, and + // it is what makes the gate's prescription testable rather than merely + // stated -- a red fixture with no green counterpart proves only that + // something is rejected, never that the fix is accepted. + write('packages/cli/src/prompt-fixed.ts', " case '\\u007f': // Backspace\n"); // An extension nobody has seen before must still be scanned -- that is the // property an allow-list cannot have. write('config/weird.frobnicate', Buffer.concat([Buffer.from('key='), NUL, Buffer.from('\n')])); @@ -447,12 +524,11 @@ function selfTest() { write('src/clean.ts', "export const sep = '\\u0000';\n"); write('.github/workflows/ci.yml', 'name: ci\non: [push]\n'); // The three exempt controls are ordinary text structure and must stay green, - // CRLF endings included. DEL (0x7f) rides along: it is not a C0 control, so - // it is deliberately outside this gate's set and must not be flagged either. - write( - 'src/whitespace.ts', - Buffer.concat([Buffer.from('const a\t= 1;\r\nconst b = 2;\r\n'), byte(0x7f), Buffer.from('\n')]), - ); + // CRLF endings included. DEL used to ride along on this fixture, asserted as + // deliberately OUTSIDE the set; #5460 moved it into the set, so it moved out + // of this fixture and into `packages/cli/src/prompt.ts` above, where it is + // now asserted red. Tab / CR / LF are the whole exemption list. + write('src/whitespace.ts', 'const a\t= 1;\r\nconst b = 2;\r\n'); // Real binary assets: a PNG header and an ICO header, both carrying NULs and // other control bytes -- they must stay binary under the WIDENED stripping. write( @@ -509,6 +585,49 @@ function selfTest() { assert(!nulOnlyProbeDecodes, '#5157: NUL-only stripping would misread the split-sequence file as binary'); assert(classify(splitSeq) === 'text', '#5157: widened stripping keeps the split-sequence file scannable'); + // ── #5460: DEL added to the set, proved in both directions ────────────── + // + // Forward -- a raw DEL is flagged, and reported as 0x7f so the prescription + // can name the right escape. + assert(flagged.has('packages/cli/src/prompt.ts'), '#5460: a raw 0x7f Backspace literal must be flagged'); + assert( + flagged.get('packages/cli/src/prompt.ts')?.bytes.join() === String(0x7f), + `#5460: the offending byte is reported as 0x7f, got ${flagged.get('packages/cli/src/prompt.ts')?.bytes}`, + ); + assert(escapeFor(0x7f) === '\\u007f', 'the prescribed escape for DEL is \\u007f'); + assert(hex(0x7f) === '0x7f', 'DEL is reported as 0x7f'); + assert(flagged.has('docs/split-sequence-del.md'), '#5460: a 0x7f inside a multi-byte sequence must be flagged'); + // + // Reverse -- and note WHICH way it runs. #5157's own reverse proof compared + // against the NUL-only gate; the predecessor here is the C0-only gate, so + // the question is whether these fixtures were green under THAT. They were, + // and not by accident of construction: neither contains a single C0 byte, so + // a C0-shaped scan had nothing to find in either. + const c0OnlyGateWouldFlag = (rel) => { + const buf = readFileSync(join(dir, rel)); + return buf.some((b) => b <= 0x1f && b !== 0x09 && b !== 0x0a && b !== 0x0d); + }; + for (const rel of ['packages/cli/src/prompt.ts', 'docs/split-sequence-del.md']) { + assert(!c0OnlyGateWouldFlag(rel), `#5460 reverse: ${rel} carries no C0 byte, so the C0-only gate passed it`); + } + // ...and the DEL anti-circularity fixture would not even have been SCANNED + // before: strip the C0 set only, and `head E4 B8 7F AD tail` fails to decode, + // so the file skips as binary and the 0x7f is its own alibi. + const splitDel = readFileSync(join(dir, 'docs/split-sequence-del.md')); + let c0OnlyProbeDecodes = true; + try { + new TextDecoder('utf8', { fatal: true }).decode(splitDel.filter((b) => b > 0x1f || b === 0x09 || b === 0x0a || b === 0x0d)); + } catch { + c0OnlyProbeDecodes = false; + } + assert(!c0OnlyProbeDecodes, '#5460: C0-only stripping would misread the DEL split-sequence file as binary'); + assert(classify(splitDel) === 'text', '#5460: widened stripping keeps the DEL split-sequence file scannable'); + // + // The cure is green. Escaping is what the gate tells authors to do, so the + // escaped spelling must actually pass -- otherwise the prescription is + // untested and an author who follows it lands in the same red. + assert(!flagged.has('packages/cli/src/prompt-fixed.ts'), '#5460: the \\u007f escape spelling stays green'); + // Which byte it was is reported, so the prescription can name the escape. assert( flagged.get('packages/x/src/key.ts')?.bytes.join() === '1', @@ -531,12 +650,17 @@ function selfTest() { ['docs/clean.md', 'docs/long.md', 'src/clean.ts', '.github/workflows/ci.yml'].every((f) => !flagged.has(f)), 'clean text of every shape stays green', ); - assert(!flagged.has('src/whitespace.ts'), 'tab / CR / LF / DEL are outside the scanned set and stay green'); + // #5460 inverted this one on purpose. It used to read "tab / CR / LF / DEL + // are outside the scanned set and stay green" -- DEL was pinned OUT + // deliberately, to record that the C0 boundary was chosen rather than + // overlooked. The choice was re-made in #5460 and went the other way, so the + // assertion states the new boundary and the DEL half is asserted red above. + assert(!flagged.has('src/whitespace.ts'), 'tab / CR / LF are outside the scanned set and stay green'); assert( !skipped.binary.includes('docs/long.md'), 'a long multi-byte UTF-8 file must not be misread as binary (leading-window truncation)', ); - assert(scanned >= 11, `every text fixture is actually scanned, got ${scanned}`); + assert(scanned >= 14, `every text fixture is actually scanned, got ${scanned}`); // The location report points at the byte, not at byte 0. const skill = flagged.get('.claude/skills/demo/SKILL.md'); @@ -549,9 +673,18 @@ function selfTest() { // classify() is the criterion; state it directly too. assert(classify(Buffer.concat([Buffer.from('plain text'), NUL])) === 'text', 'a NUL alone never makes a file binary'); assert(classify(Buffer.concat([Buffer.from('plain text'), SOH])) === 'text', 'a 0x01 alone never makes a file binary'); + assert(classify(Buffer.concat([Buffer.from('plain text'), DEL])) === 'text', 'a 0x7f alone never makes a file binary'); assert(classify(Buffer.from([0xc0, 0x80, 0x41, 0xf8])) === 'binary', 'invalid UTF-8 is binary'); assert(classify(Buffer.from('')) === 'text', 'an empty file is text'); assert(findControlBytes(Buffer.from('a\tb\r\nc\n')).length === 0, 'tab / CR / LF are not control-byte hits'); + assert(findControlBytes(Buffer.concat([Buffer.from('a'), DEL])).join() === '1', 'DEL is a control-byte hit (#5460)'); + // The set is exactly ASCII's controls minus the three text-structure ones -- + // stated as a whole so a future edit to the table has to face the boundary + // rather than nudge it. 0x20 (space) and 0x7e (~) bracket the printable run. + const scannedSet = [...Array(256).keys()].filter((b) => IS_SCANNED[b] === 1); + const expectedSet = [...Array(0x20).keys()].filter((b) => b !== 0x09 && b !== 0x0a && b !== 0x0d).concat(0x7f); + assert(scannedSet.join() === expectedSet.join(), `the scanned set is C0-minus-tab/LF/CR plus DEL, got ${scannedSet.length} bytes`); + assert(IS_SCANNED[0x20] === 0 && IS_SCANNED[0x7e] === 0, 'printable ASCII is never scanned'); } finally { rmSync(dir, { recursive: true, force: true }); }