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/packages/compiler/src/mechanical-fix.ts b/packages/compiler/src/mechanical-fix.ts index 34af7389..32ec7795 100644 Binary files a/packages/compiler/src/mechanical-fix.ts and b/packages/compiler/src/mechanical-fix.ts differ diff --git a/scripts/check-runtime-boundary.mjs b/scripts/check-runtime-boundary.mjs index d5aecce1..ed665a59 100644 --- a/scripts/check-runtime-boundary.mjs +++ b/scripts/check-runtime-boundary.mjs @@ -48,8 +48,9 @@ import { VELAR_TYPE_VALIDATION_MODULE_SOURCE, } from "../packages/compiler/src/type-validation-runtime.ts"; import { standardModuleInterfaces, standardModuleSources } from "../packages/cli/src/standard-modules.ts"; +import { esModuleExports } from "./es-module-exports.mjs"; import { velarCompilerExtension as velarWebCompilerExtension } from "../packages/web/src/compiler.ts"; -import { velarNodeCompilerExtension } from "../packages/node/src/compiler.ts"; +import { VELAR_NODE_HOST_MODULE, velarNodeCompilerExtension } from "../packages/node/src/compiler.ts"; import { velarCompilerExtension as velarDesktopCompilerExtension } from "../packages/desktop/src/compiler.ts"; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); @@ -1943,47 +1944,113 @@ for (const phrase of [ // later extension's module silently overwrite an earlier one's — Desktop ships // its own velar/fs alongside Node's, so the merged form checked one of the two // implementations and reported as though it had checked both. -let standardModulesAudited = 0; -const auditedSurfaces = new Set(); +// +// Three repairs, each of which had let this pass green on something: +// +// 1. What a module publishes is read from its export syntax by +// `scripts/es-module-exports.mjs`, not matched with two regular +// expressions. Those patterns could not see `export var`, +// `export function*` (the spelling `packages/compiler/src/ast.ts` itself +// uses), `export const {a} = ...`, `export default`, `export * as ns`, or +// any export not flush against column zero, and every one of them would +// have published a name this gate reported as absent. An export form the +// scanner cannot read is a failure here, never a skip. +// 2. A runtime module source with no published `ModuleInterface` used to be +// skipped in silence — ten of them per extension set, which is a tenth of +// the module sources this loop walks passing without a word. They are +// accounted for now instead. Rule 140 compares a runtime against its +// interface, and these have none because they are outside the checked +// standard-module namespace: a `import {narrow} from +// "velar/compiler-runtime-narrowing-v1"` is VEL6003 `Unknown standard +// module`, exactly as for a name nobody ever defined. What can be checked, +// and is, is that every one of them is a module identity the compiler +// declares. An eleventh source with no interface and no declared identity +// is a module surface nobody accounted for, and it fails here. +// 3. A surface is identified by its whole source. The old key was the module +// name and the source's byte length, so two same-named modules of equal +// size counted as one surface: the second went unchecked while the total +// reported otherwise. That is the merged-map defect above, rebuilt inside +// its own repair. +const declaredInternalModules = new Set([ + VELAR_CLASS_FIELD_MODULE, + VELAR_COLLECTION_HOST_MODULE, + VELAR_COLLECTION_LOWERING_MODULE, + VELAR_ERROR_NORMALIZATION_MODULE, + VELAR_NARROWING_MODULE, + VELAR_NODE_HOST_MODULE, + VELAR_PRIMITIVE_METHOD_MODULE, + VELAR_PROMISE_NORMALIZATION_MODULE, + VELAR_REACTIVE_BRIDGE_MODULE, + VELAR_TYPE_VALIDATION_MODULE, +]); +let publicModuleSurfaces = 0; +let internalModuleSurfaces = 0; +const auditedSurfaces = new Map(); +const accountedInternalModules = new Set(); for (const extensions of [[], [velarWebCompilerExtension], [velarNodeCompilerExtension], [velarDesktopCompilerExtension]]) { const interfaces = standardModuleInterfaces(extensions); const sources = standardModuleSources(extensions); for (const [name, source] of sources) { + const seenSources = auditedSurfaces.get(name) ?? new Set(); + if (seenSources.has(source)) continue; + seenSources.add(source); + auditedSurfaces.set(name, seenSources); const contract = interfaces.get(name); - if (!contract) continue; - const surface = `${name}${source.length}`; - if (auditedSurfaces.has(surface)) continue; - auditedSurfaces.add(surface); - standardModulesAudited += 1; - const declared = new Set(); - for (const table of [contract.exports, contract.mutableExports, contract.reactiveExports, contract.reExports, - contract.namedTypes, contract.typeAliases, contract.enums, contract.classes, contract.extensionExports]) { - if (table instanceof Map) for (const key of table.keys()) declared.add(key); - else if (table && typeof table === "object") for (const key of Object.keys(table)) declared.add(key); - } - const published = new Set(); - for (const match of source.matchAll(/^export\s+(?:async\s+)?(?:function|const|let|class)\s+([A-Za-z_$][\w$]*)/gmu)) { - published.add(match[1]); + if (contract) { + publicModuleSurfaces += 1; + } else { + internalModuleSurfaces += 1; + accountedInternalModules.add(name); + if (!declaredInternalModules.has(name)) { + failures.push(`${name}: this module source publishes no interface and is not one of the compiler's declared internal runtime` + + ` modules, so nothing here knows what it is allowed to export`); + } } - for (const match of source.matchAll(/^export\s*\{([^}]*)\}/gmsu)) { - for (const clause of match[1].split(",")) { - const parts = clause.split(/\s+as\s+/u); - const exported = (parts[1] ?? parts[0]).trim(); - if (exported !== "") published.add(exported); + const declared = new Set(); + if (contract) { + for (const table of [contract.exports, contract.mutableExports, contract.reactiveExports, contract.reExports, + contract.namedTypes, contract.typeAliases, contract.enums, contract.classes, contract.extensionExports]) { + if (table instanceof Map) for (const key of table.keys()) declared.add(key); + else if (table && typeof table === "object") for (const key of Object.keys(table)) declared.add(key); } } + const { names: published, unreadable } = esModuleExports(source); + for (const problem of unreadable) { + failures.push(`${name}: this gate cannot read an export form in the runtime module, so the names it publishes are unknown` + + ` — ${problem.reason}: ${problem.text}`); + } + if (!contract) continue; for (const exported of published) { if (exported.startsWith("__velar") || declared.has(exported)) continue; failures.push(`${name}: runtime exports '${exported}', which the module interface does not declare — 'import js unsafe' can reach it`); } } } +// The other direction of the same accounting: a declared internal module whose +// source stopped being emitted is a retired runtime this gate would otherwise +// keep reporting as covered. +for (const name of declaredInternalModules) { + if (!accountedInternalModules.has(name)) { + failures.push(`${name}: the compiler declares this internal runtime module, but no module source carries it`); + } +} if (failures.length > 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/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 }; 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, "\\$&"); }