From 68741a516cfd2fbcdc4a342a6193cc4cfafead5d Mon Sep 17 00:00:00 2001 From: Orca Worker Date: Tue, 1 Sep 2026 19:34:39 -0700 Subject: [PATCH] fix(build): pin config/scripts LF so Windows can run their tests core.autocrlf=true ships in the Git-for-Windows system config, so a fresh Windows checkout materializes config/scripts/*.mjs with CRLF. Vite's SSR transform finds the shebang with /^#!.*\n/, and \r is a JS regex line terminator, so the pattern misses on CRLF: the hoisted import/export preamble lands at offset 0 ahead of the shebang, which then defeats the code[0] === '#' guard that blanks it. A literal #! survives into the middle of the module and every suite importing the script dies at load with SyntaxError: Invalid or unexpected token. Eight suites were unrunnable on Windows. .gitattributes already pinned eight of these scripts individually; replace those with one glob over the directory so the pin does not have to be remembered per file, and add a ratchet that fails when a shebanged script is left on the platform default. --- .gitattributes | 12 ++-- .../shebang-script-line-ending-pin.test.mjs | 70 +++++++++++++++++++ 2 files changed, 74 insertions(+), 8 deletions(-) create mode 100644 config/scripts/shebang-script-line-ending-pin.test.mjs diff --git a/.gitattributes b/.gitattributes index f4676d210bb..97eeed155a2 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,11 +1,7 @@ -/config/scripts/create-draft-release.mjs text eol=lf -/config/scripts/orca-dev.mjs text eol=lf -/config/scripts/latest-stable-release.mjs text eol=lf -/config/scripts/publish-complete-draft-releases.mjs text eol=lf -/config/scripts/release-rc-history.mjs text eol=lf -/config/scripts/run-internal-dev-setup.mjs text eol=lf -/config/scripts/verify-cli-bin.mjs text eol=lf -/config/scripts/verify-release-required-assets.mjs text eol=lf +# A shebang plus CRLF makes vite's SSR transform emit a literal `#!` mid-module, +# so any suite importing the script dies at load with a SyntaxError. Pin the whole +# directory rather than the scripts that happen to have a test today. +/config/scripts/**/*.mjs text eol=lf /skill-guides/*.md text eol=lf /skill-stubs/*.md text eol=lf /skills/*/SKILL.md text eol=lf diff --git a/config/scripts/shebang-script-line-ending-pin.test.mjs b/config/scripts/shebang-script-line-ending-pin.test.mjs new file mode 100644 index 00000000000..5537d258096 --- /dev/null +++ b/config/scripts/shebang-script-line-ending-pin.test.mjs @@ -0,0 +1,70 @@ +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +/** + * Guard the `.gitattributes` pin that keeps `config/scripts` scripts on LF. + * + * `core.autocrlf=true` ships in the Git-for-Windows system config, so without a + * pin a Windows checkout gets CRLF. Vite's SSR transform locates the shebang + * with `/^#!.*\n/` — `\r` is a JS regex line terminator, so `.` never matches it + * and the pattern misses on CRLF. The hoisted import/export preamble then lands + * at offset 0, ahead of the shebang, which in turn defeats the `code[0] === '#'` + * guard that blanks it. A literal `#!` survives into the middle of the module and + * every suite importing the script dies at load with a SyntaxError. + * + * Scoped to `config/scripts` because that is where tests import scripts. Other + * shebanged `.mjs` in the tree are spawned, not imported, so they cannot hit this. + */ +const projectDir = resolve(import.meta.dirname, '../..') +const SCRIPT_DIRECTORY = 'config/scripts' + +function git(args) { + return execFileSync('git', args, { cwd: projectDir, encoding: 'utf8' }) +} + +/** `git check-attr -z` emits NUL-separated path/attr/value triples. */ +function eolAttributes(paths) { + const fields = git(['check-attr', '-z', 'eol', '--', ...paths]).split('\0') + const found = new Map() + for (let index = 0; index + 2 < fields.length; index += 3) { + found.set(fields[index], fields[index + 2]) + } + return found +} + +function shebangScripts() { + return git(['ls-files', '-z', '--', `${SCRIPT_DIRECTORY}/*.mjs`]) + .split('\0') + .filter(Boolean) + .filter((path) => readFileSync(join(projectDir, path), 'utf8').startsWith('#!')) +} + +describe('config/scripts line-ending pin', () => { + it('pins every shebanged script to LF', () => { + const scripts = shebangScripts() + expect(scripts.length).toBeGreaterThan(0) + + const attributes = eolAttributes(scripts) + const unpinned = scripts.filter((path) => attributes.get(path) !== 'lf') + + expect( + unpinned, + 'A shebanged script left on the platform default gets CRLF on Windows, ' + + 'which makes every suite importing it fail to load. Pin it in .gitattributes.' + ).toEqual([]) + }) + + // Why: without these the assertion above still passes against a pattern so broad + // it says nothing, or so narrow it only covers the files that exist today. + it.each([ + ['config/scripts/example.mjs', 'lf'], + ['config/scripts/nested/deeper/example.mjs', 'lf'], + ['config/scripts-extra/example.mjs', 'unspecified'], + ['vendor/config/scripts/example.mjs', 'unspecified'], + ['config/scripts/example.mjsx', 'unspecified'] + ])('resolves %s to eol=%s', (path, expected) => { + expect(eolAttributes([path]).get(path)).toBe(expected) + }) +})