diff --git a/config/daemon-blocking-call-baseline.txt b/config/daemon-blocking-call-baseline.txt new file mode 100644 index 00000000000..05e615e08eb --- /dev/null +++ b/config/daemon-blocking-call-baseline.txt @@ -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 diff --git a/config/scripts/benchmark-ci-preflight-critical-path.mjs b/config/scripts/benchmark-ci-preflight-critical-path.mjs index 8978f5edd51..e65798e8087 100644 --- a/config/scripts/benchmark-ci-preflight-critical-path.mjs +++ b/config/scripts/benchmark-ci-preflight-critical-path.mjs @@ -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 }) } diff --git a/config/scripts/check-changed-selection.mjs b/config/scripts/check-changed-selection.mjs index 90391ceeb3f..92afb6899c4 100644 --- a/config/scripts/check-changed-selection.mjs +++ b/config/scripts/check-changed-selection.mjs @@ -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) + ) })) } diff --git a/config/scripts/check-daemon-blocking-call-ratchet.mjs b/config/scripts/check-daemon-blocking-call-ratchet.mjs new file mode 100644 index 00000000000..f4a1628bc57 --- /dev/null +++ b/config/scripts/check-daemon-blocking-call-ratchet.mjs @@ -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])) +} diff --git a/config/scripts/check-daemon-blocking-call-ratchet.test.mjs b/config/scripts/check-daemon-blocking-call-ratchet.test.mjs new file mode 100644 index 00000000000..215ff441c07 --- /dev/null +++ b/config/scripts/check-daemon-blocking-call-ratchet.test.mjs @@ -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) + }) +}) diff --git a/config/scripts/check-main-sync-spawn-ratchet.mjs b/config/scripts/check-main-sync-spawn-ratchet.mjs index b5b0f29334c..5df6cb8fc4f 100644 --- a/config/scripts/check-main-sync-spawn-ratchet.mjs +++ b/config/scripts/check-main-sync-spawn-ratchet.mjs @@ -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( diff --git a/config/scripts/gated-call-use-collector.mjs b/config/scripts/gated-call-use-collector.mjs new file mode 100644 index 00000000000..ba6b5487687 --- /dev/null +++ b/config/scripts/gated-call-use-collector.mjs @@ -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 } +} diff --git a/config/scripts/run-lint.mjs b/config/scripts/run-lint.mjs index 121e9a4a95d..51a5712ef0c 100644 --- a/config/scripts/run-lint.mjs +++ b/config/scripts/run-lint.mjs @@ -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', diff --git a/package.json b/package.json index 625a029bc54..e44cefc3133 100644 --- a/package.json +++ b/package.json @@ -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",