Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -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
25 changes: 22 additions & 3 deletions packages/core/scripts/stage_external_core.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,15 @@ function resolveSpecifiers(text, rel) {
.replace(/readonly ([A-Za-z_][A-Za-z0-9_]*(?:<[A-Za-z_, ]*>)?)\[\]/g, "$1[]")
.replace(/(?<![A-Za-z0-9_])Bytes(?![A-Za-z0-9_])/g, "Uint8Array")
.split("\n")
.filter((line) => 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");
}
Expand All @@ -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);
Expand Down
121 changes: 121 additions & 0 deletions packages/core/test/stage_external_core.test.ts
Original file line number Diff line number Diff line change
@@ -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));
});
Loading