chore(ratchet): gate event-loop-blocking calls in the daemon, PTY and orcad code (#34614)

* fix(daemon): read the PTY process table without blocking on terminal kill and resize

The terminal daemon force-killed a session by running ps and reading
/proc/<pid>/stat synchronously, and checked the resize foreground group
with execFileSync('ps'). A slow or wedged ps froze every terminal the
daemon serves for up to the ps timeout on each call.

Both paths now use the async process-table read (runProcess with a
timeout that kills the child). SubprocessHandle.forceKill may return a
promise; the termination controller awaits it so a refused signal still
releases the claim for a retry. A force-kill or SIGWINCH whose root
exits while ps runs signals nothing, since the captured groups could
already be recycled. ConPTY force-kill stays synchronous so dispose()
still sees the node-pty kill it must not repeat. The in-process
provider's app-quit sweep keeps the blocking variant. The ps dialect
and table readers move to posix-pty-process-table.ts.

Refs #34410
Refs #33870

* fix(daemon): address review on async PTY process-table reads

Guard only the root-pid fallback on exit so proven groups are still swept
after dispose, wait out at most one ps dialect probe, move the sync-launch
baseline entry with the table reader, and keep tests cross-platform.

Refs #34410
Refs #33870

* fix(daemon): keep the PTY open until an in-flight force-kill sweep finishes

A sweep whose root exits mid-read must not signal: the freed TTY may
belong to another terminal. Dispose therefore defers destroy() until the
sweep settles, so its hangup cannot end the root first.

Refs #34410
Refs #33870

* test(daemon): build the destroy-tracking PTY mock without a type assertion

Refs #34410
Refs #33870

* fix(pty): make the local graceful-deadline force-kill async and stub ps in tests

The in-process provider's graceful-deadline timer now uses the async
group sweep; the app-quit sweep and the root-signal path stay
synchronous. Tests that drove force-kill through fixture pids now stub
the async process-table read instead of querying the host, and a new
test pins that waiters do not chain behind a failed dialect probe.
Session ids are only read for rows on the root's terminal.

Refs #34410
Refs #33870

* fix(pty): hold PTY teardown until an in-flight force-kill sweep settles

Orphan cleanup could destroy a local PTY while the graceful-deadline
sweep was still reading the process table, neutralizing its fallback
kill and hanging up the root first. Both the daemon handle and the
local provider now defer teardown through PendingPtyGroupSweep.

Concurrent force-kill callers in the daemon now await the shared
in-flight request, so a joiner's physical-exit budget starts after the
signals are sent and it sees their rejection.

Refs #34410
Refs #33870

* fix(pty): let disposal and orphan cleanup join the in-flight force-kill sweep

Session disposal now joins the controller's shared force-kill request
instead of starting a second sweep. Local orphan cleanup decides its
escalation only after a pending graceful-deadline sweep settles, so a
refused sweep is escalated before the handle is released rather than
leaving the retry with a neutralized kill.

Refs #34410
Refs #33870

* fix(daemon): SIGKILL live PTY groups before a shutdown deadline exit

The old synchronous sweep sent its signals before the shutdown deadline
timer could run; the async sweep can still be reading the process table
when it fires. The deadline now SIGKILLs each live session's process
group synchronously before exiting (node-pty roots lead their own
group), falling back to the root pid when the group kill is refused.
POSIX only.

Refs #34410
Refs #33870

* chore(ratchet): gate event-loop-blocking calls in the daemon, PTY and orcad code

The terminal daemon and orcad serve every pane from one event loop, so a
synchronous child launch or file write there freezes all of them. A new
count ratchet tracks execFileSync/spawnSync/execSync, runProcessSync,
appendFileSync and writeSync per file under src/main/daemon,
src/main/pty, src/main/runtime and src/main/orcad; counts may only go
down. It reuses the sync-spawn ratchet's import-aware call collector,
now extracted to gated-call-use-collector.mjs, so aliases, namespace
imports and re-exports count and same-named methods on other objects
do not.

Refs #34410
Refs #33870
This commit is contained in:
Neil
2026-10-27 19:05:34 -07:00
committed by GitHub
parent 3a4ac5445c
commit 61337dfb33
9 changed files with 405 additions and 245 deletions
+13
View File
@@ -0,0 +1,13 @@
# Event-loop-blocking calls in the terminal daemon, PTY and orcad runtime code, per file:
# execFileSync/spawnSync/execSync, runProcessSync, appendFileSync and writeSync.
# This is a RATCHET: counts may only go DOWN.
# Prune after removing calls: pnpm check:daemon-blocking-call-ratchet --prune
2 src/main/daemon/daemon-cgroup-scope.ts
1 src/main/daemon/daemon-file-log.ts
1 src/main/daemon/daemon-process-identity-query.ts
1 src/main/daemon/daemon-process-start-time.ts
2 src/main/daemon/daemon-stream-backlog-probe.ts
1 src/main/daemon/daemon-user-manager-lifetime.ts
1 src/main/daemon/terminal-scopes.ts
2 src/main/orcad/node-pty-precondition.ts
1 src/main/pty/posix-pty-process-table.ts
@@ -60,7 +60,22 @@ const baselineName =
const baselineFile = path.join(root, 'config/scripts', `.benchmark-baseline-${baselineName}`)
const runnerFile = path.join(output, 'baseline-smoke-runner.mjs')
const mobileBundle = path.join(output, 'smoke-mobile-web')
writeFileSync(baselineFile, baselineSource(`config/scripts/${baselineName}`))
// The sync-spawn ratchet's analysis lives in a shared module; compare the parent's copy of it too.
const collectorName = 'gated-call-use-collector.mjs'
const baselineCollectorFile = path.join(
root,
'config/scripts',
`.benchmark-baseline-${collectorName}`
)
let baselineScript = baselineSource(`config/scripts/${baselineName}`)
if (baselineScript.includes(`./${collectorName}`)) {
writeFileSync(baselineCollectorFile, baselineSource(`config/scripts/${collectorName}`))
baselineScript = baselineScript.replaceAll(
`./${collectorName}`,
`./.benchmark-baseline-${collectorName}`
)
}
writeFileSync(baselineFile, baselineScript)
try {
if (comparison !== 'smoke') {
@@ -186,5 +201,6 @@ for (const [program, args] of ${JSON.stringify(steps)}) {
console.log(JSON.stringify(report.summary))
} finally {
rmSync(baselineFile, { force: true })
rmSync(baselineCollectorFile, { force: true })
rmSync(runnerFile, { force: true })
}
+15 -1
View File
@@ -20,6 +20,7 @@ const LOCALES = 'src/renderer/src/i18n/'
// Ratchets default to SOURCE_ROOTS; list narrower inputs only where a scan is clearly scoped.
const RATCHET_INPUTS = {
'check-daemon-blocking-call-ratchet.mjs': ['src/main/'],
'check-e2e-typecheck-ratchet.mjs': ['tests/', 'config/tsconfig.e2e.json'],
'check-focus-read-ratchet.mjs': ['src/renderer/src/', 'src/shared/'],
'check-host-list-render-ratchet.mjs': ['src/renderer/src/'],
@@ -168,6 +169,14 @@ export function selectTypecheckProjects(files) {
).map(({ config }) => config)
}
// Shared analysis modules select every ratchet built on them.
const RATCHET_SHARED_MODULES = {
'config/scripts/gated-call-use-collector.mjs': [
'check-daemon-blocking-call-ratchet.mjs',
'check-main-sync-spawn-ratchet.mjs'
]
}
function listRatchetChecks(scriptsDir) {
const scripts = new Set(listRatchetScripts(scriptsDir))
// `pnpm lint` runs these although CI checks them in their own steps outside run-ratchets.mjs.
@@ -177,7 +186,12 @@ function listRatchetChecks(scriptsDir) {
name: script.replace(/^check-|\.mjs$/g, ''),
args: [`config/scripts/${script}`],
inputs: RATCHET_INPUTS[script] ?? SOURCE_ROOTS,
matches: (files) => files.some((file) => /^config\/[^/]+-baseline\.txt$/.test(file))
matches: (files) =>
files.some(
(file) =>
/^config\/[^/]+-baseline\.txt$/.test(file) ||
RATCHET_SHARED_MODULES[file]?.includes(script)
)
}))
}
@@ -0,0 +1,70 @@
import process from 'node:process'
import { pathToFileURL } from 'node:url'
import { createGatedCallCollector } from './gated-call-use-collector.mjs'
import { readTrackedSources, runCountRatchet } from './ratchet-baseline.mjs'
import { isTestOnlySourcePath } from './test-only-source-path.mjs'
// The terminal daemon and orcad serve every pane from one event loop; a blocking call there freezes all of them (#34410).
const BASELINE_PATH = 'config/daemon-blocking-call-baseline.txt'
const SCAN_ROOTS = ['src/main/daemon', 'src/main/pty', 'src/main/runtime', 'src/main/orcad']
const PROCESS_ADVICE = 'use runProcess or spawnProcess from @orca/process-host'
const WRITE_ADVICE = 'use an async write or a buffered stream'
const BLOCKING_CALLS = [
{
modules: ['child_process', 'node:child_process'],
advice: { execFileSync: PROCESS_ADVICE, spawnSync: PROCESS_ADVICE, execSync: PROCESS_ADVICE }
},
{ modules: ['@orca/process-host'], advice: { runProcessSync: PROCESS_ADVICE } },
{
modules: ['fs', 'node:fs'],
advice: { appendFileSync: WRITE_ADVICE, writeSync: WRITE_ADVICE }
}
]
const collector = createGatedCallCollector(BLOCKING_CALLS, 'daemon blocking-call')
function isScannedPath(rel) {
return /\.[cm]?[jt]sx?$/.test(rel) && !rel.endsWith('.d.ts') && !isTestOnlySourcePath(rel)
}
export function countBlockingCalls(rel, source) {
return collector.collectUses(rel, source).count
}
export function collectDaemonBlockingCallCounts(root = process.cwd()) {
const counts = new Map()
for (const [rel, source] of readTrackedSources(root, SCAN_ROOTS, isScannedPath)) {
const count = countBlockingCalls(rel, source)
if (count > 0) {
counts.set(rel, count)
}
}
return counts
}
export function main(root = process.cwd(), arg = undefined) {
return runCountRatchet(
{
name: 'daemon-blocking-call',
baselinePath: BASELINE_PATH,
header: [
'# Event-loop-blocking calls in the terminal daemon, PTY and orcad runtime code, per file:',
'# execFileSync/spawnSync/execSync, runProcessSync, appendFileSync and writeSync.',
'# This is a RATCHET: counts may only go DOWN.',
'# Prune after removing calls: pnpm check:daemon-blocking-call-ratchet --prune'
],
collect: collectDaemonBlockingCallCounts,
unit: 'blocking call(s)',
fix: [
`Child processes: ${PROCESS_ADVICE}. File writes: ${WRITE_ADVICE}.`,
'One stalled call freezes every terminal the daemon or orcad serves.'
]
},
root,
arg
)
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
process.exit(main(process.cwd(), process.argv[2]))
}
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest'
import { countBlockingCalls } from './check-daemon-blocking-call-ratchet.mjs'
const count = (source) => countBlockingCalls('src/main/daemon/example.ts', source)
describe('countBlockingCalls', () => {
it('counts synchronous file writes through named, namespace and default imports', () => {
expect(
count(
"import { appendFileSync } from 'node:fs'\nimport * as fs from 'fs'\nimport nodeFs from 'node:fs'\n" +
'appendFileSync(p, line)\nfs.writeSync(fd, line)\nnodeFs.appendFileSync(p, line)'
)
).toBe(3)
})
it('ignores same-named methods on other objects and other fs calls', () => {
expect(
count(
"import { writeFileSync } from 'node:fs'\nemulator.writeSync(chunk)\nwriter.writeSync(save)\nwriteFileSync(p, x)"
)
).toBe(0)
})
})
@@ -1,14 +1,13 @@
import { globSync, readFileSync } from 'node:fs'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
import ts from 'typescript-api'
import {
diffCounts,
parseCountBaseline,
pruneCountBaseline,
totalCount
} from './ratchet-baseline.mjs'
import { literalString, propertyName, unwrap } from './static-module-specifiers.mjs'
import { createGatedCallCollector } from './gated-call-use-collector.mjs'
import { isTestOnlySourcePath } from './test-only-source-path.mjs'
// Synchronous launches freeze all Electron windows; Windows antivirus magnifies the delay. Count launch APIs and synchronous wrappers per file.
@@ -48,14 +47,7 @@ const GATED_FAMILIES = [
{ modules: ['src/main/pwsh'], advice: { isPwshAvailable: 'use isPwshAvailableAsync' } },
{ modules: ['src/main/win32-utils'], advice: { grantDirAcl: 'use grantDirAclAsync' } }
]
const ADVICE = new Map(GATED_FAMILIES.flatMap((family) => Object.entries(family.advice)))
const GATED_MARKERS = [
...ADVICE.keys(),
...new Set(
GATED_FAMILIES.flatMap((family) => family.modules.map((module) => path.posix.basename(module)))
)
]
const FALLBACK_OPERATORS = new Set([ts.SyntaxKind.QuestionQuestionToken, ts.SyntaxKind.BarBarToken])
const collector = createGatedCallCollector(GATED_FAMILIES, 'sync-spawn')
const BASELINE_HEADER = [
'# Synchronous child-process launches in production src/main and src/shared, per file: uses of',
'# execFileSync/spawnSync/execSync, runProcessSync and the repo sync wrappers listed in',
@@ -70,231 +62,9 @@ function normalized(file) {
return file.replaceAll('\\', '/')
}
function moduleKey(file) {
return normalized(file).replace(/\.[cm]?[jt]sx?$/, '')
}
function symbolsOf(key) {
const names = GATED_FAMILIES.filter((family) => family.modules.includes(key)).flatMap((family) =>
Object.keys(family.advice)
)
return names.length > 0 ? new Set(names) : null
}
export function gatedSymbolsFor(file, specifier) {
const normalizedSpecifier = normalized(specifier)
if (!normalizedSpecifier.startsWith('.')) {
return symbolsOf(normalizedSpecifier)
}
return symbolsOf(
moduleKey(
path.posix.normalize(
path.posix.join(path.posix.dirname(normalized(file)), normalizedSpecifier)
)
)
)
}
function isValueReference(node) {
const parent = node.parent
if (
ts.isImportSpecifier(parent) ||
ts.isImportClause(parent) ||
ts.isNamespaceImport(parent) ||
ts.isImportEqualsDeclaration(parent)
) {
return false
}
if (ts.isExportSpecifier(parent)) {
return !parent.parent.parent.moduleSpecifier && node === (parent.propertyName ?? parent.name)
}
if (ts.isBindingElement(parent)) {
return node === parent.initializer
}
return (
ts.isShorthandPropertyAssignment(parent) ||
(parent.name !== node && parent.propertyName !== node && parent.label !== node)
)
}
// Count each gated value reference, alias use and re-export; imports alone do not count.
// File-wide, not scope-aware: computed specifiers/members and non-const holder flows are unresolved; local wrappers are not traced.
export function collectSyncLaunchUses(file, contents) {
// Escaped spellings and relative imports inside gated modules still need the full analysis.
const analyze =
contents.includes('\\') ||
GATED_MARKERS.some((marker) => contents.includes(marker) || file.includes(marker))
const source = ts.createSourceFile(file, contents, ts.ScriptTarget.Latest, analyze)
if (source.parseDiagnostics.length > 0) {
throw new Error(`Cannot parse ${file}; the sync-spawn ratchet must not skip it.`)
}
if (!analyze) {
return { count: 0, symbols: [] }
}
const used = new Set()
let count = 0
const namespaces = new Map()
const functions = new Map([...(symbolsOf(moduleKey(file)) ?? [])].map((name) => [name, name]))
const aliases = []
const members = []
const identifiers = []
const destructures = []
function moduleSymbols(node) {
node = unwrap(node)
if (!node) {
return null
}
if (ts.isIdentifier(node)) {
return namespaces.get(node.text) ?? null
}
const loads =
ts.isCallExpression(node) &&
(node.expression.kind === ts.SyntaxKind.ImportKeyword ||
(ts.isIdentifier(node.expression) && node.expression.text === 'require'))
const specifier = loads ? literalString(node.arguments[0]) : null
return specifier === null ? null : gatedSymbolsFor(file, specifier)
}
function gatedFunction(node) {
node = unwrap(node)
if (!node) {
return null
}
if (ts.isIdentifier(node)) {
return functions.get(node.text) ?? null
}
if (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) {
const name = propertyName(node)
return moduleSymbols(node.expression)?.has(name) ? name : null
}
if (ts.isConditionalExpression(node)) {
return gatedFunction(node.whenTrue) ?? gatedFunction(node.whenFalse)
}
if (ts.isBinaryExpression(node) && FALLBACK_OPERATORS.has(node.operatorToken.kind)) {
return gatedFunction(node.left) ?? gatedFunction(node.right)
}
return null
}
function recordUse(name) {
used.add(name)
count += 1
}
function visitImport(node) {
const symbols = gatedSymbolsFor(file, node.moduleSpecifier.text)
const clause = node.importClause
if (!symbols || !clause || clause.isTypeOnly) {
return
}
if (clause.name) {
namespaces.set(clause.name.text, symbols)
}
const named = clause.namedBindings
if (named && ts.isNamespaceImport(named)) {
namespaces.set(named.name.text, symbols)
}
for (const element of named && ts.isNamedImports(named) ? named.elements : []) {
const imported = (element.propertyName ?? element.name).text
if (!element.isTypeOnly && symbols.has(imported)) {
functions.set(element.name.text, imported)
}
}
}
function visitExport(node) {
const symbols =
node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier) && !node.isTypeOnly
? gatedSymbolsFor(file, node.moduleSpecifier.text)
: null
if (!symbols) {
return
}
const names =
!node.exportClause || ts.isNamespaceExport(node.exportClause)
? [...symbols]
: node.exportClause.elements
.filter((element) => !element.isTypeOnly)
.map((element) => (element.propertyName ?? element.name).text)
for (const name of names.filter((name) => symbols.has(name))) {
used.add(name)
count += 1
}
}
function visit(node) {
if (ts.isTypeNode(node) && !ts.isExpressionWithTypeArguments(node)) {
return
}
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
visitImport(node)
} else if (
ts.isImportEqualsDeclaration(node) &&
!node.isTypeOnly &&
ts.isExternalModuleReference(node.moduleReference) &&
ts.isStringLiteral(node.moduleReference.expression)
) {
const symbols = gatedSymbolsFor(file, node.moduleReference.expression.text)
if (symbols) {
namespaces.set(node.name.text, symbols)
}
} else if (ts.isExportDeclaration(node)) {
visitExport(node)
} else if (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) {
members.push(node)
} else if (ts.isIdentifier(node) && isValueReference(node)) {
identifiers.push(node)
} else if (ts.isVariableDeclaration(node) && node.initializer) {
if (ts.isIdentifier(node.name)) {
aliases.push({ name: node.name.text, initializer: node.initializer })
} else if (ts.isObjectBindingPattern(node.name)) {
destructures.push(node)
}
}
ts.forEachChild(node, visit)
}
visit(source)
// Aliases can chain (`const cp = require('child_process'); const run = cp.execFileSync`).
for (let changed = true; changed;) {
changed = false
for (const { name, initializer } of aliases) {
const symbols = moduleSymbols(initializer)
const gated = symbols ? null : gatedFunction(initializer)
if (symbols && !namespaces.has(name)) {
namespaces.set(name, symbols)
changed = true
} else if (gated && !functions.has(name)) {
functions.set(name, gated)
changed = true
}
}
for (const node of destructures) {
const symbols = moduleSymbols(node.initializer)
for (const element of symbols ? node.name.elements : []) {
const key = element.propertyName ?? element.name
const name = ts.isIdentifier(key) ? key.text : literalString(key)
if (
symbols.has(name) &&
ts.isIdentifier(element.name) &&
!functions.has(element.name.text)
) {
functions.set(element.name.text, name)
changed = true
}
}
}
}
for (const node of members) {
const name = propertyName(node)
if (moduleSymbols(node.expression)?.has(name)) {
recordUse(name)
}
}
for (const node of identifiers) {
const name = functions.get(node.text)
if (name) {
recordUse(name)
}
}
return { count, symbols: [...used].sort() }
}
export const gatedSymbolsFor = collector.gatedSymbolsFor
export const collectSyncLaunchUses = collector.collectUses
export const adviceFor = collector.adviceFor
export function isRatchetedSourcePath(file) {
return (
@@ -320,14 +90,6 @@ export function countSyncSpawnUses(sources) {
return { counts, symbols }
}
export function adviceFor(symbols) {
const groups = new Map()
for (const symbol of symbols) {
groups.set(ADVICE.get(symbol), [...(groups.get(ADVICE.get(symbol)) ?? []), symbol])
}
return [...groups].map(([advice, names]) => `${names.join(', ')}: ${advice}.`).join(' ')
}
export function collectSyncSpawnSources(root = ROOT) {
return new Map(
globSync(
+259
View File
@@ -0,0 +1,259 @@
import path from 'node:path'
import ts from 'typescript-api'
import { literalString, propertyName, unwrap } from './static-module-specifiers.mjs'
const FALLBACK_OPERATORS = new Set([ts.SyntaxKind.QuestionQuestionToken, ts.SyntaxKind.BarBarToken])
function normalized(file) {
return file.replaceAll('\\', '/')
}
function moduleKey(file) {
return normalized(file).replace(/\.[cm]?[jt]sx?$/, '')
}
function isValueReference(node) {
const parent = node.parent
if (
ts.isImportSpecifier(parent) ||
ts.isImportClause(parent) ||
ts.isNamespaceImport(parent) ||
ts.isImportEqualsDeclaration(parent)
) {
return false
}
if (ts.isExportSpecifier(parent)) {
return !parent.parent.parent.moduleSpecifier && node === (parent.propertyName ?? parent.name)
}
if (ts.isBindingElement(parent)) {
return node === parent.initializer
}
return (
ts.isShorthandPropertyAssignment(parent) ||
(parent.name !== node && parent.propertyName !== node && parent.label !== node)
)
}
/**
* Each family lists module specifiers (packages, or repo paths without extension) and the gated
* exports to count, each with advice for the failure message.
*/
export function createGatedCallCollector(families, ratchetName) {
const advice = new Map(families.flatMap((family) => Object.entries(family.advice)))
const markers = [
...advice.keys(),
...new Set(
families.flatMap((family) => family.modules.map((module) => path.posix.basename(module)))
)
]
function symbolsOf(key) {
const names = families
.filter((family) => family.modules.includes(key))
.flatMap((family) => Object.keys(family.advice))
return names.length > 0 ? new Set(names) : null
}
function gatedSymbolsFor(file, specifier) {
const normalizedSpecifier = normalized(specifier)
if (!normalizedSpecifier.startsWith('.')) {
return symbolsOf(normalizedSpecifier)
}
return symbolsOf(
moduleKey(
path.posix.normalize(
path.posix.join(path.posix.dirname(normalized(file)), normalizedSpecifier)
)
)
)
}
// Count each gated value reference, alias use and re-export; imports alone do not count.
// File-wide, not scope-aware: computed specifiers/members and non-const holder flows are unresolved; local wrappers are not traced.
function collectUses(file, contents) {
// Escaped spellings and relative imports inside gated modules still need the full analysis.
const analyze =
contents.includes('\\') ||
markers.some((marker) => contents.includes(marker) || file.includes(marker))
const source = ts.createSourceFile(file, contents, ts.ScriptTarget.Latest, analyze)
if (source.parseDiagnostics.length > 0) {
throw new Error(`Cannot parse ${file}; the ${ratchetName} ratchet must not skip it.`)
}
if (!analyze) {
return { count: 0, symbols: [] }
}
const used = new Set()
let count = 0
const namespaces = new Map()
const functions = new Map([...(symbolsOf(moduleKey(file)) ?? [])].map((name) => [name, name]))
const aliases = []
const members = []
const identifiers = []
const destructures = []
function moduleSymbols(node) {
node = unwrap(node)
if (!node) {
return null
}
if (ts.isIdentifier(node)) {
return namespaces.get(node.text) ?? null
}
const loads =
ts.isCallExpression(node) &&
(node.expression.kind === ts.SyntaxKind.ImportKeyword ||
(ts.isIdentifier(node.expression) && node.expression.text === 'require'))
const specifier = loads ? literalString(node.arguments[0]) : null
return specifier === null ? null : gatedSymbolsFor(file, specifier)
}
function gatedFunction(node) {
node = unwrap(node)
if (!node) {
return null
}
if (ts.isIdentifier(node)) {
return functions.get(node.text) ?? null
}
if (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) {
const name = propertyName(node)
return moduleSymbols(node.expression)?.has(name) ? name : null
}
if (ts.isConditionalExpression(node)) {
return gatedFunction(node.whenTrue) ?? gatedFunction(node.whenFalse)
}
if (ts.isBinaryExpression(node) && FALLBACK_OPERATORS.has(node.operatorToken.kind)) {
return gatedFunction(node.left) ?? gatedFunction(node.right)
}
return null
}
function recordUse(name) {
used.add(name)
count += 1
}
function visitImport(node) {
const symbols = gatedSymbolsFor(file, node.moduleSpecifier.text)
const clause = node.importClause
if (!symbols || !clause || clause.isTypeOnly) {
return
}
if (clause.name) {
namespaces.set(clause.name.text, symbols)
}
const named = clause.namedBindings
if (named && ts.isNamespaceImport(named)) {
namespaces.set(named.name.text, symbols)
}
for (const element of named && ts.isNamedImports(named) ? named.elements : []) {
const imported = (element.propertyName ?? element.name).text
if (!element.isTypeOnly && symbols.has(imported)) {
functions.set(element.name.text, imported)
}
}
}
function visitExport(node) {
const symbols =
node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier) && !node.isTypeOnly
? gatedSymbolsFor(file, node.moduleSpecifier.text)
: null
if (!symbols) {
return
}
const names =
!node.exportClause || ts.isNamespaceExport(node.exportClause)
? [...symbols]
: node.exportClause.elements
.filter((element) => !element.isTypeOnly)
.map((element) => (element.propertyName ?? element.name).text)
for (const name of names.filter((name) => symbols.has(name))) {
used.add(name)
count += 1
}
}
function visit(node) {
if (ts.isTypeNode(node) && !ts.isExpressionWithTypeArguments(node)) {
return
}
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
visitImport(node)
} else if (
ts.isImportEqualsDeclaration(node) &&
!node.isTypeOnly &&
ts.isExternalModuleReference(node.moduleReference) &&
ts.isStringLiteral(node.moduleReference.expression)
) {
const symbols = gatedSymbolsFor(file, node.moduleReference.expression.text)
if (symbols) {
namespaces.set(node.name.text, symbols)
}
} else if (ts.isExportDeclaration(node)) {
visitExport(node)
} else if (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) {
members.push(node)
} else if (ts.isIdentifier(node) && isValueReference(node)) {
identifiers.push(node)
} else if (ts.isVariableDeclaration(node) && node.initializer) {
if (ts.isIdentifier(node.name)) {
aliases.push({ name: node.name.text, initializer: node.initializer })
} else if (ts.isObjectBindingPattern(node.name)) {
destructures.push(node)
}
}
ts.forEachChild(node, visit)
}
visit(source)
// Aliases can chain (`const cp = require('child_process'); const run = cp.execFileSync`).
for (let changed = true; changed;) {
changed = false
for (const { name, initializer } of aliases) {
const symbols = moduleSymbols(initializer)
const gated = symbols ? null : gatedFunction(initializer)
if (symbols && !namespaces.has(name)) {
namespaces.set(name, symbols)
changed = true
} else if (gated && !functions.has(name)) {
functions.set(name, gated)
changed = true
}
}
for (const node of destructures) {
const symbols = moduleSymbols(node.initializer)
for (const element of symbols ? node.name.elements : []) {
const key = element.propertyName ?? element.name
const name = ts.isIdentifier(key) ? key.text : literalString(key)
if (
symbols.has(name) &&
ts.isIdentifier(element.name) &&
!functions.has(element.name.text)
) {
functions.set(element.name.text, name)
changed = true
}
}
}
}
for (const node of members) {
const name = propertyName(node)
if (moduleSymbols(node.expression)?.has(name)) {
recordUse(name)
}
}
for (const node of identifiers) {
const name = functions.get(node.text)
if (name) {
recordUse(name)
}
}
return { count, symbols: [...used].sort() }
}
function adviceFor(symbols) {
const groups = new Map()
for (const symbol of symbols) {
groups.set(advice.get(symbol), [...(groups.get(advice.get(symbol)) ?? []), symbol])
}
return [...groups].map(([advice, names]) => `${names.join(', ')}: ${advice}.`).join(' ')
}
return { collectUses, gatedSymbolsFor, adviceFor }
}
+1
View File
@@ -36,6 +36,7 @@ export const LINT_STEPS = [
'check:ts-nocheck-ratchet',
'check:workspace-record-read-ratchet',
'check:unproven-pty-retirement-ratchet',
'check:daemon-blocking-call-ratchet',
'check:node-runtime-pin',
'check:path-literal-ratchet',
'check:host-list-render-ratchet',
+1
View File
@@ -53,6 +53,7 @@
"check:path-literal-ratchet": "node config/scripts/check-path-literal-ratchet.mjs",
"check:legacy-relay-fence-ratchet": "node config/scripts/check-legacy-relay-fence-ratchet.mjs",
"check:unproven-pty-retirement-ratchet": "node config/scripts/check-unproven-pty-retirement-ratchet.mjs",
"check:daemon-blocking-call-ratchet": "node config/scripts/check-daemon-blocking-call-ratchet.mjs",
"check:id-only-store-write-ratchet": "node config/scripts/check-id-only-store-write-ratchet.mjs",
"check:repo-host-read-ratchet": "node config/scripts/check-repo-host-read-ratchet.mjs",
"check:workspace-record-read-ratchet": "node config/scripts/check-workspace-record-read-ratchet.mjs",