From 4665773fe8334d24284cb995cbb0f617dfe223e3 Mon Sep 17 00:00:00 2001 From: Jeff Date: Tue, 11 Aug 2026 16:07:38 -0700 Subject: [PATCH] fix(core): keep the external-core stager CRLF-safe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a Windows checkout Git ships core.autocrlf=true and this repository carries no .gitattributes, so every source lands in the working tree with CRLF. The stager splits its inputs on "\n" and then makes line-shaped decisions about them, which leaves a trailing CR that a `$`-anchored terminator or an exact-equality compare no longer matches. Transform 4 fails hardest. Its terminator scan never matches, so the first alias it means to drop reads as an unterminated multi-line declaration, `skipping` latches on and never clears, and the remainder of the static @native-sdk/core restatement is dropped from the stage. The build does not fail there; it fails much later, and blames the SDK: error SC0001: Module '"./sdk/core.ts"' has no exported member 'Cmd'. Every TypeScript-core app is unbuildable on Windows as a result — the staged surface is truncated at the first deduped alias, taking Cmd and Sub with it. Transform 3's Bytes-alias folding fails the same way, quietly keeping the alias it exists to remove. Tolerate the optional CR in all three guards. This is a no-op on LF input, so the staged bytes the compiled-core batteries pin against the transpiler lane are unchanged, and the fixture twin in tests/compiled-core needs no matching edit. Add .gitattributes so the working tree is LF on every platform. This repository reads its own sources byte-exactly in several places, so a CRLF checkout does not fail loudly, it changes build output. The index is already all-LF, so this changes checkout behavior only; third_party keeps its upstream bytes, since the WebView2 headers are committed CRLF. Co-Authored-By: Claude Opus 5 (1M context) --- .gitattributes | 13 ++ packages/core/scripts/stage_external_core.mjs | 25 +++- .../core/test/stage_external_core.test.ts | 121 ++++++++++++++++++ 3 files changed, 156 insertions(+), 3 deletions(-) create mode 100644 .gitattributes create mode 100644 packages/core/test/stage_external_core.test.ts diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..1dc9d3c39 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,13 @@ +# Git for Windows installs with core.autocrlf=true, so without this file a +# Windows checkout smudges CRLF into every text file. This repository reads +# its own sources byte-exactly in more places than most — the external-core +# stager's staged tree, the compiled-core batteries that pin staged output +# byte-identical to the transpiler lane, the pinned goldens, and the POSIX +# shell the gate and CI scripts run under — so a CRLF working tree does not +# fail loudly, it changes build output. Normalize to LF in the repository +# and on checkout, on every platform. +* text=auto eol=lf + +# Vendored third-party sources keep their upstream bytes verbatim; the +# WebView2 headers Microsoft ships are CRLF and are committed that way. +third_party/** -text diff --git a/packages/core/scripts/stage_external_core.mjs b/packages/core/scripts/stage_external_core.mjs index 2d4abb962..5cf8167b0 100644 --- a/packages/core/scripts/stage_external_core.mjs +++ b/packages/core/scripts/stage_external_core.mjs @@ -76,7 +76,15 @@ function resolveSpecifiers(text, rel) { .replace(/readonly ([A-Za-z_][A-Za-z0-9_]*(?:<[A-Za-z_, ]*>)?)\[\]/g, "$1[]") .replace(/(? line !== "export type Uint8Array = Uint8Array;" && !/^ *type Uint8Array,$/.test(line)) + // Both drops tolerate a trailing CR for the same reason transform 4's + // terminator does: on a CRLF checkout the split leaves one behind, and + // an exact-equality drop (or a `$`-anchored one) would quietly keep the + // folded alias it is meant to remove. + .filter( + (line) => + line.replace(/\r$/, "") !== "export type Uint8Array = Uint8Array;" && + !/^ *type Uint8Array,\r?$/.test(line), + ) .map((line) => line.replaceAll("type Uint8Array, ", "").replace(/, type Uint8Array([,}])/g, "$1")) .join("\n"); } @@ -95,18 +103,29 @@ function exportedAliasNames(text) { /// Transform 4: drop the static surface's copy of any alias another /// staged file declares (single-line aliases and multi-line ones, /// through the terminating `;`). +/// +/// The `\r?` in the terminator is load-bearing on a CRLF checkout (Git +/// for Windows defaults to core.autocrlf=true, and this repo carries no +/// .gitattributes to override it): splitting on "\n" leaves a trailing +/// CR on every line, so a `;$` terminator matches nothing. Without it a +/// SINGLE-line alias reads as multi-line, `skipping` latches on and +/// never clears, and the rest of the static surface — Cmd and Sub +/// included — is silently dropped from the stage, surfacing far away as +/// "Module './sdk/core.ts' has no exported member 'Cmd'". It is a no-op +/// on LF input, so the staged bytes the compiled-core batteries pin are +/// unchanged. function dedupeAliases(text, dropNames) { const drop = new Set(dropNames); const out = []; let skipping = false; for (const line of text.split("\n")) { if (skipping) { - if (/;[ \t]*$/.test(line)) skipping = false; + if (/;[ \t]*\r?$/.test(line)) skipping = false; continue; } const match = /^export type ([A-Za-z0-9_]+) =/.exec(line); if (match && drop.has(match[1])) { - if (!/;[ \t]*$/.test(line)) skipping = true; + if (!/;[ \t]*\r?$/.test(line)) skipping = true; continue; } out.push(line); diff --git a/packages/core/test/stage_external_core.test.ts b/packages/core/test/stage_external_core.test.ts new file mode 100644 index 000000000..f4ea66924 --- /dev/null +++ b/packages/core/test/stage_external_core.test.ts @@ -0,0 +1,121 @@ +// The external-core stager, held to its contract on a CRLF checkout. +// +// The stager splits its inputs on "\n" and makes line-shaped decisions +// about them. On a Windows checkout (Git for Windows ships +// core.autocrlf=true) every one of those lines keeps a trailing CR, so a +// `$`-anchored terminator or an exact-equality compare quietly stops +// matching. That failure mode is not a crash: transform 4's `skipping` +// flag latches on at the first alias it means to drop and never clears, +// so the rest of the static @native-sdk/core restatement — Cmd and Sub +// among it — never reaches the stage. The app then fails its external +// compile with "Module './sdk/core.ts' has no exported member 'Cmd'", +// pointing at the SDK rather than at the stager that truncated it. +// +// These cases pin both halves: the staged surface survives past a dropped +// alias under either line ending, the dedupe those transforms exist for +// still happens, and CRLF staging agrees with LF staging line for line — +// the property that lets the compiled-core batteries keep pinning staged +// bytes against the transpiler lane. + +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const pkg = path.dirname(path.dirname(fileURLToPath(import.meta.url))); +const stager = path.join(pkg, "scripts", "stage_external_core.mjs"); + +// An author source carrying the folded Bytes alias transform 3 removes. +const AUTHOR_CORE = [ + 'import { Cmd } from "@native-sdk/core";', + 'import { type AudioState } from "@native-sdk/core/events";', + "export type Uint8Array = Uint8Array;", + 'export type AppMsg = { readonly kind: "tick" };', + "export const boot = Cmd;", +].join("\n"); + +const SDK_EVENTS = ['export type AudioState = "loaded" | "failed";'].join("\n"); +const SDK_TEXT = ['export type TextState = "ready";'].join("\n"); + +// The static restatement. AudioState is declared by the staged events +// module too, so transform 4 drops it here — and everything below it must +// still survive. It is spelled on ONE line, ending in `;`: that is the +// case a CRLF split misreads as an unterminated multi-line alias. +const STATIC_CORE = [ + "export type Msgish = { readonly kind: string };", + 'export type AudioState = "loaded" | "position" | "failed";', + "export const Cmd = {", + " none: 0,", + "};", + "export type Sub = { readonly sub: string };", +].join("\n"); + +/** Stage a fixture whose every file uses `eol`, and return what landed. */ +function stage(eol: string) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "stage-external-core-")); + const write = (rel: string, body: string) => { + const at = path.join(root, rel); + fs.mkdirSync(path.dirname(at), { recursive: true }); + fs.writeFileSync(at, body.replaceAll("\n", eol)); + return at; + }; + + write("src/core.ts", AUTHOR_CORE); + write("sdk/text.ts", SDK_TEXT); + write("sdk/events.ts", SDK_EVENTS); + const staticCore = write("static/core.ts", STATIC_CORE); + const facade = write("gen/core_facade.ts", "export const facade = 1;\n"); + const profile = write("gen/profile.json", '{"entry":"core_facade.ts"}\n'); + const out = path.join(root, "stage"); + + execFileSync(process.execPath, [ + stager, + "--src", path.join(root, "src"), + "--sdk", path.join(root, "sdk"), + "--static", staticCore, + "--facade", facade, + "--profile", profile, + "--out", out, + ]); + + return { + core: fs.readFileSync(path.join(out, "sdk", "core.ts"), "utf8"), + author: fs.readFileSync(path.join(out, "core.ts"), "utf8"), + }; +} + +for (const [label, eol] of [["LF", "\n"], ["CRLF", "\r\n"]] as const) { + test(`${label}: the static surface survives past a deduped alias`, () => { + const { core } = stage(eol); + // The regression: everything below the dropped alias used to vanish. + assert.match(core, /export const Cmd = \{/); + assert.match(core, /export type Sub = /); + }); + + test(`${label}: transform 4 still drops the duplicated alias`, () => { + const { core } = stage(eol); + // events.ts owns AudioState; exactly one declaration site may survive. + assert.ok(!/^export type AudioState =/m.test(core), "AudioState should be deduped out"); + assert.match(core, /export type Msgish = /); + }); + + test(`${label}: transform 3 still folds the Bytes alias away`, () => { + const { author } = stage(eol); + assert.ok( + !/^export type Uint8Array = Uint8Array;\r?$/m.test(author), + "the folded Uint8Array alias should be dropped", + ); + assert.match(author, /export type AppMsg = /); + }); +} + +test("CRLF staging agrees with LF staging line for line", () => { + const lf = stage("\n"); + const crlf = stage("\r\n"); + const normalize = (s: string) => s.replaceAll("\r\n", "\n"); + assert.equal(normalize(crlf.core), normalize(lf.core)); + assert.equal(normalize(crlf.author), normalize(lf.author)); +});