Files
orca/src/shared/source-scan/source-tree-scan.ts
T
6c1d95b0da perf(tooling): reuse directory entry types in source scans (#20212)
* perf(tooling): reuse directory entry types in source scans

* fix(source-scan): stat DT_UNKNOWN dirents so untyped directories are still walked

`readdirSync(..., { withFileTypes: true })` can hand back a Dirent whose
type the filesystem did not report. For that entry every predicate is
false, so the readdir-type fast path treated a real directory as a file
and silently dropped its subtree from every ratchet guard. Fall back to
`statSync` whenever the entry is neither conclusively a file nor a
directory, keeping the no-stat fast path for ordinary entries.

Also make the two readdir-order assertions in the walk test
order-independent; `scanSourceTree` returns raw readdir order, which
differs on tmpfs.

* test(source-scan): unit-test the stat fallback via an extracted helper

The fabricated-Dirent readdir mock could not satisfy both gates at once:
vi.mocked(readdirSync) resolves to Node's Dirent<NonSharedBuffer> overload, so
the mock needed a type assertion, and #19462's casting gate rejects new ones on
changed lines. Removing the cast then failed tsc.

Extract directoryEntryNeedsStat and test it directly with a structural probe.
No mock, no cast, no top-level await, and the DT_UNKNOWN case is pinned:
removing the fallback fails 'stats an entry whose type readdir could not report'.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Neil <neil@stably.ai>
2026-09-12 22:38:25 -07:00

317 lines
11 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 }
/** The three readdir type predicates the walk consults. */
type DirentTypeProbe = {
isSymbolicLink(): boolean
isFile(): boolean
isDirectory(): boolean
}
/**
* Links need a stat to follow them, and so does DT_UNKNOWN (every predicate
* false) -- filesystems that do not report d_type would otherwise have a real
* directory silently dropped from the scan.
*/
export function directoryEntryNeedsStat(entry: DirentTypeProbe): boolean {
return entry.isSymbolicLink() || (!entry.isFile() && !entry.isDirectory())
}
/**
* 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, { withFileTypes: true })) {
const name = entry.name
if (IGNORED_DIRECTORIES.has(name) || name.startsWith('.') || name === '__fixtures__') {
continue
}
const path = join(directory, name)
// Ordinary entries carry their type from readdir.
if (directoryEntryNeedsStat(entry) ? statSync(path).isDirectory() : entry.isDirectory()) {
visit(path)
continue
}
if (!extensions.test(name)) {
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(prev: string | undefined): boolean {
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 lastSignificantChar: string | undefined
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 += '${'
lastSignificantChar = '{'
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
lastSignificantChar = 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
lastSignificantChar = 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(lastSignificantChar)) {
const end = findRegexLiteralEnd(source, index)
if (end !== -1) {
out += `/${' '.repeat(end - index - 1)}`
lastSignificantChar = '/'
index = end
continue
}
}
if (char === "'" || char === '"' || char === '`') {
quote = char
}
out += char
if (/\S/.test(char)) {
lastSignificantChar = char
}
index += 1
}
if (reportDesync) {
return quote !== null || templates.length > 0 ? 'desynced' : ''
}
return out
}