mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 17:37:40 +00:00
* build: fail the React Compiler ratchet on stale uncompiled-list paths - A listed path that no longer names a renderer source (a rename or split) fails the ratchet, so the file can't silently start compiling. - The build and the ratchet read the lists through one parser that drops trailing notes, resolved from the repo root rather than the current directory. * build: resolve uncompiled lists from import.meta.url and check listed paths on disk - electron-vite's config bundle rewrites import.meta.url per file but not import.meta.dirname, so the repo root came out wrong and desktop builds failed with ENOENT. - A listed path is stale only if no renderer file exists there; the scan's test-name filter no longer flags shipped files like notification-test-delivery.ts. * build: require uncompiled-list entries to name the exact file the build matches A ../ segment, a directory or a different letter case passed the existence check, but the build's exclusion matches only the exact path, so the file still compiled. * build: reject backslashes in uncompiled-list entries On Windows path.join turns a backslash into a separator, so the entry passed the check while the build's forward-slash match missed it.
388 lines
13 KiB
JavaScript
388 lines
13 KiB
JavaScript
import { Buffer } from 'node:buffer'
|
|
import { execFileSync } from 'node:child_process'
|
|
import fs from 'node:fs'
|
|
import path from 'node:path'
|
|
import process from 'node:process'
|
|
import { pathToFileURL } from 'node:url'
|
|
import { transform } from 'oxc-transform-react'
|
|
import {
|
|
diffCounts,
|
|
formatCountBaseline,
|
|
parseCountBaseline,
|
|
parseCountNotes,
|
|
pruneCountBaseline,
|
|
readTrackedSources,
|
|
totalCount
|
|
} from '../../ratchet-baseline/ratchet-baseline.mjs'
|
|
import {
|
|
HAZARD_FIXES,
|
|
LIVE_MODULE_READERS,
|
|
VIRTUALIZER_HOOKS,
|
|
findLiveReadWrappers,
|
|
findMemoizedCalls,
|
|
findSourceHazards,
|
|
findVirtualizerMemoFlows,
|
|
findVirtualizerWrappers,
|
|
hookCallLines,
|
|
renderLiveReadLines,
|
|
renderStoreReadLines
|
|
} from './react-compiler-hazards.mjs'
|
|
import {
|
|
UNCOMPILED_LIST,
|
|
readListedFiles
|
|
} from '../../../../build-plugins/react-compiler-uncompiled-files.mjs'
|
|
|
|
// Runs React Compiler (analysis only, nothing is written) over the renderer so code that compiles
|
|
// today keeps compiling, and code that would change behavior once compiled can't be added.
|
|
|
|
const SCAN_ROOT = 'src/renderer/src'
|
|
const BAILOUT_BASELINE = 'config/react-compiler-bailout-baseline.txt'
|
|
const HAZARD_BASELINE = 'config/react-compiler-hazard-baseline.txt'
|
|
const COMMAND = 'pnpm check:react-compiler-compat-ratchet'
|
|
const NOT_SHIPPED =
|
|
/(?:^|\/)__(?:mocks|fixtures|tests)__\/|\.(?:test|spec|bench)\.[cm]?tsx?$|[.-]test-|[.-]fixtures?\.tsx?$/
|
|
const COMPILER_OPTIONS = {
|
|
jsx: 'preserve',
|
|
reactCompiler: { target: '19', reportDiagnostics: true },
|
|
sourcemap: false
|
|
}
|
|
const IN_FLIGHT = 32
|
|
const BAILOUT_HEADER = [
|
|
'# React Compiler diagnostics (functions it skips) per renderer file. Generated; never hand-edit.',
|
|
`# Lower after a fix: ${COMMAND} --prune. Regenerate (compiler bump, reviewed exception): --update`
|
|
]
|
|
const HAZARD_HEADER = [
|
|
'# Grandfathered code React Compiler would run differently (<file>#<function>:<hazard>).',
|
|
`# Generated; never hand-edit. Lower after a fix: ${COMMAND} --prune`
|
|
]
|
|
const BAILOUT_FIXES = [
|
|
[
|
|
/Cannot access refs during render/,
|
|
'Read the ref in an effect or event handler, or keep the value in state.'
|
|
],
|
|
[
|
|
/cannot be modified|Cannot modify|Cannot reassign/,
|
|
'Copy before changing it; never mutate props, state, hook results or values after render.'
|
|
],
|
|
[
|
|
/TryStatement with a finalizer|`try`\/`finally`/,
|
|
'Move the try/finally into a module-level function the component calls.'
|
|
],
|
|
[/Logical assignment operators/, 'Rewrite `a ??= b` as an explicit `if (a == null) a = b`.'],
|
|
[/rule suppression/, 'Remove the react-hooks lint suppression and satisfy the rule.'],
|
|
[
|
|
/memoization could not be preserved/,
|
|
'Make the useMemo/useCallback deps list exactly what the callback reads.'
|
|
],
|
|
[/incompatible library/, 'Isolate the incompatible API in a small component or hook of its own.']
|
|
]
|
|
|
|
export function isScannedPath(rel) {
|
|
return (
|
|
rel.startsWith(`${SCAN_ROOT}/`) &&
|
|
/\.tsx?$/.test(rel) &&
|
|
!rel.endsWith('.d.ts') &&
|
|
!NOT_SHIPPED.test(rel.slice(SCAN_ROOT.length))
|
|
)
|
|
}
|
|
|
|
// Diagnostic spans are UTF-8 byte offsets.
|
|
function lineAt(bytes, offset) {
|
|
let line = 1
|
|
for (
|
|
let index = bytes.indexOf(10);
|
|
index !== -1 && index < offset;
|
|
index = bytes.indexOf(10, index + 1)
|
|
) {
|
|
line += 1
|
|
}
|
|
return line
|
|
}
|
|
|
|
function addTo(map, key, value) {
|
|
map.set(key, [...(map.get(key) ?? []), value])
|
|
}
|
|
|
|
// The build leaves these files uncompiled, so their hazards cannot run.
|
|
function readUncompiledFiles(root) {
|
|
return fs.existsSync(path.join(root, UNCOMPILED_LIST))
|
|
? new Set(readListedFiles(UNCOMPILED_LIST, root))
|
|
: new Set()
|
|
}
|
|
|
|
// Why: the build matches the exact id, so `\`, `../`, a directory or another case would silently compile.
|
|
function isCanonicalFile(root, rel) {
|
|
try {
|
|
const file = path.join(root, rel)
|
|
return (
|
|
!rel.includes('\\') &&
|
|
path.posix.normalize(rel) === rel &&
|
|
fs.statSync(file).isFile() &&
|
|
fs.realpathSync.native(file) === path.join(fs.realpathSync.native(root), rel)
|
|
)
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
/** Listed paths that name no renderer file; a renamed file would silently compile. */
|
|
export function findStaleUncompiledFiles(uncompiled, exists) {
|
|
return [...uncompiled].filter((rel) => !rel.startsWith(`${SCAN_ROOT}/`) || !exists(rel))
|
|
}
|
|
|
|
/** Virtualizer hooks plus every hook that returns one, followed through wrappers of wrappers. */
|
|
function collectVirtualizerHooks(sources) {
|
|
const hooks = new Map(VIRTUALIZER_HOOKS)
|
|
for (let grew = true; grew;) {
|
|
grew = false
|
|
for (const [rel, text] of sources) {
|
|
for (const { name, shape } of findVirtualizerWrappers(rel, text, hooks)) {
|
|
if (!hooks.has(name)) {
|
|
hooks.set(name, shape)
|
|
grew = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return hooks
|
|
}
|
|
|
|
/** Live module-state readers plus every plain function that calls one, followed through wrappers. */
|
|
function collectLiveReaders(sources) {
|
|
const readers = new Set(LIVE_MODULE_READERS)
|
|
for (let grew = true; grew;) {
|
|
grew = false
|
|
for (const [rel, text] of sources) {
|
|
for (const name of findLiveReadWrappers(rel, text, readers)) {
|
|
if (!readers.has(name)) {
|
|
readers.add(name)
|
|
grew = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return readers
|
|
}
|
|
|
|
function sourceLineOf(text, snippet) {
|
|
const index = text.split('\n').findIndex((line) => line.includes(snippet.split('(')[0]))
|
|
return index === -1 ? '?' : index + 1
|
|
}
|
|
|
|
async function scanFile(rel, text, result) {
|
|
const output = await transform(rel, text, COMPILER_OPTIONS)
|
|
if (output.fatal) {
|
|
addTo(result.diagnostics, rel, { line: 0, message: output.errors[0]?.message ?? 'fatal' })
|
|
return
|
|
}
|
|
result.compiled += output.code.match(/= _c\d*\(\d+\)/g)?.length ?? 0
|
|
const bytes = Buffer.from(text)
|
|
for (const error of output.errors) {
|
|
const message = error.message.split('\n')[0]
|
|
addTo(result.diagnostics, rel, { line: lineAt(bytes, error.labels[0]?.start ?? 0), message })
|
|
}
|
|
if (result.uncompiled.has(rel)) {
|
|
return
|
|
}
|
|
for (const { fn, text: snippet } of findVirtualizerMemoFlows(
|
|
rel,
|
|
text,
|
|
output.code,
|
|
result.virtualizerHooks
|
|
)) {
|
|
addTo(
|
|
result.hazards,
|
|
`${rel}#${fn}:virtualizer-in-memo-block`,
|
|
`${sourceLineOf(text, snippet)}: ${snippet}`
|
|
)
|
|
}
|
|
for (const hazard of findSourceHazards(rel, text)) {
|
|
addTo(result.hazards, `${rel}#${hazard.fn}:${hazard.kind}`, `${hazard.line}: ${hazard.text}`)
|
|
}
|
|
const storeReadFns = new Set()
|
|
for (const { fn, kind, name } of findMemoizedCalls(rel, output.code, result.liveReaders)) {
|
|
if (kind === 'store-read') {
|
|
storeReadFns.add(fn)
|
|
continue
|
|
}
|
|
if (kind === 'live-read') {
|
|
// A call inside the source's own useMemo or a closure keeps its written behavior.
|
|
for (const line of renderLiveReadLines(rel, text, fn, name)) {
|
|
addTo(result.hazards, `${rel}#${fn}:live-read-in-memo-block`, `${line}: ${name}()`)
|
|
}
|
|
continue
|
|
}
|
|
const lines = hookCallLines(rel, text, fn, name)
|
|
addTo(result.hazards, `${rel}#${fn}:hook-in-memo-block`, `${lines.join(', ')}: ${name}()`)
|
|
}
|
|
for (const fn of storeReadFns) {
|
|
// A read inside the source's own useMemo keeps its written deps, so only render-level ones count.
|
|
for (const line of renderStoreReadLines(rel, text, fn)) {
|
|
addTo(result.hazards, `${rel}#${fn}:store-read-in-memo-block`, `${line}: getState()`)
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function scanRenderer(root = process.cwd()) {
|
|
const sources = [...readTrackedSources(root, [SCAN_ROOT], isScannedPath)]
|
|
const uncompiled = readUncompiledFiles(root)
|
|
const result = {
|
|
files: 0,
|
|
compiled: 0,
|
|
diagnostics: new Map(),
|
|
hazards: new Map(),
|
|
uncompiled,
|
|
staleUncompiled: findStaleUncompiledFiles(uncompiled, (rel) => isCanonicalFile(root, rel)),
|
|
virtualizerHooks: collectVirtualizerHooks(sources),
|
|
liveReaders: collectLiveReaders(sources)
|
|
}
|
|
const pending = [...sources]
|
|
result.files = pending.length
|
|
await Promise.all(
|
|
Array.from({ length: IN_FLIGHT }, async () => {
|
|
while (pending.length > 0) {
|
|
const [rel, text] = pending.shift()
|
|
await scanFile(rel, text, result)
|
|
}
|
|
})
|
|
)
|
|
return result
|
|
}
|
|
|
|
const countsOf = (details) =>
|
|
new Map(
|
|
[...details].map(([key, values]) => [key, values.length]).sort(([a], [b]) => (a < b ? -1 : 1))
|
|
)
|
|
|
|
function readBaseline(root, rel) {
|
|
const file = path.join(root, rel)
|
|
return fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : ''
|
|
}
|
|
|
|
function bailoutFix(message) {
|
|
return (
|
|
BAILOUT_FIXES.find(([pattern]) => pattern.test(message))?.[1] ??
|
|
'Fix or isolate the pattern so the function compiles.'
|
|
)
|
|
}
|
|
|
|
function reportGrowth(kind, grown, details) {
|
|
for (const { file, now, allowed } of grown) {
|
|
console.error(`::error::${file}: ${now} ${kind}, baseline allows ${allowed}.`)
|
|
for (const detail of details.get(file) ?? []) {
|
|
if (kind === 'bailout(s)') {
|
|
console.error(
|
|
` ${file}:${detail.line} ${detail.message}\n Fix: ${bailoutFix(detail.message)}`
|
|
)
|
|
} else {
|
|
console.error(` line ${detail}`)
|
|
}
|
|
}
|
|
if (kind === 'hazard(s)') {
|
|
console.error(` ${HAZARD_FIXES[file.slice(file.lastIndexOf(':') + 1)].join('\n ')}`)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Under check:changed (ORCA_CODE_QUALITY_BASE set) a shrink fails only in files the change touches;
|
|
// elsewhere, e.g. after a stale-CI merge to main, it is a notice so improvements never turn CI red.
|
|
export function listTouchedFiles(root, base = process.env.ORCA_CODE_QUALITY_BASE) {
|
|
if (!base) {
|
|
return null
|
|
}
|
|
try {
|
|
const git = (args) =>
|
|
execFileSync('git', args, { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 })
|
|
const mergeBase = git(['merge-base', 'HEAD', base]).trim()
|
|
const tracked = git(['diff', '--name-only', '--no-renames', '-z', mergeBase]).split('\0')
|
|
const untracked = git(['ls-files', '--others', '--exclude-standard', '-z']).split('\0')
|
|
return new Set([...tracked, ...untracked].filter(Boolean))
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
const fileOfKey = (key) => key.split('#')[0]
|
|
|
|
function check(root, label, baselinePath, current, details, touched) {
|
|
const { grown, shrunk } = diffCounts(
|
|
current,
|
|
parseCountBaseline(readBaseline(root, baselinePath))
|
|
)
|
|
reportGrowth(label, grown, details)
|
|
const enforced = new Set(
|
|
shrunk.filter(({ file }) => touched?.has(fileOfKey(file))).map(({ file }) => file)
|
|
)
|
|
for (const { file, now, allowed } of shrunk) {
|
|
const level = enforced.has(file) ? 'error' : 'notice'
|
|
const line = `::${level}::${file}: now ${now} ${label}, baseline ${allowed}. Record it: ${COMMAND} --prune`
|
|
if (level === 'error') {
|
|
console.error(line)
|
|
} else {
|
|
console.log(line)
|
|
}
|
|
}
|
|
if (shrunk.length > 0) {
|
|
const touchedNote =
|
|
enforced.size > 0
|
|
? ` (${enforced.size} in files this change touches, which must be recorded)`
|
|
: ''
|
|
const write = enforced.size > 0 ? console.error : console.log
|
|
write(
|
|
`\n${shrunk.length} baseline row(s) improved${touchedNote}.\n` +
|
|
`Lock it in: ${COMMAND} --prune\nthen commit ${baselinePath}.`
|
|
)
|
|
}
|
|
return grown.length + enforced.size === 0
|
|
}
|
|
|
|
function writeBaseline(root, baselinePath, header, current) {
|
|
const text = readBaseline(root, baselinePath)
|
|
fs.writeFileSync(
|
|
path.join(root, baselinePath),
|
|
formatCountBaseline(current, header, parseCountNotes(text))
|
|
)
|
|
}
|
|
|
|
export async function main(root = process.cwd(), arg = undefined) {
|
|
const startedAt = performance.now()
|
|
const scan = await scanRenderer(root)
|
|
const bailouts = countsOf(scan.diagnostics)
|
|
const hazards = countsOf(scan.hazards)
|
|
if (arg === '--prune' || arg === '--update') {
|
|
const write = arg === '--prune' ? pruneCountBaseline : writeBaseline
|
|
write(root, BAILOUT_BASELINE, BAILOUT_HEADER, bailouts)
|
|
write(root, HAZARD_BASELINE, HAZARD_HEADER, hazards)
|
|
console.log(
|
|
`Wrote ${BAILOUT_BASELINE} and ${HAZARD_BASELINE}; rerun ${COMMAND} to see what remains.`
|
|
)
|
|
return 0
|
|
}
|
|
for (const rel of scan.staleUncompiled) {
|
|
console.error(
|
|
`::error::${UNCOMPILED_LIST}: ${rel} is not a renderer file. ` +
|
|
'Point it at the renamed file, or remove it once the file is gone.'
|
|
)
|
|
}
|
|
const touched = listTouchedFiles(root)
|
|
const ok = [
|
|
scan.staleUncompiled.length === 0,
|
|
check(root, 'bailout(s)', BAILOUT_BASELINE, bailouts, scan.diagnostics, touched),
|
|
check(root, 'hazard(s)', HAZARD_BASELINE, hazards, scan.hazards, touched)
|
|
].every(Boolean)
|
|
const seconds = ((performance.now() - startedAt) / 1000).toFixed(1)
|
|
if (!ok) {
|
|
console.error(`\nreact-compiler-compat ratchet failed (${scan.files} files, ${seconds}s).`)
|
|
return 1
|
|
}
|
|
console.log(
|
|
`react-compiler-compat ratchet OK — ${scan.compiled} functions compile in ${scan.files} files; ` +
|
|
`${totalCount(bailouts)} grandfathered bailouts, ${totalCount(hazards)} hazards (${seconds}s).`
|
|
)
|
|
return 0
|
|
}
|
|
|
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
process.exit(await main(process.cwd(), process.argv[2]))
|
|
}
|