Files
orca/src/shared/source-scan/source-tree-scan.ts
0c9c3c00cf test(ci): ratchet Windows-gated tests into both registration lists (#18047)
* test(ci): ratchet Windows-gated tests into both registration lists

PR CI has one windows-2022 job running a curated explicit file list. Every
other job runs on ubuntu, where a Windows-gated suite self-skips and reports
success -- so an unregistered Windows-gated file executes on no machine and
passes green with nothing to tell the author.

Scans every test file for the win32 suite-level gate spellings in use plus the
.win32.test.* filename, and asserts each one appears in BOTH the
"Test Windows-specific boundaries" vitest argv and WINDOWS_PACKAGE_TESTS: the
classifier decides whether the job runs, the argv decides whether the file
runs. The eight already-unregistered files on main are held in a shrink-only
debt list.

* fix(ci): detect compound win32 gates in the lane-registration ratchet

The gate matcher anchored its argument on the closing paren, so
`runIf(platform === 'win32' && hasAddon)` was not matched at all -- the
guard excluded real Windows-gated files by accident of a regex rather
than by design, and would have missed a compound gate on a file that
genuinely needed registering.

Match the condition followed by `)` or `&&`, and resolve named flags from
their assignment in the same file, so `RUN_REAL = platform === 'win32' &&
env…` used as `runIf(RUN_REAL)` is detected whatever the flag is called
and whichever polarity it was written in. That replaces the hardcoded
`isWindows`/`IS_WINDOWS`/`isWin32` names, which guessed polarity from a
name; an imported flag stays undetected and is now documented with the
live example. `||` compounds are rejected on purpose: they can run off
Windows.

Ten env-opt-in suites surface as a result. They are win32-gated but also
require an `ORCA_REAL_*` env var, so registering them would not make CI
run them; they go in MANUAL_OPT_IN, whose entries are asserted to be
genuinely compound and env-gated so the list cannot become a quiet
parking spot.

Also: reuse `scanSourceTree` instead of a fifth divergent walk in the
repo (its docblock records the incident where a hand-rolled walk scanned
`tests/e2e/.cross-version-checkouts/`), adding an `extensions` option so
it can see `.mjs`; strip comments so prose about a gate is not a gate;
skip `mobile/`, which `classifyPrJobs` can never report as registered;
assert exactly one `windows-2022` job, the premise the guard rests on;
cap growth of both grandfathered lists; and test that the self-exemption
covers nothing but this file.

Corrects two docblock claims that were false: that nothing in the repo
computes a gate indirectly (three files did), and that a compound gate's
registration was asserted while only its execution was not (neither was).

* fix(ci): make the manual-opt-in exemption prove the env read reaches the gate

`requiresEnvOptIn` proved the file MENTIONED an env var, not that the gate
DEPENDED on one, so `runIf(platform === 'win32' && hasAddon)` in a file
that happens to read `process.env.RUNNER_TEMP` parked as manual. That is
the native-addon-bytes shape -- a test CI could run -- and only the cap
number stood in the way. Now the win32 check must be compound and one of
its other conjuncts must read `process.env` itself or name a const that
does, which still accepts all ten listed suites.

The compound clause guarding that hole was itself unasserted: deleting it
left every test green. Two fixtures close it, including an env read on the
same line as a bare gate, which is the case that makes the `&&` do work
rather than decorate.

Split FLAG_ASSIGNMENT by polarity. One shared `&&` lookahead was right for
`===` (a second conjunct narrows) and wrong for `!==` (it widens), so
`p = platform !== 'win32' && x` used as `skipIf(p)` read as Windows-only
though it runs on Windows and on POSIX when `x` is false. The literal form
was already rejected; routing it through a flag flipped the answer.

Widen the one-lane assertion from a `windows-2022` equality test to any
`runs-on` that could land on Windows -- `windows-latest`, a label array, a
`{ group, labels }` object -- treating an unresolvable `${{ }}` expression
as Windows so it fails closed.

Docblock: the case-level count is now deliberately approximate. The
reviewer measures 26 against this guard's 31; the figure moves with which
gate spellings are counted, and the policy does not rest on it.

---------

Co-authored-by: Orca Worker <orca-worker@localhost>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
2026-09-01 23:21:22 -07:00

292 lines
9.9 KiB
TypeScript

import { readdirSync, readFileSync, statSync } from 'node:fs'
import { join, relative } from 'node:path'
/**
* Shared file walk for the ratchet guards.
*
* Why one copy: four guards had grown their own `collectSourceFiles` /
* `isTestFile` / allowlist reader, and they had already drifted -- one skipped
* dot-directories and three did not, which is how the WSL separator guard came
* to scan `tests/e2e/.cross-version-checkouts/` and report 21 offenders that
* were copies of shipped releases. A guard that can be wrong about what it
* scanned is worse than no guard, because its count is the goalpost.
*/
const IGNORED_DIRECTORIES = new Set(['node_modules', 'dist', 'out', 'build', '.git'])
/** Tests may do the thing the guard forbids; that is often why they exist. */
export function isTestFile(relativePath: string): boolean {
return (
/\.(?:test|spec)\.tsx?$/.test(relativePath) ||
// `repro` must be a whole token: a bare substring exempted the shipped
// windows-terminal-capability-reprobe.ts from every guard using this walk.
/(?:test-harness|test-utils|test-setup|test-fixture|\brepro\b|reproduction)/.test(
relativePath
) ||
relativePath.includes('/__tests__/')
)
}
export type ScannedFile = { path: string; relativePath: string; source: string }
/**
* Every `.ts`/`.tsx` file under `root`, with its text.
*
* Dot-directories are skipped: they hold generated and vendored trees (the
* cross-version e2e checkouts among them), which are not ours to fix.
*
* `extensions` widens or narrows which filenames are read -- a guard over CI
* config also has to see `.mjs`, and one that only wants test files pays for
* reading nothing else.
*/
export function scanSourceTree(
root: string,
options: { includeTests?: boolean; extensions?: RegExp } = {}
): ScannedFile[] {
const extensions = options.extensions ?? /\.tsx?$/
const found: ScannedFile[] = []
const visit = (directory: string): void => {
for (const entry of readdirSync(directory)) {
if (IGNORED_DIRECTORIES.has(entry) || entry.startsWith('.') || entry === '__fixtures__') {
continue
}
const path = join(directory, entry)
if (statSync(path).isDirectory()) {
visit(path)
continue
}
if (!extensions.test(entry)) {
continue
}
const relativePath = relative(root, path).replace(/\\/g, '/')
if (!options.includeTests && isTestFile(relativePath)) {
continue
}
found.push({ path, relativePath, source: readFileSync(path, 'utf8') })
}
}
visit(root)
return found
}
/** Read a ratchet allowlist, dropping comments and blanks. */
export function readAllowlist(fixturePath: string): string[] {
return readFileSync(fixturePath, 'utf8')
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0 && !line.startsWith('#'))
}
/**
* Comments blanked out, so a construct documented in prose is not counted as code.
*
* Why a scanner and not two regexes: a POSIX glob inside a shell script written
* as a template literal contains a slash-star sequence, and the naive version
* read that as a comment opener, blanking everything to the next star-slash --
* 24,000 characters of live code in one file. A guard then read straight past a
* real unguarded spawn and reported the file clean, which is worse than no
* guard. Quote state is the difference, so it has to be tracked.
*/
export function stripComments(source: string): string {
let out = ''
let index = 0
let quote: string | null = null
while (index < source.length) {
const char = source[index]!
const next = source[index + 1]
if (quote) {
// Only a template literal may span lines. Resetting at a newline stops an
// apostrophe in prose, or a quote inside a regex literal, from swallowing
// the rest of the file and disabling comment stripping from there on.
if (char === '\n' && quote !== '`') {
quote = null
out += char
index += 1
continue
}
if (char === '\\') {
out += ' '
index += 2
continue
}
if (char === quote) {
quote = null
}
out += char
index += 1
continue
}
if (char === "'" || char === '"' || char === '`') {
quote = char
out += char
index += 1
continue
}
if (char === '/' && next === '*') {
const end = source.indexOf('*/', index + 2)
const stop = end === -1 ? source.length : end + 2
// Keep newlines so reported line numbers stay honest.
out += source.slice(index, stop).replace(/[^\n]/g, ' ')
index = stop
continue
}
if (char === '/' && next === '/') {
const end = source.indexOf('\n', index)
const stop = end === -1 ? source.length : end
out += ' '.repeat(stop - index)
index = stop
continue
}
out += char
index += 1
}
return out
}
/**
* String contents replaced by spaces, quotes kept.
*
* Why: a brace matcher that counts parentheses inside a shell script embedded
* as a string closes the call early, so the options object -- and any flag in
* it -- falls outside the matched range and reads as absent.
*/
/**
* True when the lexer could not keep its bearings through the file.
*
* Why callers must check this: three separate attempts to make the blanker
* exact all shipped with a desync that silently hid real calls, and each time
* the offender count went DOWN, which read as progress. A scanner that cannot
* say "I lost track here" will keep under-reporting. Treat a desync as an
* offender -- over-reporting is a nuisance, under-reporting is a false clean.
*/
export function blankStringContentsDesynced(source: string): boolean {
return blankStringContents(source, true) !== ''
}
/**
* After a value `/` is division; after an opener or a binary operator it opens
* a regex.
*
* The set is deliberately narrow, because the two errors are not symmetric. A
* false negative leaves a pattern unblanked, which at worst desyncs the lexer
* -- and every caller treats desync as an offender, so it fails closed. A
* false positive blanks live code, and a scan that cannot see a call reports
* it clean. A wider set cost 13 real JSX spans (`<Icon size={14} /> : <Icon`)
* and swallowed a whole `execFile(...)` after `n-- / 2`, with no desync to
* show for it.
*
* So the postfix and value-terminating characters are excluded even though
* each also has a prefix reading: `!` (non-null assertion vs `!/re/.test(x)`),
* `+` `-` `*` `%` `^` `~` (postfix `--`/`++`), and `>` `}` (JSX close).
*/
function startsRegexLiteral(emitted: string): boolean {
const prev = emitted.replace(/\s+$/, '').at(-1)
return prev === undefined || '(,=:[&|?;'.includes(prev)
}
/** End index (exclusive) of the regex literal opening at `start`, or -1. */
function findRegexLiteralEnd(source: string, start: number): number {
let inClass = false
for (let index = start + 1; index < source.length; index += 1) {
const char = source[index]
if (char === '\\') {
index += 1
continue
}
// A `/` inside `[...]` is literal, so it must not close the pattern.
if (char === '[') {
inClass = true
} else if (char === ']') {
inClass = false
} else if (char === '\n') {
return -1
} else if (char === '/' && !inClass) {
return index + 1
}
}
return -1
}
export function blankStringContents(source: string, reportDesync = false): string {
let out = ''
let index = 0
let quote: string | null = null
// Brace depth per interpolation, so a `}` inside `${ { a: 1 } }` does not
// close it. A plain counter mistook the first `}` for the closer.
const templates: number[] = []
while (index < source.length) {
const char = source[index]!
if (quote === '`' && char === '$' && source[index + 1] === '{') {
templates.push(0)
quote = null
out += '${'
index += 2
continue
}
if (quote === null && templates.length > 0) {
const depth = templates.at(-1) ?? 0
if (char === '{') {
templates[templates.length - 1] = depth + 1
} else if (char === '}') {
if (depth === 0) {
templates.pop()
quote = '`'
out += char
index += 1
continue
}
templates[templates.length - 1] = depth - 1
}
}
if (quote) {
// Same rule stripComments uses: only a template may span lines, so an
// apostrophe in a regex literal cannot invert the rest of the file. That
// desync dropped a real unguarded spawn out of the ratchet.
if (char === '\n' && quote !== '`') {
quote = null
out += char
index += 1
continue
}
if (char === '\\') {
out += ' '
index += 2
continue
}
if (char === quote) {
quote = null
out += char
} else {
out += char === '\n' ? char : ' '
}
index += 1
continue
}
// A regex literal can carry a lone apostrophe (`/'/g` in a shell quoter),
// which reads as a string opener and desyncs the rest of the file. The
// classic prev-token test disambiguates it from division: after a value a
// `/` divides, after an operator or opener it starts a pattern.
// `/*` and `//` open comments, never patterns. Callers normally strip
// comments first, but this runs standalone too, and at index 0 a file
// starting with a banner comment read as one giant regex.
const next = source[index + 1]
if (char === '/' && next !== '/' && next !== '*' && startsRegexLiteral(out)) {
const end = findRegexLiteralEnd(source, index)
if (end !== -1) {
out += `/${' '.repeat(end - index - 1)}`
index = end
continue
}
}
if (char === "'" || char === '"' || char === '`') {
quote = char
}
out += char
index += 1
}
if (reportDesync) {
return quote !== null || templates.length > 0 ? 'desynced' : ''
}
return out
}