From 4e98577ae280d670eef4ab858d71131955b100b7 Mon Sep 17 00:00:00 2001 From: Error-Zhang Date: Sun, 16 Aug 2026 23:57:14 +0800 Subject: [PATCH 1/2] fix: four gates that could not have caught what they were named for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each of these reported nothing and was read as finding nothing. The CI acceptance test is called "CI covers platform, browser, and non-publishing provenance gates" and asserted exactly one gate's invocation. Deleting `npm run check` from the workflow left it green. The gate roster is derived from the gate:* script keys now, the argument-forwarding entry point is excluded by the structural property that it ends in `--` rather than by name, and the excluded set is pinned so a real gate that starts forwarding arguments fails instead of quietly dropping out. The workflow reader throws on anchors, aliases, tags and flow collections rather than misreading them — a reader that guesses is the same failure one layer down. The runtime-boundary gate scraped exports with two regexes under a comment claiming nothing else enforced this. It could not see `export var` or `export function*`, and the second is this repository's own house style. It reads the export grammar off a token scan now, and every form it cannot read fails the gate instead of passing as a module with no exports. It agrees with the old regexes on all 46 real surfaces, so nothing was escaping today. Two adjacent holes in that gate mattered more than the regex. It identified a surface by name plus byte length, and padding Desktop's velar/fs to Node's exact length made the old key drop a surface silently — 35 where there are 36 — and pass green over a planted leak. And it skipped ten interface-less module sources with a bare continue. The wave's first rule for those was that internal modules may export only __velar names; that produced 77 failures because the rule is false, and it was discarded rather than forced. What replaced it is true: those sources sit outside the checked namespace, so each is accounted for against the compiler's own declared internal-module identities, in both directions. The same gate printed a count of runtime boundary operations that counted rows in a Markdown table, bound to nothing — appending a row raised it. Of those 77 rows, 73 name their proof in prose that resolves to no artifact and 50 are cited nowhere else in the repository, so binding them is a rewrite of the ledger rather than a repair of the gate. The number is gone instead of dressed up, and a test now asserts every number the summary prints is one the gate computed. Release sentinels are derived from the build roster, cross-checked by a second independent route so that replacing the derivation with a list fails loudly. The fifth site is the one nobody would have looked for: two tracked source files carried literal NUL bytes, so grep classified them as binary and returned nothing from them. One is the 131 KB boundary gate itself. A tool reporting nothing reads exactly like a tool finding nothing, which is how a file quietly stops being reviewed. The bytes are escapes now, with a test that decides text-ness without reference to the byte under test, because deciding it the way grep does would make the assertion vacuous. All four gates green: check=0, test=0 (1465/1465), test:packages=0, test:browser=0. Co-Authored-By: Claude Opus 5 --- packages/compiler/src/mechanical-fix.ts | Bin 3630 -> 3632 bytes scripts/check-runtime-boundary.mjs | 113 ++++- scripts/es-module-exports.d.mts | 16 + scripts/es-module-exports.mjs | 535 ++++++++++++++++++++++++ tests/ci.acceptance.ts | 153 ++++++- tests/package-contract.test.ts | 41 ++ tests/release.acceptance.ts | 28 +- tests/runtime-boundary.test.ts | 85 ++++ 8 files changed, 940 insertions(+), 31 deletions(-) create mode 100644 scripts/es-module-exports.d.mts create mode 100644 scripts/es-module-exports.mjs diff --git a/packages/compiler/src/mechanical-fix.ts b/packages/compiler/src/mechanical-fix.ts index 34af7389414dfc82397ede8d845ac33615732640..32ec77952572a74f7968d3dd8e09b639a714c466 100644 GIT binary patch delta 22 ccmZ1{vq5G<4;xdA!Q@`HNFcTOIh#8>09uj 0) { console.error(failures.join("\n")); process.exitCode = 1; } else { - console.log(`Checked ${ids.size} runtime boundary operations, ${standardModulesAudited} standard module surfaces, and the shared registry, strict JSON, Web DOM, host-event, browser-platform, storage-host, and Desktop-host ABIs`); + // This line used to open with `Checked ${ids.size} runtime boundary + // operations`, counting rows of a Markdown table that no check in this file + // is connected to: appending a row to `docs/contributing/runtime-boundary.md` + // raised the number with nothing behind it. Binding each row to a check is + // not available from here — 73 of the 77 rows name their proof in prose that + // resolves to no artifact, and 50 are cited nowhere outside the ledger — so + // making that number mean something is a change to the ledger, not to this + // gate. Until it means something it is not reported: a number nothing + // supports claims more coverage than no number at all. + console.log(`Checked ${publicModuleSurfaces} standard module surfaces, ${internalModuleSurfaces} internal runtime module surfaces,` + + ` the boundary ledger's structure, and the shared registry, strict JSON, Web DOM, host-event, browser-platform, storage-host,` + + ` and Desktop-host ABIs`); } async function sourceFiles(directory) { diff --git a/scripts/es-module-exports.d.mts b/scripts/es-module-exports.d.mts new file mode 100644 index 00000000..5c70ff5f --- /dev/null +++ b/scripts/es-module-exports.d.mts @@ -0,0 +1,16 @@ +/** One export form the scanner could not read, with the text that defeated it. */ +export interface EsModuleExportProblem { + readonly reason: string; + readonly text: string; +} + +/** What a module publishes, and what could not be read while finding out. */ +export interface EsModuleExportSurface { + /** Every name the module exports, in source order. */ + readonly names: string[]; + /** Export forms outside this scanner's boundary. A caller must fail on these. */ + readonly unreadable: EsModuleExportProblem[]; +} + +/** The names `source` exports, read from its export syntax rather than matched. */ +export function esModuleExports(source: string): EsModuleExportSurface; diff --git a/scripts/es-module-exports.mjs b/scripts/es-module-exports.mjs new file mode 100644 index 00000000..a1c44fd1 --- /dev/null +++ b/scripts/es-module-exports.mjs @@ -0,0 +1,535 @@ +/** + * The names an ES module publishes, read from the module's own export syntax. + * + * `scripts/check-runtime-boundary.mjs` used to answer this question with two + * regular expressions — `^export (async )?(function|const|let|class) NAME` and + * `^export {...}` — under a comment claiming that nothing else enforced D57 + * rule 140 and that this did. What those two patterns could not see was not a + * short list: `export var`, `export function*` (the spelling this repository's + * own `packages/compiler/src/ast.ts` uses), `export const {a, b} = ...`, + * `export default`, `export * as ns from ...`, and any export that was not + * flush against column zero. Every one of them would have published a name the + * gate reported as absent, which is the A-002/A-022 shape again: a regular + * expression standing in for a grammar, passing green on the forms it happens + * not to meet. + * + * This reads the export forms instead. It is deliberately not a JavaScript + * parser — it tokenizes far enough to know where a string, comment, template, + * or regular expression is and how deeply nested a token sits, and then reads + * the export grammar off the token stream. + * + * The boundary, and it is loud rather than silent everywhere it is reached: + * + * - Every form it cannot read is reported through `unreadable` and the caller + * fails on it. Nothing is skipped quietly. + * - `export * from "..."` cannot be enumerated without resolving the target + * module, so it is reported rather than assumed empty. + * - An exported variable declaration must end in `;`. Automatic semicolon + * insertion would need expression-level parsing to locate the end of an + * initializer, and guessing wrong would silently drop the second declarator + * of `export const a = f(), b = g()`. Reaching the end of the module without + * the terminator is reported. + * - A `/` that begins a regular expression is told apart from division by the + * preceding token, the usual heuristic. If the tokenizer is nevertheless + * misled, nesting no longer balances at the end of the module, and that is + * reported too — a module whose structure could not be read never passes as + * a module with no exports. + */ + +/** @typedef {{ kind: string, value: string, depth: number, start: number }} Token */ +/** @typedef {{ reason: string, text: string }} Problem */ + +// A `/` after one of these keywords starts a regular expression; after any +// other name it is division, because that name is a value. +const KEYWORDS_BEFORE_REGEX = new Set([ + "await", "case", "delete", "do", "else", "in", "instanceof", "new", "of", + "return", "throw", "typeof", "void", "yield", +]); + +/** + * The names `source` exports, plus every export form this scanner could not + * read. A caller that ignores `unreadable` has re-created the defect. + * + * @param {string} source + * @returns {{ names: string[], unreadable: Problem[] }} + */ +export function esModuleExports(source) { + /** @type {Problem[]} */ + const unreadable = []; + const tokens = tokenize(source, unreadable); + /** @type {string[]} */ + const names = []; + const snippet = (index) => { + const token = tokens[index]; + if (token === undefined) return ""; + return source.slice(token.start, token.start + 72).split("\n")[0] ?? ""; + }; + const report = (reason, index) => unreadable.push({ reason, text: snippet(index) }); + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index]; + if (token.kind !== "name" || token.value !== "export" || token.depth !== 0) continue; + // `object.export` and `object?.export` are member reads, not declarations. + const previous = tokens[index - 1]; + if (previous !== undefined && previous.kind === "punct" && previous.value === ".") continue; + readExportClause(tokens, index + 1, names, report); + } + return { names, unreadable }; +} + +/** + * @param {Token[]} tokens + * @param {number} at index of the token after `export` + * @param {string[]} names + * @param {(reason: string, index: number) => void} report + */ +function readExportClause(tokens, at, names, report) { + const token = tokens[at]; + if (token === undefined) { + report("the module ends immediately after 'export'", at - 1); + return; + } + if (token.kind === "punct" && token.value === "*") { + const next = tokens[at + 1]; + const alias = tokens[at + 2]; + if (next?.kind === "name" && next.value === "as" && (alias?.kind === "name" || alias?.kind === "string")) { + names.push(alias.kind === "string" ? stringValue(alias.value) : alias.value); + return; + } + report("a star re-export publishes names that cannot be enumerated without resolving the module it names", at - 1); + return; + } + if (token.kind === "punct" && token.value === "{") { + readNamedExports(tokens, at, names, report); + return; + } + if (token.kind !== "name") { + report("unreadable export form", at - 1); + return; + } + switch (token.value) { + case "default": + names.push("default"); + return; + case "async": { + const next = tokens[at + 1]; + if (next?.kind === "name" && next.value === "function") { + readExportedFunction(tokens, at + 1, names, report); + return; + } + report("unreadable export form after 'export async'", at - 1); + return; + } + case "function": + readExportedFunction(tokens, at, names, report); + return; + case "class": { + const named = tokens[at + 1]; + if (named?.kind === "name") { + names.push(named.value); + return; + } + report("an exported class other than 'export default' must be named", at - 1); + return; + } + case "const": + case "let": + case "var": + readDeclarationBindings(tokens, at + 1, token.depth, names, report); + return; + default: + report(`unreadable export form 'export ${token.value}'`, at - 1); + } +} + +/** `export function f`, `export function* g`, `export async function* h`. */ +function readExportedFunction(tokens, at, names, report) { + let cursor = at + 1; + if (tokens[cursor]?.kind === "punct" && tokens[cursor]?.value === "*") cursor += 1; + const named = tokens[cursor]; + if (named?.kind === "name") { + names.push(named.value); + return; + } + report("an exported function other than 'export default' must be named", at - 1); +} + +/** `export {a, b as c}` and `export {a} from "..."` — both publish the right-hand names. */ +function readNamedExports(tokens, at, names, report) { + const depth = tokens[at].depth; + /** @type {Token[]} */ + let entry = []; + const flush = () => { + if (entry.length === 0) return; + const asIndex = entry.findIndex((token) => token.kind === "name" && token.value === "as"); + const exported = asIndex >= 0 ? entry[asIndex + 1] : entry[0]; + if (exported === undefined) return; + if (exported.kind === "string") names.push(stringValue(exported.value)); + else if (exported.kind === "name") names.push(exported.value); + entry = []; + }; + for (let index = at + 1; index < tokens.length; index += 1) { + const token = tokens[index]; + if (token.depth === depth && token.kind === "punct" && token.value === "}") { + flush(); + return; + } + if (token.depth === depth + 1 && token.kind === "punct" && token.value === ",") { + flush(); + continue; + } + entry.push(token); + } + report("an export list is never closed", at - 1); +} + +/** + * The declarators of `export const|let|var ...`, up to the `;` that ends the + * declaration. Each declarator's binding may be a name or a destructuring + * pattern; the initializer between two declarators is skipped by nesting depth. + */ +function readDeclarationBindings(tokens, at, depth, names, report) { + let index = at; + for (;;) { + index = readBindingTarget(tokens, index, names, report); + if (index < 0) return; + let separator = null; + while (index < tokens.length) { + const token = tokens[index]; + if (token.depth === depth && token.kind === "punct" && (token.value === "," || token.value === ";")) { + separator = token.value; + index += 1; + break; + } + index += 1; + } + if (separator === null) { + report("an exported variable declaration must end with ';' for this gate to read its declarators", at - 2); + return; + } + if (separator === ";") return; + } +} + +/** One binding target: a name, an object pattern, or an array pattern. */ +function readBindingTarget(tokens, at, names, report) { + const token = tokens[at]; + if (token === undefined) { + report("the module ends inside an exported declaration", at - 1); + return -1; + } + if (token.kind === "name") { + names.push(token.value); + return at + 1; + } + if (token.kind === "punct" && token.value === "{") return readObjectPattern(tokens, at, names, report); + if (token.kind === "punct" && token.value === "[") return readArrayPattern(tokens, at, names, report); + report("unreadable binding in an exported declaration", at); + return -1; +} + +function readObjectPattern(tokens, at, names, report) { + const depth = tokens[at].depth; + let index = at + 1; + while (index < tokens.length) { + const token = tokens[index]; + if (token.depth === depth && token.kind === "punct" && token.value === "}") return index + 1; + if (token.depth === depth + 1 && token.kind === "punct" && token.value === ",") { + index += 1; + continue; + } + let cursor = index; + if (tokens[cursor]?.kind === "punct" && tokens[cursor]?.value === "...") cursor += 1; + const key = tokens[cursor]; + const after = tokens[cursor + 1]; + const renamed = after?.depth === depth + 1 && after.kind === "punct" && after.value === ":"; + if (key?.kind === "punct" && key.value === "[") { + // `{[computed]: target}` — the key is an expression, the binding follows. + const closed = skipToMatching(tokens, cursor); + if (closed < 0 || tokens[closed]?.value !== ":") { + report("unreadable computed key in an exported binding pattern", cursor); + return -1; + } + cursor = readBindingTarget(tokens, closed + 1, names, report); + } else if (renamed) { + cursor = readBindingTarget(tokens, cursor + 2, names, report); + } else { + cursor = readBindingTarget(tokens, cursor, names, report); + } + if (cursor < 0) return -1; + index = skipToEntryEnd(tokens, cursor, depth, "}"); + } + report("an exported binding pattern is never closed", at); + return -1; +} + +function readArrayPattern(tokens, at, names, report) { + const depth = tokens[at].depth; + let index = at + 1; + while (index < tokens.length) { + const token = tokens[index]; + if (token.depth === depth && token.kind === "punct" && token.value === "]") return index + 1; + // An elision — `[, second]` — binds nothing. + if (token.depth === depth + 1 && token.kind === "punct" && token.value === ",") { + index += 1; + continue; + } + let cursor = index; + if (tokens[cursor]?.kind === "punct" && tokens[cursor]?.value === "...") cursor += 1; + cursor = readBindingTarget(tokens, cursor, names, report); + if (cursor < 0) return -1; + index = skipToEntryEnd(tokens, cursor, depth, "]"); + } + report("an exported binding pattern is never closed", at); + return -1; +} + +/** Past a `= default` initializer, to this entry's `,` or the pattern's close. */ +function skipToEntryEnd(tokens, at, depth, closer) { + let index = at; + while (index < tokens.length) { + const token = tokens[index]; + if (token.depth === depth && token.kind === "punct" && token.value === closer) return index; + if (token.depth === depth + 1 && token.kind === "punct" && token.value === ",") return index; + index += 1; + } + return index; +} + +/** The index after the closer matching the opener at `at`. */ +function skipToMatching(tokens, at) { + const depth = tokens[at].depth; + for (let index = at + 1; index < tokens.length; index += 1) { + if (tokens[index].depth === depth && tokens[index].kind === "punct") return index + 1; + } + return -1; +} + +function stringValue(raw) { + return raw.slice(1, -1); +} + +function isIdentifierStart(code) { + return (code >= 97 && code <= 122) || (code >= 65 && code <= 90) || code === 36 || code === 95 || code > 127; +} + +function isIdentifierPart(code) { + return isIdentifierStart(code) || (code >= 48 && code <= 57); +} + +/** + * Significant tokens, each carrying the `()[]{}` nesting depth it sits at, so a + * caller can tell a module-level `export` from the word appearing inside a + * function body, an object literal, a string, or a comment. + * + * @param {string} source + * @param {Problem[]} unreadable + * @returns {Token[]} + */ +function tokenize(source, unreadable) { + /** @type {Token[]} */ + const tokens = []; + let index = 0; + let depth = 0; + const fail = (reason, start) => unreadable.push({ reason, text: source.slice(start, start + 72).split("\n")[0] ?? "" }); + while (index < source.length) { + const code = source.charCodeAt(index); + if (code === 32 || code === 9 || code === 10 || code === 13 || code === 0xfeff) { + index += 1; + continue; + } + if (code === 47 && source.charCodeAt(index + 1) === 47) { + while (index < source.length && source.charCodeAt(index) !== 10) index += 1; + continue; + } + if (code === 47 && source.charCodeAt(index + 1) === 42) { + const end = source.indexOf("*/", index + 2); + if (end < 0) { + fail("a block comment is never closed", index); + return tokens; + } + index = end + 2; + continue; + } + if (code === 34 || code === 39) { + const end = readString(source, index); + if (end < 0) { + fail("a string literal is never closed", index); + return tokens; + } + tokens.push({ kind: "string", value: source.slice(index, end), depth, start: index }); + index = end; + continue; + } + if (code === 96) { + const end = readTemplate(source, index); + if (end < 0) { + fail("a template literal is never closed", index); + return tokens; + } + tokens.push({ kind: "template", value: "", depth, start: index }); + index = end; + continue; + } + if (code === 47 && regexAllowed(tokens[tokens.length - 1])) { + const end = readRegex(source, index); + if (end < 0) { + fail("a regular expression literal is never closed", index); + return tokens; + } + tokens.push({ kind: "regex", value: "", depth, start: index }); + index = end; + continue; + } + if (isIdentifierStart(code)) { + let end = index + 1; + while (end < source.length && isIdentifierPart(source.charCodeAt(end))) end += 1; + tokens.push({ kind: "name", value: source.slice(index, end), depth, start: index }); + index = end; + continue; + } + if ((code >= 48 && code <= 57) || (code === 46 && source.charCodeAt(index + 1) >= 48 && source.charCodeAt(index + 1) <= 57)) { + let end = index + 1; + while (end < source.length && (isIdentifierPart(source.charCodeAt(end)) || source.charCodeAt(end) === 46)) end += 1; + tokens.push({ kind: "number", value: source.slice(index, end), depth, start: index }); + index = end; + continue; + } + if (code === 123 || code === 40 || code === 91) { + tokens.push({ kind: "punct", value: source[index], depth, start: index }); + depth += 1; + index += 1; + continue; + } + if (code === 125 || code === 41 || code === 93) { + depth -= 1; + if (depth < 0) { + fail("more closing brackets than opening ones", index); + return tokens; + } + tokens.push({ kind: "punct", value: source[index], depth, start: index }); + index += 1; + continue; + } + if (code === 46 && source.charCodeAt(index + 1) === 46 && source.charCodeAt(index + 2) === 46) { + tokens.push({ kind: "punct", value: "...", depth, start: index }); + index += 3; + continue; + } + tokens.push({ kind: "punct", value: source[index], depth, start: index }); + index += 1; + } + // A module whose nesting does not balance was misread somewhere, and a + // misread module would otherwise look like a module with no exports. + if (depth !== 0) fail(`module nesting does not balance (${depth} unclosed)`, Math.max(0, source.length - 72)); + return tokens; +} + +function regexAllowed(previous) { + if (previous === undefined) return true; + if (previous.kind === "name") return KEYWORDS_BEFORE_REGEX.has(previous.value); + if (previous.kind === "number" || previous.kind === "string" || previous.kind === "template" || previous.kind === "regex") return false; + if (previous.kind === "punct") return previous.value !== ")" && previous.value !== "]"; + return true; +} + +function readString(source, start) { + const quote = source.charCodeAt(start); + let index = start + 1; + while (index < source.length) { + const code = source.charCodeAt(index); + if (code === 92) { + index += 2; + continue; + } + if (code === quote) return index + 1; + if (code === 10) return -1; + index += 1; + } + return -1; +} + +function readTemplate(source, start) { + let index = start + 1; + while (index < source.length) { + const code = source.charCodeAt(index); + if (code === 92) { + index += 2; + continue; + } + if (code === 96) return index + 1; + if (code === 36 && source.charCodeAt(index + 1) === 123) { + const end = readSubstitution(source, index + 2); + if (end < 0) return -1; + index = end; + continue; + } + index += 1; + } + return -1; +} + +/** The index after the `}` closing a `${...}` substitution. */ +function readSubstitution(source, start) { + let index = start; + let braces = 0; + while (index < source.length) { + const code = source.charCodeAt(index); + if (code === 125) { + if (braces === 0) return index + 1; + braces -= 1; + index += 1; + continue; + } + if (code === 123) { + braces += 1; + index += 1; + continue; + } + if (code === 96) { + const end = readTemplate(source, index); + if (end < 0) return -1; + index = end; + continue; + } + if (code === 34 || code === 39) { + const end = readString(source, index); + if (end < 0) return -1; + index = end; + continue; + } + if (code === 47 && source.charCodeAt(index + 1) === 47) { + while (index < source.length && source.charCodeAt(index) !== 10) index += 1; + continue; + } + if (code === 47 && source.charCodeAt(index + 1) === 42) { + const end = source.indexOf("*/", index + 2); + if (end < 0) return -1; + index = end + 2; + continue; + } + index += 1; + } + return -1; +} + +function readRegex(source, start) { + let index = start + 1; + let inClass = false; + while (index < source.length) { + const code = source.charCodeAt(index); + if (code === 92) { + index += 2; + continue; + } + if (code === 10) return -1; + if (code === 91) inClass = true; + else if (code === 93) inClass = false; + else if (code === 47 && !inClass) { + index += 1; + while (index < source.length && isIdentifierPart(source.charCodeAt(index))) index += 1; + return index; + } + index += 1; + } + return -1; +} diff --git a/tests/ci.acceptance.ts b/tests/ci.acceptance.ts index 822023d1..54f8e4e7 100644 --- a/tests/ci.acceptance.ts +++ b/tests/ci.acceptance.ts @@ -13,10 +13,123 @@ import { repositoryRoot } from "./repository-root.ts"; function resolveScript(scripts: Record, name: string, seen: Set = new Set()): string { if (seen.has(name)) return ""; seen.add(name); - return (scripts[name] ?? "").replace( - /npm run (?:--silent )?([\w:.-]+)/gu, - (match, target: string) => `${match} ${resolveScript(scripts, target, seen)}`, - ); + const body = scripts[name] ?? ""; + return [body, ...invokedScripts(body).map((target) => resolveScript(scripts, target, seen))].join(" "); +} + +/** npm's own script aliases: `npm test` runs the `test` script, `npm ci` does not. */ +const NPM_SCRIPT_ALIASES = new Set(["test", "start", "stop", "restart"]); + +/** + * The package scripts one shell command runs — `npm run x`, `npm run --silent x`, + * `npm run-script x`, and npm's builtin aliases. + * + * Words are split on whitespace, which covers every command form this + * repository writes, and it fails in the safe direction: an invocation this + * missed would make a gate look unreached, and an unreached gate is a red test, + * never a quiet pass. + */ +function invokedScripts(command: string): string[] { + const words = command.split(/\s+/u).filter((word) => word !== ""); + const found: string[] = []; + for (const [index, word] of words.entries()) { + if (word !== "npm") continue; + let cursor = index + 1; + while (words[cursor]?.startsWith("-") === true) cursor += 1; + const verb = words[cursor]; + if (verb === undefined) continue; + if (verb !== "run" && verb !== "run-script") { + if (NPM_SCRIPT_ALIASES.has(verb)) found.push(verb); + continue; + } + cursor += 1; + while (words[cursor]?.startsWith("-") === true) cursor += 1; + const target = words[cursor]; + if (target !== undefined) found.push(target); + } + return found; +} + +/** Every package script these commands reach, directly or through another script. */ +function reachedScripts(scripts: Record, commands: readonly string[]): Set { + const reached = new Set(); + const pending = commands.flatMap((command) => invokedScripts(command)); + while (pending.length > 0) { + const name = pending.pop()!; + const body = scripts[name]; + if (body === undefined || reached.has(name)) continue; + reached.add(name); + pending.push(...invokedScripts(body)); + } + return reached; +} + +/** + * Every `run:` step of a GitHub workflow, in file order. + * + * Deliberately not a YAML parser. It reads the block-mapping subset these + * workflows are written in and refuses everything else out loud, because a step + * this could not see would look exactly like a step CI does not have — which is + * the failure being repaired here, not one to rebuild. Block scalars are + * followed so a `run: |` body is never mistaken for more keys; anchors, + * aliases, merge keys, tags, flow collections, tab indentation and multiple + * documents throw instead of being misread. + */ +function workflowRunCommands(workflow: string): string[] { + const refused = new Map([ + ["<<:", "a merge key"], + [": &", "an anchor"], + [": *", "an alias"], + ["- &", "an anchor"], + ["- *", "an alias"], + ["run: [", "a flow sequence"], + ["run: {", "a flow mapping"], + ["run: !", "a tag"], + ]); + const commands: string[] = []; + let block: { indent: number; folded: boolean; lines: string[] } | null = null; + const closeBlock = () => { + if (block === null) return; + commands.push(block.lines.join(block.folded ? " " : "\n")); + block = null; + }; + for (const line of workflow.split(/\r?\n/u)) { + const content = line.trim(); + if (block !== null) { + const indent = line.length - line.trimStart().length; + if (content === "" || indent > block.indent) { + block.lines.push(content); + continue; + } + closeBlock(); + } + if (line.includes("\t")) throw new Error(`this workflow reader does not model tab indentation: ${content}`); + if (content === "---" || content === "..." || content.startsWith("%")) { + throw new Error(`this workflow reader models one document per file: ${content}`); + } + for (const [marker, what] of refused) { + if (line.includes(marker)) throw new Error(`this workflow reader does not model ${what}: ${content}`); + } + if (content === "" || content.startsWith("#")) continue; + const keyed = content.startsWith("- ") ? content.slice(2).trim() : content; + if (!keyed.startsWith("run:")) continue; + const value = keyed.slice("run:".length).trim(); + if (value.startsWith("|") || value.startsWith(">")) { + block = { indent: line.length - line.trimStart().length, folded: value.startsWith(">"), lines: [] }; + continue; + } + if (value.startsWith("\"") || value.startsWith("'")) { + const quote = value[0]!; + const end = value.indexOf(quote, 1); + if (end < 0) throw new Error(`this workflow reader cannot read an unterminated quoted command: ${content}`); + commands.push(value.slice(1, end)); + continue; + } + const comment = value.indexOf(" #"); + commands.push((comment < 0 ? value : value.slice(0, comment)).trim()); + } + closeBlock(); + return commands; } test("CI covers platform, browser, and non-publishing provenance gates", async () => { @@ -25,17 +138,45 @@ test("CI covers platform, browser, and non-publishing provenance gates", async ( assert.equal(workspace.scripts["release:rehearse"], "node scripts/release-toolchain.mjs rehearse"); assert.equal(workspace.scripts["preview:prepare"], "node scripts/prepare-external-preview.mjs"); + // Every gate, derived: a `gate:x` script is the body a public gate `x` runs + // under the checkout lock. The list this replaces named six by hand. + const gates = Object.keys(workspace.scripts) + .filter((name) => name.startsWith("gate:")) + .map((name) => name.slice("gate:".length)) + .sort(); + assert.ok(gates.length >= 5, `package.json declares only ${gates.length} gates`); // A gate that stops taking the checkout lock lets a second run delete a // package dist mid-test and fail the first one for no reason. - for (const gate of ["build:packages", "check", "test", "test:browser", "test:packages", "velar"]) { + for (const gate of gates) { assert.match(workspace.scripts[gate] ?? "", /^node scripts\/gate-lock\.mjs /u, `${gate} must run under the gate lock`); } + // `npm run velar -- build app.vel` forwards the developer's own arguments, + // and CI has none to give it; that trailing `--` is what makes it an entry + // point rather than a gate. Pinned instead of merely filtered, so the day a + // real gate starts forwarding arguments — and would therefore drop out of + // the set CI has to run — this fails rather than quietly shrinking that set. + const forwarding = gates.filter((gate) => (workspace.scripts[gate] ?? "").trimEnd().endsWith(" --")); + assert.deepEqual(forwarding, ["velar"], "the gates that forward developer arguments changed"); + const required = gates.filter((gate) => !forwarding.includes(gate)); + const ci = await readFile(".github/workflows/ci.yml", "utf8"); for (const platform of ["ubuntu-latest", "macos-latest", "windows-latest"]) assert.match(ci, new RegExp(platform, "u")); assert.match(ci, /node-version: "24\.x"/u); assert.match(ci, /playwright install --with-deps chromium firefox webkit/u); - assert.match(ci, /npm run test:browser/u); + + // The only gate this file used to assert about ci.yml was `test:browser`. + // The three lines above it — `npm run check`, `npm test`, + // `npm run test:packages` — were covered by nothing: deleting any of them + // from the workflow left every test in this repository green, and + // `test:packages` had already sat red for days on the strength of being the + // gate people skip. The steps are read out of the workflow now and followed + // through the script graph, so a deleted step takes a named gate down with + // it. Which job runs which gate is not modelled here; the platform matrix + // and the browser job are asserted separately above. + const reached = reachedScripts(workspace.scripts, workflowRunCommands(ci)); + const missing = required.filter((gate) => !reached.has(gate)); + assert.deepEqual(missing, [], `.github/workflows/ci.yml runs no step that reaches: ${missing.join(", ")}`); const release = await readFile(".github/workflows/release-rehearsal.yml", "utf8"); assert.match(release, /id-token: write/u); diff --git a/tests/package-contract.test.ts b/tests/package-contract.test.ts index 23564932..67dacd2e 100644 --- a/tests/package-contract.test.ts +++ b/tests/package-contract.test.ts @@ -199,3 +199,44 @@ test("[A-024] every acceptance script that installs the toolchain derives its se ); } }); + +test("no tracked text file carries a NUL byte that hides it from every text tool", async () => { + // `scripts/check-runtime-boundary.mjs` held a literal NUL inside a template + // literal — a deduplication key written as the character rather than the `\0` + // escape — and `packages/compiler/src/mechanical-fix.ts` held two more. One + // byte is enough: `grep` classifies the whole file as binary and reports + // nothing from it, so `grep -rn standardModuleSources scripts/` came back + // empty while the gate script imported and called it on the next line. The + // largest gate in the repository was invisible to every plain text search + // anybody would run over it, which is the quietest possible way for a file to + // stop being reviewed. + // + // Which files are text is decided without reference to the byte under test: + // a file counts as text when, ignoring NULs, it decodes as UTF-8 and carries + // no other control characters. Deciding it by the NUL — which is how git and + // grep themselves decide — would make this assertion vacuous. + const listed = spawnSync("git", ["ls-files"], { cwd: root, encoding: "utf8" }); + assert.equal(listed.status, 0, listed.stderr); + const decoder = new TextDecoder("utf-8", { fatal: true }); + const offenders: string[] = []; + let checked = 0; + for (const file of listed.stdout.split("\n").filter((name) => name !== "")) { + let bytes: Buffer; + try { + bytes = await readFile(join(root, file)); + } catch { + continue; + } + const withoutNul = bytes.filter((byte) => byte !== 0); + try { + decoder.decode(withoutNul); + } catch { + continue; + } + if (withoutNul.some((byte) => byte < 9 || (byte > 13 && byte < 32) || byte === 127)) continue; + checked += 1; + if (bytes.includes(0)) offenders.push(file); + } + assert.ok(checked > 100, `only ${checked} tracked text files were read`); + assert.deepEqual(offenders, [], "these text files carry a NUL byte, so grep and diff treat them as binary"); +}); diff --git a/tests/release.acceptance.ts b/tests/release.acceptance.ts index a994ea63..5b7eff21 100644 --- a/tests/release.acceptance.ts +++ b/tests/release.acceptance.ts @@ -1,11 +1,12 @@ import assert from "node:assert/strict"; import { spawn } from "node:child_process"; -import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { cp, mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import test from "node:test"; import { fileURLToPath } from "node:url"; import { prepareExternalPreview } from "../scripts/prepare-external-preview.mjs"; +import { velarBuildOrder } from "../scripts/velar-packages.mjs"; const root = resolve(fileURLToPath(new URL("..", import.meta.url))); @@ -135,7 +136,30 @@ test("external preview preparation emits a reproducible root Netlify build", asy }); async function protectWorkspaceOutputs(label: string) { - const paths = ["compiler", "node", "web", "create", "cli", "desktop"].map((workspace) => join(root, "packages", workspace, "dist", `.workspace-${label}.sentinel`)); + // A-024 again, one file further on. The six workspaces were spelled out here, + // which was correct and was a copy: the seventh compiled package would have + // been the one package release tooling could quietly overwrite. What has to + // be protected is every package that has a `dist` to lose, and that is what + // `velarBuildOrder` already answers. + const built = await velarBuildOrder(root); + const paths = built.map((package_) => join(package_.directory, "dist", `.workspace-${label}.sentinel`)); + // Recomputed by a second route — the manifests themselves — so that replacing + // the derivation above with a literal list again fails here instead of + // silently protecting fewer packages than the workspace has. + const compiled = (await Promise.all((await readdir(join(root, "packages"), { withFileTypes: true })) + .filter((entry) => entry.isDirectory()) + .map(async (entry) => { + let manifest: { private?: boolean; scripts?: Record }; + try { + manifest = JSON.parse(await readFile(join(root, "packages", entry.name, "package.json"), "utf8")); + } catch { + return null; + } + return manifest.private === true || manifest.scripts?.build === undefined ? null : entry.name; + }))) + .filter((name): name is string => name !== null) + .map((name) => join(root, "packages", name, "dist", `.workspace-${label}.sentinel`)); + assert.deepEqual([...paths].sort(), compiled.sort(), "release tooling is not protected on every package that declares a build"); const sourcePaths = [ join(root, "packages", "compiler", "src", "analyzer.ts"), join(root, "packages", "web", "src", "analyzer.ts"), diff --git a/tests/runtime-boundary.test.ts b/tests/runtime-boundary.test.ts index 5fc045d1..7cf7c227 100644 --- a/tests/runtime-boundary.test.ts +++ b/tests/runtime-boundary.test.ts @@ -10,7 +10,11 @@ import { VELAR_TYPE_REGISTRY_RUNTIME, VELAR_UTF8_RUNTIME, } from "@velarscript/compiler/extension"; +import { standardModuleInterfaces, standardModuleSources } from "../packages/cli/src/standard-modules.ts"; +import { velarCompilerExtension as velarDesktopCompilerExtension } from "../packages/desktop/src/compiler.ts"; +import { velarNodeCompilerExtension } from "../packages/node/src/compiler.ts"; import { velarCompilerExtension } from "../packages/web/src/compiler.ts"; +import { esModuleExports } from "../scripts/es-module-exports.mjs"; function executeModule(source: string) { // Passing generated programs through `--eval` is bounded by the host's @@ -256,6 +260,87 @@ console.log(poisonCalls); ].join("\n")); }); +test("the boundary gate reads a runtime module's exports as syntax, not as two patterns", () => { + // D57 rule 140 is enforced by comparing what a runtime module publishes + // against what its interface declares, and what it publishes used to be found + // with `^export (async )?(function|const|let|class) NAME` and `^export {...}`. + // Every form below is a name those two patterns never saw, so a runtime that + // used any of them published names the gate reported as absent. Compiled + // sources are generated, which is exactly why this is a defect and not a + // style note: `export function*` is already this repository's own spelling in + // `packages/compiler/src/ast.ts`, and nothing stops an emitter reaching for + // it or for `export var`. + for (const [source, expected] of [ + ["export var counter = 0;", ["counter"]], + ["export function* walk() { yield 1; }", ["walk"]], + ["export async function* stream() { yield 1; }", ["stream"]], + ["export const {alpha, beta: renamed, ...rest} = host;", ["alpha", "renamed", "rest"]], + ["export const [first, , third = 2, ...tail] = host;", ["first", "third", "tail"]], + [" export const indented = 1;", ["indented"]], + ["\texport const tabbed = 1;", ["tabbed"]], + ["export let first = 1, second = 2;", ["first", "second"]], + ["export default function () {}", ["default"]], + ["export * as everything from \"./other.js\";", ["everything"]], + ["export const outer = 1;\nfunction hide() { const inner = 2; return inner; }", ["outer"]], + ["export {local as published, plain};", ["published", "plain"]], + ["export {renamed as \"quoted name\"};", ["quoted name"]], + ] as const) { + const { names, unreadable } = esModuleExports(source); + assert.deepEqual(unreadable, [], source); + assert.deepEqual(names, [...expected], source); + } + + // A name that only looks like an export publishes nothing. + for (const source of [ + "const host = {export: 1};\nconst read = host.export;", + "// export const commented = 1;", + "const text = \"export const inString = 1\";", + "const pattern = /export const inRegex = 1/u;", + "const filled = `export const ${\"inTemplate\"} = 1`;", + "function body() { return 1; }", + ]) { + assert.deepEqual(esModuleExports(source).names, [], source); + } + + // And a form outside the scanner's boundary is named, never skipped: a silent + // skip is what made the two patterns look like coverage in the first place. + for (const source of ["export * from \"./other.js\";", "export const unterminated = 1", "export interface Shape {}"]) { + assert.notEqual(esModuleExports(source).unreadable.length, 0, source); + } +}); + +test("the boundary gate reports no number it cannot support", () => { + // The summary used to open with `Checked ${ids.size} runtime boundary + // operations`, counting rows of the Markdown table in + // `docs/contributing/runtime-boundary.md` — a count connected to no check in + // that gate, which rose by one for every row appended. Pinned here is that + // every number the gate prints is a number this test can recompute: an + // unsupported count cannot be re-introduced without failing. + const surfaces = new Map>(); + let publicSurfaces = 0; + let internalSurfaces = 0; + for (const extensions of [[], [velarCompilerExtension], [velarNodeCompilerExtension], [velarDesktopCompilerExtension]]) { + const interfaces = standardModuleInterfaces(extensions); + for (const [name, source] of standardModuleSources(extensions)) { + const seen = surfaces.get(name) ?? new Set(); + if (seen.has(source)) continue; + seen.add(source); + surfaces.set(name, seen); + if (interfaces.has(name)) publicSurfaces += 1; + else internalSurfaces += 1; + } + } + const gate = spawnSync(process.execPath, ["scripts/check-runtime-boundary.mjs"], { encoding: "utf8" }); + assert.equal(gate.status, 0, `${gate.stdout}\n${gate.stderr}`); + const reported = String(gate.stdout).match(/\d+/gu) ?? []; + assert.deepEqual(reported, [String(publicSurfaces), String(internalSurfaces)], gate.stdout); + + // Every internal runtime module is accounted for rather than skipped. Ten of + // them used to fall through a `continue` that said nothing, so a tenth of the + // module sources that loop walks passed without being looked at. + assert.notEqual(internalSurfaces, 0); +}); + function escapeRegex(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); } From 63d2e4fa12f2842f235aa1524ff4e5ade68483ae Mon Sep 17 00:00:00 2001 From: Error-Zhang Date: Mon, 17 Aug 2026 00:00:29 +0800 Subject: [PATCH 2/2] fix: the installed acceptance was one Web module short, and I gave the wrong reason MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I told the wave to derive this list as every Web interface minus velar/web-test and minus velar/look, on the ground that look is a syntax feature with no importable value surface. The wave verified that and refused to build on it, which was the right call: velar/look publishes 37 exports, the charter itself writes `import {alpha, border, rgb, spacing} from "velar/look"`, and the repository's own web-capabilities fixture already counts it as public. The old hand-written nine was not a definition of "browser application module" — it was an omission, and my reasoning was an explanation invented for it after the fact. So the roster is every interface the extension publishes minus one, and the one subtraction is the compiler's own BROWSER_TEST_MODULE constant rather than a name matched by spelling. It earns the exclusion: application source that imports velar/web-test is refused outright, because it only has a runtime under `velar test --browser`, so the generated browser test exercises it one layer below where it actually works. Each module is probed with its first function export rather than its first export. A type name would prove the interface parsed; a function proves the installed package serves the module. The documentation sentence had the same undercount and now states the rule instead of a number, since the number is derived. test:packages=0, check=0. Co-Authored-By: Claude Opus 5 --- docs/contributing/continuous-integration.md | 9 ++- tests/installed-browser.acceptance.ts | 61 ++++++++++++++------- 2 files changed, 46 insertions(+), 24 deletions(-) diff --git a/docs/contributing/continuous-integration.md b/docs/contributing/continuous-integration.md index f0e3bf1c..f50cc4a0 100644 --- a/docs/contributing/continuous-integration.md +++ b/docs/contributing/continuous-integration.md @@ -39,9 +39,12 @@ The repository defines three GitHub Actions workflows: cleanup. Host-side tests do not bypass that source contract by importing the generated realtime JavaScript module directly. - Packed-browser acceptance independently creates an application from the eight - installed tarballs. Its application graph imports all nine browser - application modules before mounting, while its generated browser test loads - the tenth module, `velar/web-test`; the installed CLI then checks, tests, + installed tarballs. Its application graph imports every browser application + module the Web extension publishes — ten today, derived from the extension's + own interface table rather than listed, so an eleventh fails this acceptance + until the installed toolchain serves it — while its generated browser test + loads `velar/web-test`, which application source may not import at all; the + installed CLI then checks, tests, builds, verifies, and executes that project. - Hosted-deployment acceptance runs the public remote verifier against root and subpath product servers and proves that byte tampering, wrong cache headers, diff --git a/tests/installed-browser.acceptance.ts b/tests/installed-browser.acceptance.ts index fd9f7dcb..df8c88e3 100644 --- a/tests/installed-browser.acceptance.ts +++ b/tests/installed-browser.acceptance.ts @@ -5,10 +5,44 @@ import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { velarPackageNames } from "../scripts/velar-packages.mjs"; +import { BROWSER_TEST_MODULE, webModuleInterfaces } from "../packages/web/dist/compiler.js"; const root = resolve(fileURLToPath(new URL("..", import.meta.url))); const directory = await mkdtemp(join(tmpdir(), "velar-installed-browser-")); +/** + * Every Web module an application may import, read from the extension's own + * interface table rather than listed here, so a module published tomorrow + * fails this acceptance until the installed toolchain really serves it. + * + * `velar/web-test` is the one subtraction, and it is the compiler's own + * constant rather than a name matched by spelling: importing it from + * application source is refused outright (VEL5062 — it only has a runtime + * under `velar test --browser`), so it is exercised by the generated browser + * test instead, one layer below. + * + * Nothing else is subtracted. `velar/look` was missing from the hand-written + * list this replaces, on the belief that it is syntax with no importable value + * surface; its interface publishes 37 exports and the charter writes + * `import {alpha, border, rgb, spacing} from "velar/look"`, so the belief was + * simply wrong and the old count of nine was one short. + * + * The probe name per module is its first *function* export, because a value + * with a runtime behind it proves the installed package actually serves the + * module. A type name would prove only that the interface parsed. + */ +const applicationWebModules = [...webModuleInterfaces] + .filter(([specifier]) => specifier !== BROWSER_TEST_MODULE) + .map(([specifier, moduleInterface]) => { + const probe = [...moduleInterface.exports] + .filter(([, entry]) => entry?.kind === "function") + .map(([name]) => name) + .sort()[0]; + assert.ok(probe, `${specifier} publishes no function export to probe the installed module with`); + return { specifier, probe }; + }) + .sort((left, right) => left.specifier.localeCompare(right.specifier)); + try { // A-024: this file held the fifth copy of the eight-package roster — one // literal `pack()` list and four literal install lists — while @@ -27,34 +61,19 @@ try { const application = join(directory, "Team & App"); await run(process.execPath, [installedCli, "create", application], directory); await install(["--save-dev"], application); - await writeFile(join(application, "src", "web-contract.vel"), ` -import {onError} from "velar/app" -import {environment} from "velar/browser" -import {has} from "velar/config" -import {readText} from "velar/files" -import {errors} from "velar/forms" -import {http} from "velar/http" -import {socket} from "velar/realtime" -import {session} from "velar/storage" -import {currentRoute} from "velar/web" + await writeFile(join(application, "src", "web-contract.vel"), `${applicationWebModules + .map(({ specifier, probe }) => `import {${probe}} from "${specifier}"`) + .join("\n")} export const installedWebModules = [ - onError, - environment, - has, - readText, - errors, - http, - socket, - session, - currentRoute, +${applicationWebModules.map(({ probe }) => ` ${probe},`).join("\n")} ].size -`.trimStart(), "utf8"); +`, "utf8"); await writeFile(join(application, "src", "main.vel"), ` import {App} from "./app.vel" import {installedWebModules} from "./web-contract.vel" -assert installedWebModules == 9 else "The installed Web package must expose all application modules" +assert installedWebModules == ${applicationWebModules.length} else "The installed Web package must expose all application modules" mount(, "#app") `.trimStart(), "utf8"); const manifest = JSON.parse(await readFile(join(application, "package.json"), "utf8")) as { scripts: Record };