Files
msd-core/scripts/lint-windows-test-portability.cjs
Tom Boucher 837991d5a2 chore(#1087): Windows test-portability lint + n/no-path-concat + LF normalization (#1088)
* chore(#1087): add Windows test-portability lint + DEFECT.WINDOWS-TEST-PORTABILITY

Local gsd-test runs Mac+Linux only, so Windows-only test failures (Git Bash
msys2 not honoring Node's chmod exec bit for PATH-executing extension-less
scripts; `/` vs `\` path assertions) surface for the first time in CI's
windows lanes — repeatedly (most recently PR #1084's #381 fix).

Add scripts/lint-windows-test-portability.cjs: a high-signal, low-false-
positive tripwire that flags any tests/**/*.test.cjs combining a chmod exec
bit with a `sh -c`/`bash -c` invocation and no process.platform guard, unless
annotated `// windows-portability-ok: <reason>`. Wired into lint:ci and
runnable as `npm run lint:windows-test-portability`. Clean against all 721
current test files (zero pre-existing violations).

Document the broader anti-pattern as DEFECT.WINDOWS-TEST-PORTABILITY in
CONTEXT.md (.symptom/.examples/.detect/.fix-forward/.prevention): local
gsd-test cannot substitute for the CI windows lane — watch it green before
declaring a PR done.

tests/lint-windows-test-portability.test.cjs covers the scanContent matrix.

Closes #1087

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(#1087): enable n/no-path-concat + add .gitattributes/.editorconfig (LF)

Two cross-platform "free wins" complementing the Windows test-portability lint:
- Enable eslint-plugin-n's n/no-path-concat (already-installed plugin) as
  'error' — flags string path concatenation (the / vs \ separator class).
  Zero existing violations, so it's a clean ratchet, not a refactor.
- Add .gitattributes (* text=auto eol=lf + binary exemptions) and .editorconfig
  (LF, UTF-8, final newline, trim whitespace) to normalize line endings and
  kill the CRLF-only-fails-on-Windows class at the source.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 22:04:56 -04:00

179 lines
6.2 KiB
JavaScript

'use strict';
/**
* lint-windows-test-portability.cjs — flag tests that combine chmod exec-bit
* with bare sh/bash -c without a platform guard.
*
* ## Why
*
* Windows Git Bash (msys2) does not honour Node's chmod exec bit for
* PATH-executing extension-less scripts. A test that (a) makes a fixture
* executable via chmodSync and (b) runs it with `sh -c`/`bash -c` will pass
* on Mac/Linux but fail only in the CI `test (windows-latest, *)` /
* `full test (windows-latest, *)` lanes, producing a hard-to-diagnose
* false-negative gate. See CONTEXT.md → DEFECT.WINDOWS-TEST-PORTABILITY.
*
* ## What this enforces
*
* For every file in tests/**\/*.test.cjs (recursive, excluding node_modules):
* - makesExecutable: contains chmodSync?( with an exec-bit octal literal
* - shellDashC: contains a sh/bash -c invocation (array form or string literal)
* - guarded: contains a process.platform / os.platform() / win32 / isWindows guard
* - optOut: contains the literal `windows-portability-ok`
* VIOLATION = makesExecutable && shellDashC && !guarded && !optOut
*
* ## Remediation
*
* Gate the bare-command execution with `if (process.platform !== 'win32')`,
* or invoke via an explicit interpreter (`sh <path>`), or annotate
* `// windows-portability-ok: <reason>`.
*
* ## Export contract (for unit tests)
*
* When required as a module (`require.main !== module`) this file exports
* `scanContent(source)` → { makesExecutable, shellDashC, guarded, optOut, violation }.
*/
const fs = require('fs');
const path = require('path');
// ─── Detection regexes ──────────────────────────────────────────────────────
/**
* Match chmod/chmodSync( calls with an octal mode literal whose exec bits are
* set, e.g. `fs.chmodSync(p, 0o755)` or `chmod(file, 0o111)`.
*/
const CHMOD_RE = /chmod(?:Sync)?\s*\([^,;]+,\s*0o([0-7]{3})\b/g;
/**
* Array form: execFileSync/spawnSync/exec* with 'sh' or 'bash' (optionally
* prefixed) as the first arg and '-c' as an element of the args array.
* e.g. execFileSync('bash', ['-c', ...]) or spawnSync('/bin/sh', ['-c', ...])
*/
const SHELL_ARRAY_RE =
/(?:execFile(?:Sync)?|spawnSync|spawn|exec)\s*\(\s*['"`](?:\/(?:usr\/)?bin\/)?(?:bash|sh)['"`]\s*,\s*\[[^\]]*['"]-c['"]/;
/**
* String-literal form: any string containing `bash -c` or `sh -c`.
*/
const SHELL_STRING_RE = /['"`][^'"`\n]*(?:bash|sh)\s+-c[^'"`\n]*['"`]/;
/** Platform guard presence. */
const GUARD_RE = /process\.platform|os\.platform\s*\(|\bwin32\b|\bisWindows\b/;
/** Opt-out annotation. */
const OPT_OUT_RE = /windows-portability-ok/;
// ─── Pure scanning function (exported for unit tests) ────────────────────────
/**
* Scan a single file's source text and return detection flags.
*
* @param {string} source - The file contents as a string.
* @returns {{ makesExecutable: boolean, shellDashC: boolean, guarded: boolean, optOut: boolean, violation: boolean }}
*/
function scanContent(source) {
// Reset stateful regex before use.
CHMOD_RE.lastIndex = 0;
let makesExecutable = false;
let match;
while ((match = CHMOD_RE.exec(source)) !== null) {
const oct = match[1];
if ((parseInt(oct, 8) & 0o111) !== 0) {
makesExecutable = true;
break;
}
}
const shellDashC = SHELL_ARRAY_RE.test(source) || SHELL_STRING_RE.test(source);
const guarded = GUARD_RE.test(source);
const optOut = OPT_OUT_RE.test(source);
const violation = makesExecutable && shellDashC && !guarded && !optOut;
return { makesExecutable, shellDashC, guarded, optOut, violation };
}
// ─── Filesystem walker ───────────────────────────────────────────────────────
/**
* Recursively collect all *.test.cjs files under `dir`, excluding node_modules.
*
* @param {string} dir
* @param {string[]} [acc]
* @returns {string[]}
*/
function collectTestFiles(dir, acc) {
acc = acc || [];
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return acc;
}
for (const entry of entries) {
if (entry.name === 'node_modules') continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
collectTestFiles(full, acc);
} else if (entry.isFile() && entry.name.endsWith('.test.cjs')) {
acc.push(full);
}
}
return acc;
}
// ─── Main ────────────────────────────────────────────────────────────────────
function main() {
const ROOT = path.join(__dirname, '..');
const TESTS_DIR = path.join(ROOT, 'tests');
const files = collectTestFiles(TESTS_DIR);
const violations = [];
for (const file of files) {
let source;
try {
source = fs.readFileSync(file, 'utf8');
} catch {
continue;
}
const { violation } = scanContent(source);
if (violation) {
const rel = path.relative(ROOT, file).replace(/\\/g, '/');
violations.push(rel);
}
}
if (violations.length > 0) {
for (const rel of violations) {
process.stderr.write(
`${rel}: chmod-executable + sh/bash -c with no platform guard\n`,
);
}
process.stderr.write(
'\nWindows Git Bash does not honor Node\'s chmod exec bit for ' +
'PATH-executing extension-less scripts ' +
'(CONTEXT.md → DEFECT.WINDOWS-TEST-PORTABILITY). ' +
'Gate the bare-command execution with ' +
'`if (process.platform !== \'win32\')`, or invoke via an explicit ' +
'interpreter (`sh <path>`), or annotate ' +
'`// windows-portability-ok: <reason>`.\n',
);
process.exitCode = 1;
} else {
console.log(
`ok lint-windows-test-portability: ${files.length} file(s) scanned, no violations`,
);
}
}
// ─── Module boundary ─────────────────────────────────────────────────────────
if (require.main === module) {
main();
} else {
module.exports = { scanContent };
}