Skip to content

Commit 52daddb

Browse files
os-zhuangclaude
andauthored
test(devx): check-nul-bytes 的 --self-test 断言字符类引用面逐字节相等 (#5680)
字符类此前在两处手抄:IS_SCANNED 表(门禁真正扫的东西)与 os-dev.md 自扫命令行。该族漂移当日已付两次(#5577 自扫类缺 \x7f、#5579 危害论证 只搬了对 NUL 成立的那条)。 scannedCharClass() 从表派生出 PCRE 字符类,--self-test 断言登记在 CHAR_CLASS_REFERENCES 里的每处引用与之逐字节相等;提取不到也红。 不做散文 codegen:引用文件是手写 prompt,断言只拒绝它们「错」,并在 失败信息里给出该粘贴的确切字符串。 Co-authored-by: Claude <noreply@anthropic.com>
1 parent c36abfe commit 52daddb

1 file changed

Lines changed: 176 additions & 0 deletions

File tree

scripts/check-nul-bytes.mjs

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,11 +183,41 @@
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

187216
import { execFileSync } from 'node:child_process';
188217
import { lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
189218
import { tmpdir } from 'node:os';
190219
import { 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.
213243
IS_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: /grep -naP '([^']*)'/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: /Equivalently\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+
431589
function 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

Comments
 (0)