183183// There is intentionally NO per-file exemption hatch. No tracked file in this
184184// repo carries a legitimate raw control byte; if one ever genuinely needs to,
185185// that is a decision to take in the open, not a line to add to a skip-list.
186+ //
187+ // ## The character class is written down once (#5646)
188+ //
189+ // The scanned set exists for two audiences: as the IS_SCANNED table below, which
190+ // is what the gate actually scans, and as a PCRE character class in the agent
191+ // instructions that tell an author to self-scan beyond the gate. The second was
192+ // a hand transcription of the first, and that transcription drifted twice in one
193+ // day: #5577 found the self-scan class missing `\x7f` months after #5460 put DEL
194+ // in the table, and #5579 found the harm argument next to it carrying only the
195+ // part that is true of NUL.
196+ //
197+ // #5579 fixed the prose side by making it CITE this header instead of restating
198+ // it. The class itself cannot be handled that way -- an author needs a command
199+ // line they can paste -- so it is handled the other way round: `scannedCharClass()`
200+ // DERIVES the class from the table, and `--self-test` asserts that every
201+ // registered reference spells it byte-for-byte identically. Drift is red at the
202+ // same gate that scans the tree.
203+ //
204+ // Deliberately an assertion and not codegen. The files that carry the class are
205+ // hand-written prompts; generating them would buy the same guarantee at the cost
206+ // of turning agent instructions into build output. An assertion leaves them
207+ // hand-written and merely refuses to let them be WRONG, and it names the exact
208+ // string to paste when they are.
209+ //
210+ // The registered set is an explicit ledger (see CHAR_CLASS_REFERENCES), like the
211+ // repo's other shrink-only gates: a new file spelling the class out has to be
212+ // added to it, and a file that stops spelling it out has to be removed from it
213+ // on purpose. Extraction failure is RED, never a silent skip -- the class
214+ // vanishing from a self-scan command is the same defect as it drifting.
186215
187216import { execFileSync } from 'node:child_process' ;
188217import { lstatSync , mkdirSync , mkdtempSync , readFileSync , rmSync , symlinkSync , writeFileSync } from 'node:fs' ;
189218import { tmpdir } from 'node:os' ;
190219import { dirname , join } from 'node:path' ;
220+ import { fileURLToPath } from 'node:url' ;
191221
192222/**
193223 * The scanned set as a 256-entry lookup: every ASCII control character except
@@ -212,6 +242,83 @@ IS_SCANNED[0x0d] = 0; // CR
212242// include it.
213243IS_SCANNED [ 0x7f ] = 1 ;
214244
245+ /**
246+ * The scanned set as a PCRE character class, e.g. the argument of
247+ * `grep -naP '<class>'`, derived from the table above rather than written out.
248+ *
249+ * This is the spelling for HUMANS and for grep, as opposed to `escapeFor()`,
250+ * which is the JS `\uNNNN` spelling an author should write in source. Both are
251+ * built from byte values, never from a literal: this file is in its own scan
252+ * surface (#5646, #4890).
253+ *
254+ * Runs of three or more bytes collapse to a range; a run of one or two is
255+ * spelled out, because `\x0b-\x0c` is no shorter than `\x0b\x0c` and reads
256+ * worse. That rule is a CONVENTION, not a semantic property -- both spellings
257+ * match the same bytes -- and it is fixed here precisely because the reference
258+ * check below demands byte equality: something has to be canonical, and it is
259+ * this function. When a byte is added or removed, the emitted string changes and
260+ * the self-test prints the new one to paste into every registered reference.
261+ */
262+ export function scannedCharClass ( ) {
263+ const esc = ( byteValue ) => `\\x${ byteValue . toString ( 16 ) . padStart ( 2 , '0' ) } ` ;
264+ let out = '' ;
265+ for ( let b = 0 ; b < 256 ; b ++ ) {
266+ if ( IS_SCANNED [ b ] !== 1 ) continue ;
267+ let end = b ;
268+ while ( end + 1 < 256 && IS_SCANNED [ end + 1 ] === 1 ) end ++ ;
269+ const run = end - b + 1 ;
270+ out += run >= 3 ? `${ esc ( b ) } -${ esc ( end ) } ` : Array . from ( { length : run } , ( _ , i ) => esc ( b + i ) ) . join ( '' ) ;
271+ b = end ;
272+ }
273+ return `[${ out } ]` ;
274+ }
275+
276+ /**
277+ * Every file that spells the scanned set out as a character class, and how to
278+ * find it there. `--self-test` asserts each extracted spelling equals
279+ * `scannedCharClass()` byte for byte (#5646).
280+ *
281+ * `anchor` matches the SURROUNDINGS of the class and captures the class itself.
282+ * It deliberately contains no part of the class: an anchor that did would stop
283+ * matching on exactly the drift it exists to catch, turning a mismatch (which
284+ * reports both spellings) into an extraction failure (which cannot).
285+ *
286+ * Registering a file makes its copy of the class immutable-except-in-step. Not
287+ * registered, on purpose:
288+ *
289+ * - `.changeset/control-byte-gate-scans-*.md` and the published CHANGELOGs.
290+ * Those are HISTORICAL RECORDS of one widening each, and one of them states
291+ * the pre-DEL set correctly for the change it describes. Forcing them to
292+ * equal today's set would falsify the record.
293+ * - The prose ENUMERATIONS of the same bytes -- this header's opening line and
294+ * the `.github/workflows/lint.yml` step comment. Those are sentences, not
295+ * pasteable classes; prose stays on the #5579 footing -- cite this header,
296+ * do not restate it. (Which is why this list names them rather than quoting
297+ * them: a ledger entry that spelled the bytes out would be one more copy.)
298+ * - The non-ASCII guard `[^\x00-\x7f]` quoted in the isLikelyEmail changeset
299+ * and the plugin-auth CHANGELOG: a different regex about input validation,
300+ * unrelated to this set.
301+ */
302+ const CHAR_CLASS_REFERENCES = [
303+ {
304+ file : '.claude/agents/os-dev.md' ,
305+ site : 'the byte-discipline self-scan command line' ,
306+ // The single-quoted PCRE argument of `grep -naP`.
307+ anchor : / g r e p - n a P ' ( [ ^ ' ] * ) ' / g,
308+ } ,
309+ {
310+ file : 'scripts/check-nul-bytes.mjs' ,
311+ site : "this header's own pasteable rendering of the scanned set" ,
312+ // The backticked class on the line after the word "Equivalently".
313+ anchor : / E q u i v a l e n t l y \r ? \n \/ \/ ` ( [ ^ ` ] * ) ` / g,
314+ } ,
315+ ] ;
316+
317+ /** The repo this script lives in -- resolved from the script, so cwd cannot lie. */
318+ function scriptRepoRoot ( ) {
319+ return join ( dirname ( fileURLToPath ( import . meta. url ) ) , '..' ) ;
320+ }
321+
215322/**
216323 * The escape an author should have written for a byte, as TEXT.
217324 * Built from the byte value rather than spelled out, because this file is in its
@@ -428,6 +535,57 @@ byte's name or the escape TEXT -- never the byte itself.`);
428535// runtime from a byte value; none is written as a literal, because this file is
429536// in its own scan surface and a literal would make the guard fail on itself.
430537
538+ /**
539+ * Every registered reference spells the scanned set exactly as this script emits
540+ * it (#5646). Reads the real files in the repo the script lives in -- the point
541+ * is the checked-in text, so there is nothing to fixture.
542+ *
543+ * Three ways this goes red, all of them drift:
544+ * 1. a registered file is unreadable -- de-registering is a decision, not a
545+ * side effect of deleting or renaming;
546+ * 2. the anchor finds nothing -- the class disappearing from a self-scan
547+ * command line leaves the instruction useless, so a silent skip here would
548+ * be the #4690 anti-pattern applied to the gate that exists to stop it;
549+ * 3. an extracted spelling differs from the emitted one, in any byte.
550+ */
551+ function checkCharClassReferences ( root , assert ) {
552+ const canonical = scannedCharClass ( ) ;
553+ assert (
554+ CHAR_CLASS_REFERENCES . length > 0 ,
555+ 'the character-class reference ledger is empty -- with no registered file this check asserts nothing' ,
556+ ) ;
557+
558+ for ( const ref of CHAR_CLASS_REFERENCES ) {
559+ let text = null ;
560+ try {
561+ text = readFileSync ( join ( root , ref . file ) , 'utf8' ) ;
562+ } catch {
563+ // fall through to the assertion below, which reports it as drift
564+ }
565+ assert (
566+ text !== null ,
567+ `${ ref . file } is a registered character-class reference but could not be read -- restore it, or remove it from CHAR_CLASS_REFERENCES on purpose` ,
568+ ) ;
569+ if ( text === null ) continue ;
570+
571+ const found = [ ...text . matchAll ( ref . anchor ) ] . map ( ( m ) => m [ 1 ] ) ;
572+ assert (
573+ found . length > 0 ,
574+ `${ ref . file } : found no character class at the registered anchor (${ ref . site } ). Either the class was removed -- ` +
575+ `then de-register it, since an instruction to self-scan without a class to scan for is worse than none -- or the ` +
576+ `surrounding text moved, in which case update the anchor. Expected to find: ${ canonical } ` ,
577+ ) ;
578+ for ( const spelling of found ) {
579+ assert (
580+ spelling === canonical ,
581+ `${ ref . file } (${ ref . site } ) spells the scanned set as ${ spelling } , but this gate scans ${ canonical } . ` +
582+ `The IS_SCANNED table is authoritative: paste the gate's spelling into the reference (#5577 was this drift, ` +
583+ `a self-scan class missing \\x7f for months after #5460 added DEL to the table).` ,
584+ ) ;
585+ }
586+ }
587+ }
588+
431589function selfTest ( ) {
432590 const failures = [ ] ;
433591 // Counted rather than written down: some assertions run inside a loop, and a
@@ -685,10 +843,28 @@ function selfTest() {
685843 const expectedSet = [ ...Array ( 0x20 ) . keys ( ) ] . filter ( ( b ) => b !== 0x09 && b !== 0x0a && b !== 0x0d ) . concat ( 0x7f ) ;
686844 assert ( scannedSet . join ( ) === expectedSet . join ( ) , `the scanned set is C0-minus-tab/LF/CR plus DEL, got ${ scannedSet . length } bytes` ) ;
687845 assert ( IS_SCANNED [ 0x20 ] === 0 && IS_SCANNED [ 0x7e ] === 0 , 'printable ASCII is never scanned' ) ;
846+
847+ // The emitted character class is what the reference check below compares
848+ // against, so it has to be right as a REGEX and not merely stable as a
849+ // string: compiled and run over all 256 byte values, it must select exactly
850+ // the table's set. Without this, a broken emitter would happily hold every
851+ // reference file byte-equal to a class that scans the wrong bytes.
852+ const emittedClass = new RegExp ( scannedCharClass ( ) ) ;
853+ const emittedSet = [ ...Array ( 256 ) . keys ( ) ] . filter ( ( b ) => emittedClass . test ( String . fromCharCode ( b ) ) ) ;
854+ assert (
855+ emittedSet . join ( ) === scannedSet . join ( ) ,
856+ `the emitted class ${ scannedCharClass ( ) } matches exactly the scanned set, got ${ emittedSet . length } of ${ scannedSet . length } bytes` ,
857+ ) ;
688858 } finally {
689859 rmSync ( dir , { recursive : true , force : true } ) ;
690860 }
691861
862+ // ── #5646: the class is transcribed nowhere ────────────────────────────────
863+ //
864+ // Not a temp-repo fixture: the subject is the checked-in text of this repo's
865+ // own instruction files, which is exactly what drifted in #5577.
866+ checkCharClassReferences ( scriptRepoRoot ( ) , assert ) ;
867+
692868 if ( failures . length ) {
693869 console . error ( `✗ check-nul-bytes --self-test -- ${ failures . length } failure(s)\n` ) ;
694870 for ( const f of failures ) console . error ( ` • ${ f } ` ) ;
0 commit comments