Files
msd-core/scripts/gen-platform-conformance-tier.cjs
Tom Boucher 1e47560e34 feat(#4593): add a macOS-specific conformance tier, final phase of epic #4589 (#4607)
test-conformance's macos-latest leg (Phase 2, #4591) has been running the
same 546-file, Windows-oriented conformance-tier list as windows-latest --
built from signals like windows-shell-token/windows-env-var that have
nothing to do with macOS. Issue #4593 asked for macOS coverage sized to
its own evidence-backed surface (zsh dispatch, case-sensitivity, darwin-
specific behavior) instead.

Issue #4593 was filed before Phase 5 (#4603) existed and referenced
updating test-full's macOS legs -- that job is gone. Corrected the issue's
body before any code was touched: the "shrink from full replay" half of
the original ask was already done by Phase 5; what remained was narrowing
the still-Windows-oriented tier macOS was inheriting.

Two design assumptions were measured and rejected before accepting a
design (documented in docs/adr/4593-macos-conformance-tier-architecture.md):
- Reusing the general tier's signals minus its 3 Windows-specific
  categories barely narrows anything (546 -> 424, 78% retained) -- most
  files match multiple signals and only need one to survive exclusion.
- A standalone CRLF/autocrlf signal, despite the issue naming
  "CRLF-checkout behavior": even narrowed to /\bCRLF\b|autocrlf/i it hit
  143/930 files. Root cause: CRLF is primarily a Windows checkout concern
  in this codebase (ADR-1703 files it under DEFECT.WINDOWS-TEST-
  PORTABILITY), so the signal was really re-selecting Windows-relevant
  files already covered by the general tier, not narrowing macOS
  specifically.

Built 5 new, genuinely macOS-specific signals instead: darwin-literal
(darwin alone, not the general tier's win32-OR-darwin), zsh-dispatch,
case-sensitivity, plus chmod-mode-bit and symlink-keyword reused verbatim
from the general tier (genuinely Unix-relevant, not Windows-motivated).
Measured against the real tree: 196 of 930 eligible unit-suite files
(21%), versus the general tier's 546 (59%) -- a real, evidence-backed
narrowing.

scripts/gen-platform-conformance-tier.cjs gains classifyMacosContent/
classifyMacosTree/renderMacosGeneratedFile and a --target windows
(default, unchanged)/--target macos CLI flag, so the same generator
produces two independent, gated outputs rather than needing a second
script. New committed output: scripts/lib/macos-conformance-tier.
generated.cjs. .github/workflows/test.yml's test-conformance job: only
the macos-latest leg's file-list source changes; windows-latest is
byte-for-byte untouched. New shipped-file ripples handled proactively
(19 install-tree fixtures regenerated, bin/install.js registered).

An isolated code-review pass found one real defect: the ADR's per-
category count table had drifted by 1 (zsh-dispatch, case-sensitivity)
because the new test file's own fixture strings joined the tree it
classifies after the table was authored -- fixed, with the union total
(196, what CI actually gates on) confirmed unaffected. An isolated
security-review pass found no qualifying findings.

The ADR also records an explicit requirement for any future widening
proposal: check whether the motivating regression is already covered by
Phase 1's no-rendered-text-length-assert lint rule (#4590) before
re-proposing full macOS/Linux parity, since that is exactly what #4421's
root cause was (a rendered-text-length assertion, not a real behavioral
divergence).

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 14:37:15 -04:00

469 lines
20 KiB
JavaScript

#!/usr/bin/env node
'use strict';
/**
* Generates scripts/lib/platform-conformance-tier.generated.cjs — the list of
* test files under tests/**\/*.test.cjs whose CONTENT signals they exercise
* platform-specific behavior (Windows/macOS path quirks, raw child_process
* usage, chmod mode bits, etc.) and therefore need REAL-OS coverage rather
* than a Linux-only conformance lane (#4591).
*
* Only `unit`-suite test files (no suite suffix, per `scripts/run-tests.cjs`'s
* `suiteOf()`) are considered for the conformance tier. `install`-,
* `security`-, `slow`-, `integration`-, and `qa`-suffixed files are excluded
* entirely — never merely deprioritized — for three independent reasons: (1)
* `install`/`slow` are explicitly PR-excluded suites
* (`scripts/affected-tests-lib.cjs`'s `PR_EXCLUDED_SUITES`; "PRs must never
* select or run these"), and this generator's output feeds a `pull_request`-
* triggered job; (2) `integration`/`security` already run via their own
* separate, dedicated, unsharded, shard-1-only steps in the `test` job
* (.github/workflows/test.yml) — folding any of them into
* this job's generic `--files-from` + `--shard` invocation is unproven and,
* per the incident below, unsafe. (3) `qa` (loop-walk-suite files) already
* runs via its own separate, dedicated `qa-loop-walk` job
* (.github/workflows/test.yml) — the same rationale as (2): a purpose-built
* home already exists, so folding it into this job's generic invocation
* duplicates coverage without benefit. Real incident that surfaced this: a live
* CI run's `conformance test (windows-latest, shard 2/3)` job was killed with
* 11 tests in flight — including `tests/release-tarball-smoke.install.test.cjs`
* — because this generator had (wrongly) placed an `install`-suite file into
* the Linux-conformance candidate pool with no suite filtering at all.
*
* `classifyContent(content)` is the pure, exported classifier: it favors
* simple, auditable substring/regex matching over AST parsing, mirroring
* eslint-rules/lib/portability-vocab.cjs's own design stance (over-inclusion
* is the safe direction — a false positive costs one extra test running on a
* real OS; a false negative silently drops real-OS coverage).
*
* KNOWN LIMIT, disclosed deliberately: this is a STATIC content classifier,
* not a real per-file, per-OS behavioral diff. Epic #4589's issue #4591 asked
* for the cutover to be validated by "running the existing full matrix one
* more time as a parity baseline, diffing pass/fail per file between the
* real-OS runs and the Linux run" before moving any file into the Linux-only
* bulk. That literal per-file diff was NOT performed — no historical
* per-file, per-OS pass/fail dataset exists to diff against (GitHub Actions
* publishes coverage/QA artifacts from CI runs, not per-file JUnit results).
* What stands in for it: (1) the most recent push-triggered run on `next`
* (unconditionally full-matrix) is green on every OS for every file in this
* classification, confirmed before this classifier was built; (2) the
* legacy full-matrix job ran the WHOLE suite on real Windows/macOS as a
* non-gating safety net for one release cycle (.github/workflows/test.yml)
* before it was retired (#4603) — a classifier miss during that cycle would
* have surfaced as a visible warning there, not a silent gap. This is the same
* static-analysis-substitutes-for-real-OS-execution stance ADR-1703's whole
* rule catalog already takes; it is a real, disclosed limit, not a silent
* substitution.
*
* Usage:
* node scripts/gen-platform-conformance-tier.cjs # print summary to stdout
* node scripts/gen-platform-conformance-tier.cjs --write # write the generated file
* node scripts/gen-platform-conformance-tier.cjs --check # exit 1 if the committed file is stale
* node scripts/gen-platform-conformance-tier.cjs --target macos ... # same three modes, macOS-specific list (#4593)
* node scripts/gen-platform-conformance-tier.cjs --tests-dir <path> # override the tests/ root (tests only)
* node scripts/gen-platform-conformance-tier.cjs --out <path> # override the generated-file path (tests only)
*
* `--target` selects which of the two independent generated outputs this
* invocation targets: `windows` (default, the original #4591 behavior —
* omitting the flag is unchanged) or `macos` (#4593's narrower, macOS-
* specific list). Both write into the SAME committed-file conventions
* (`scripts/lib/platform-conformance-tier.generated.cjs` /
* `scripts/lib/macos-conformance-tier.generated.cjs`), so `package.json`'s
* `lint:generated-sync`/`regen:derived` chains invoke this script twice, once
* per target, rather than needing a second script file.
*
* `--tests-dir`/`--out` (or the TESTS_DIR/OUT_PATH env vars, flag takes
* precedence) exist solely so tests/platform-conformance-tier.test.cjs can
* point the CLI at a small temp fixture tree instead of this repo's real,
* 900+-file tests/ tree. Production usage (package.json's lint:generated-sync
* / regen:derived chains) passes no flags and gets the real repo paths.
*/
const fs = require('node:fs');
const path = require('node:path');
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
const { suiteOf } = require('./lib/suite-detection.cjs');
const ROOT = path.resolve(__dirname, '..');
const DEFAULT_TESTS_DIR = path.join(ROOT, 'tests');
const DEFAULT_OUT_PATH = path.join(ROOT, 'scripts', 'lib', 'platform-conformance-tier.generated.cjs');
const DEFAULT_MACOS_OUT_PATH = path.join(ROOT, 'scripts', 'lib', 'macos-conformance-tier.generated.cjs');
const GENERATED_HEADER =
'// GENERATED FILE — do not hand-edit. Run `node scripts/gen-platform-conformance-tier.cjs --write` to regenerate.\n';
const MACOS_GENERATED_HEADER =
'// GENERATED FILE — do not hand-edit. Run `node scripts/gen-platform-conformance-tier.cjs --target macos --write` to regenerate.\n' +
'// macOS-specific conformance tier (#4593), separate from and narrower than the general/\n' +
'// Windows-oriented tier in platform-conformance-tier.generated.cjs — see\n' +
'// docs/adr/4593-macos-conformance-tier-architecture.md for the full rationale.\n';
/**
* Detection categories (#4591 design doc). Each entry's `test` receives the
* raw file content string and returns true when that category's signal is
* present. Order matches the design doc's enumeration; `signals` in
* `classifyContent`'s return value preserves this order.
*/
const CATEGORIES = [
{
name: 'process-platform',
test: (content) => /process\.platform/.test(content),
},
{
name: 'os-platform',
test: (content) => /os\.platform\(\)/.test(content),
},
{
name: 'win32-darwin-literal',
test: (content) => /\bwin32\b/.test(content) || /\bdarwin\b/.test(content),
},
{
name: 'chmod-mode-bit',
test: (content) => /chmodSync|chmod\(/.test(content) || /0o[0-7]{3,4}\b/.test(content),
},
{
name: 'windows-shell-token',
test: (content) => /cmd\.exe|powershell|pwsh|ComSpec/i.test(content),
},
{
name: 'windows-env-var',
test: (content) => /\bPATHEXT\b|\bUSERPROFILE\b|\bHOMEDRIVE\b|\bHOMEPATH\b/.test(content),
},
{
name: 'process-seam-subprocess',
test: (content) => /\brunNode\(|\brunGit\(|\brunHook\(|\brunGsdTools\(|\bgitOrThrow\(/.test(content),
},
{
name: 'raw-child-process',
// Requires BOTH the child_process import/reference token AND one of the
// three call names in the same file — this is what keeps a same-named
// local identifier (e.g. a variable called `spawnResult`) from false-
// positiving: `spawnResult` never forms the substring `spawnSync(`.
test: (content) => {
if (/require\((['"])(?:node:)?child_process\1\)/.test(content)) return true;
if (!content.includes('child_process')) return false;
return /\bspawnSync\(|\bexecSync\(|\bexecFileSync\(/.test(content);
},
},
{
name: 'symlink-keyword',
// A leading `\b` with no trailing one, case-insensitive: this is
// deliberately NOT `/\bsymlink\b|\bSymlink\b/` (that literal pair would
// never match the dominant real-world call shape `symlinkSync(` /
// `readlinkSync(` — no word boundary exists between "symlink" and the
// immediately-following "Sync", both \w characters). The leading `\b`
// alone still excludes a mid-word embedding like "presymlink".
test: (content) => /\bsymlink/i.test(content),
},
{
name: 'hardcoded-path-vs-path-call',
// Intentionally coarse (design doc: over-inclusion is the safe
// direction): a path.* call ANYWHERE in the file plus a quoted
// forward-slash-leading string literal ANYWHERE in the file, with no
// attempt at proximity/scoping.
test: (content) =>
/path\.(join|resolve|dirname|basename|normalize|relative)\(/.test(content) &&
/['"`]\/[\w.\-/]*['"`]/.test(content),
},
];
// Two CATEGORIES entries precise enough for TEST-file classification (this
// module's own purpose) but far too broad for SOURCE-file reachability
// (scripts/ci-test-scope.cjs's #4592 use). Empirically verified: applying
// classifyContent to every file under src/ (235 files) flags 100 of them,
// driven almost entirely by these two categories; excluding them narrows it
// to 28 files, all verified to carry a genuine platform-conditional branch.
const NOISY_FOR_SOURCE_REACHABILITY = new Set(['hardcoded-path-vs-path-call', 'symlink-keyword']);
/**
* macOS-specific detection categories (#4593, design doc
* .gsd/phase/chore-4593-macos-conformance-tier/40-design.md). Built new,
* rather than reusing CATEGORIES above minus its Windows-specific entries,
* because that naive exclusion barely narrows anything (measured: 546 -> 424
* files, 78%) — most files match multiple general-tier signals simultaneously
* and only need ONE surviving signal to stay in. `chmod-mode-bit` and
* `symlink-keyword` ARE deliberately duplicated verbatim from CATEGORIES:
* both are genuinely Unix-relevant (chmod bits and symlink semantics differ
* materially on macOS), not Windows-motivated the way the rest of CATEGORIES
* is. A standalone CRLF/`autocrlf` signal was considered and rejected: even
* narrowed to `/\bCRLF\b|autocrlf/i` it still hit 143/930 files (15%) — CRLF
* is primarily a Windows checkout concern in this codebase (ADR-1703's
* `no-crlf-fragile-split` files it under DEFECT.WINDOWS-TEST-PORTABILITY),
* so a CRLF signal pulls in Windows-relevant files already covered by the
* general tier, not a macOS-narrowing one.
*/
const MACOS_CATEGORIES = [
{ name: 'darwin-literal', test: (content) => /\bdarwin\b/.test(content) },
{ name: 'zsh-dispatch', test: (content) => /\bzsh\b/i.test(content) },
{ name: 'case-sensitivity', test: (content) => /case.?insensitiv|case.?sensitiv/i.test(content) },
{ name: 'chmod-mode-bit', test: (content) => /chmodSync|chmod\(/.test(content) || /0o[0-7]{3,4}\b/.test(content) },
{ name: 'symlink-keyword', test: (content) => /\bsymlink/i.test(content) },
];
/**
* Pure classifier: given a test file's raw string content, returns which
* platform-conformance categories matched and whether the file needs real-OS
* coverage (true iff at least one category matched).
*
* @param {string} content
* @returns {{needsRealOs: boolean, signals: string[]}}
*/
function classifyContent(content) {
const text = typeof content === 'string' ? content : '';
const signals = [];
for (const category of CATEGORIES) {
if (category.test(text)) signals.push(category.name);
}
return { needsRealOs: signals.length > 0, signals };
}
/**
* Pure classifier, macOS-specific signal set (#4593). Same shape as
* classifyContent, against MACOS_CATEGORIES instead of CATEGORIES.
*
* @param {string} content
* @returns {{needsRealOs: boolean, signals: string[]}}
*/
function classifyMacosContent(content) {
const text = typeof content === 'string' ? content : '';
const signals = [];
for (const category of MACOS_CATEGORIES) {
if (category.test(text)) signals.push(category.name);
}
return { needsRealOs: signals.length > 0, signals };
}
/**
* Recursively collect every `*.test.cjs` file beneath `dir`.
* @param {string} dir
* @returns {string[]} absolute paths
*/
function walkTestFiles(dir) {
const out = [];
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return out;
}
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
out.push(...walkTestFiles(full));
} else if (entry.isFile() && entry.name.endsWith('.test.cjs')) {
out.push(full);
}
}
return out;
}
/**
* Classify every test file under `testsDir`, returning `{ total, files }`
* where `files` is the SORTED array of `tests/<...>.test.cjs`-relative paths
* (POSIX-normalized, per RULESET.CONTENT-PATH-NORMALIZATION) whose content
* needs real-OS coverage.
*
* @param {string} testsDir
* @returns {{ total: number, files: string[] }}
*/
function classifyTree(testsDir) {
const absoluteFiles = walkTestFiles(testsDir);
// Only unit-suite files (no suite suffix) are eligible for the conformance
// tier — see the header doc-comment for why suite-tagged files are excluded
// entirely rather than merely deprioritized.
const unitFiles = absoluteFiles.filter((absPath) => suiteOf(absPath) === null);
const flagged = [];
for (const absPath of unitFiles) {
const content = fs.readFileSync(absPath, 'utf8');
const { needsRealOs } = classifyContent(content);
if (needsRealOs) {
const rel = path.relative(testsDir, absPath).replace(/\\/g, '/');
flagged.push('tests/' + rel);
}
}
flagged.sort();
return { total: absoluteFiles.length, files: flagged };
}
/**
* Same walk/eligibility as classifyTree, classified with the macOS-specific
* signal set (#4593).
*
* @param {string} testsDir
* @returns {{ total: number, files: string[] }}
*/
function classifyMacosTree(testsDir) {
const absoluteFiles = walkTestFiles(testsDir);
const unitFiles = absoluteFiles.filter((absPath) => suiteOf(absPath) === null);
const flagged = [];
for (const absPath of unitFiles) {
const content = fs.readFileSync(absPath, 'utf8');
const { needsRealOs } = classifyMacosContent(content);
if (needsRealOs) {
const rel = path.relative(testsDir, absPath).replace(/\\/g, '/');
flagged.push('tests/' + rel);
}
}
flagged.sort();
return { total: absoluteFiles.length, files: flagged };
}
/**
* Render the generated `.cjs` module body — one array entry per line for a
* readable diff, matching scripts/lib/portability-vocab.cjs's array-literal
* style.
*
* @param {string[]} files - already sorted.
* @returns {string}
*/
function renderGeneratedFile(files) {
const lines = files.map((f) => ` ${JSON.stringify(f)},`).join('\n');
return (
GENERATED_HEADER +
"'use strict';\n\n" +
'module.exports = {\n' +
' CONFORMANCE_TIER_FILES: [\n' +
(lines.length > 0 ? lines + '\n' : '') +
' ],\n' +
'};\n'
);
}
/**
* Render scripts/lib/macos-conformance-tier.generated.cjs's module body,
* mirroring renderGeneratedFile exactly against the macOS export name.
*
* @param {string[]} files - already sorted.
* @returns {string}
*/
function renderMacosGeneratedFile(files) {
const lines = files.map((f) => ` ${JSON.stringify(f)},`).join('\n');
return (
MACOS_GENERATED_HEADER +
"'use strict';\n\n" +
'module.exports = {\n' +
' MACOS_CONFORMANCE_TIER_FILES: [\n' +
(lines.length > 0 ? lines + '\n' : '') +
' ],\n' +
'};\n'
);
}
/** Resolve the effective target/tests-dir/out-path from argv/env, flag beats env. */
function resolveOverrides(argv) {
let target = 'windows';
for (let i = 0; i < argv.length; i++) {
if (argv[i] === '--target') {
const value = argv[i + 1];
if (value !== 'windows' && value !== 'macos') {
throw new ExitError(1, 'gen-platform-conformance-tier: --target requires "windows" or "macos"');
}
target = value;
i++;
}
}
const defaultOutPath = target === 'macos' ? DEFAULT_MACOS_OUT_PATH : DEFAULT_OUT_PATH;
let testsDir = process.env.TESTS_DIR || DEFAULT_TESTS_DIR;
let outPath = process.env.OUT_PATH || defaultOutPath;
for (let i = 0; i < argv.length; i++) {
if (argv[i] === '--tests-dir') {
const value = argv[i + 1];
if (!value || value.startsWith('--')) {
throw new ExitError(1, 'gen-platform-conformance-tier: --tests-dir requires a path value');
}
testsDir = path.resolve(value);
i++;
} else if (argv[i] === '--out') {
const value = argv[i + 1];
if (!value || value.startsWith('--')) {
throw new ExitError(1, 'gen-platform-conformance-tier: --out requires a path value');
}
outPath = path.resolve(value);
i++;
}
}
return { target, testsDir: path.resolve(testsDir), outPath: path.resolve(outPath) };
}
function main() {
const argv = process.argv.slice(2);
const { target, testsDir, outPath } = resolveOverrides(argv);
const mode = argv.includes('--check') ? 'check' : argv.includes('--write') ? 'write' : 'print';
const isMacos = target === 'macos';
const label = isMacos ? 'gen-platform-conformance-tier --target macos' : 'gen-platform-conformance-tier';
const exportKey = isMacos ? 'MACOS_CONFORMANCE_TIER_FILES' : 'CONFORMANCE_TIER_FILES';
const { total, files } = isMacos ? classifyMacosTree(testsDir) : classifyTree(testsDir);
if (mode === 'write') {
fs.mkdirSync(path.dirname(outPath), { recursive: true });
fs.writeFileSync(outPath, isMacos ? renderMacosGeneratedFile(files) : renderGeneratedFile(files));
process.stdout.write(`Wrote ${outPath} (${files.length} conformance-tier file(s))\n`);
return;
}
if (mode === 'check') {
// Never trust a stale require cache — the committed file may have been
// rewritten (by --write, or by hand) since this process started.
let committed;
try {
const resolved = require.resolve(outPath);
delete require.cache[resolved];
committed = require(resolved);
} catch (err) {
throw new ExitError(
1,
`${label}: could not load ${outPath} — run ` +
`\`node scripts/gen-platform-conformance-tier.cjs${isMacos ? ' --target macos' : ''} --write\` first ` +
`(${err && err.message ? err.message : err})`,
);
}
const committedFiles = Array.isArray(committed[exportKey]) ? committed[exportKey] : [];
const committedSet = new Set(committedFiles);
const liveSet = new Set(files);
const added = files.filter((f) => !committedSet.has(f));
const removed = committedFiles.filter((f) => !liveSet.has(f));
if (added.length > 0 || removed.length > 0) {
process.stderr.write(
`${path.relative(ROOT, outPath).replace(/\\/g, '/')} is stale. Run:\n` +
` node scripts/gen-platform-conformance-tier.cjs${isMacos ? ' --target macos' : ''} --write\n\n`,
);
for (const f of added) process.stderr.write(' + ' + f + '\n');
for (const f of removed) process.stderr.write(' - ' + f + '\n');
throw new ExitError(1);
}
process.stdout.write(`ok ${label}: ${files.length} conformance-tier files, list matches\n`);
return;
}
// No flag: print a classification summary, write nothing.
process.stdout.write(
`${label}: ${total} file(s) scanned, ` +
`${files.length} need real OS, ${total - files.length} excluded (Linux-only conformance tier eligible)\n`,
);
}
/* c8 ignore next 3 -- CLI entry guard; this repo measures coverage with c8, which does not honor istanbul pragmas */
if (require.main === module) {
runMain(main);
}
module.exports = {
classifyContent,
CATEGORIES,
NOISY_FOR_SOURCE_REACHABILITY,
walkTestFiles,
classifyTree,
renderGeneratedFile,
classifyMacosContent,
MACOS_CATEGORIES,
classifyMacosTree,
renderMacosGeneratedFile,
};