diff --git a/config/scripts/ci-unit-dependency-graph.mjs b/config/scripts/ci-unit-dependency-graph.mjs index 7182f58f17a..3b205f1f700 100644 --- a/config/scripts/ci-unit-dependency-graph.mjs +++ b/config/scripts/ci-unit-dependency-graph.mjs @@ -2,6 +2,7 @@ import { globSync, readFileSync } from 'node:fs' import { posix, join } from 'node:path' import ts from 'typescript-api' import { createWorkspaceSourceResolver } from './workspace-source-exports.mjs' +import { hasIndirectUnitInputs } from './ci-unit-indirect-inputs.mjs' const EXTENSIONS = [ '', @@ -15,8 +16,6 @@ const EXTENSIONS = [ '/index.tsx', '/index.js' ] -const INDIRECT_INPUT = - /\b(?:readFile\w*|readdir\w*|glob\w*|spawn\w*|execFile\w*|execSync|runProcess\w*|fork|Worker|createRequire|getBuiltinModule)\b|\bimport\s*\(\s*[^'"\s]|\brequire\s*\(\s*[^'"\s]|\bnew\s+URL\s*\(/ function localPath(file, specifier) { if (specifier.startsWith('.')) { @@ -41,10 +40,15 @@ export function buildUnitDependencyGraph(sources, workspace = { resolve: () => n reverse.get(input).add(file) } for (const [file, source] of sources) { - if (INDIRECT_INPUT.test(source) || file.startsWith('config/') || file.startsWith('tests/')) { + const imports = ts.preProcessFile(source, true, true).importedFiles + if ( + file.startsWith('config/') || + file.startsWith('tests/') || + hasIndirectUnitInputs(file, source, imports) + ) { opaque.add(file) } - for (const imported of ts.preProcessFile(source, true, true).importedFiles) { + for (const imported of imports) { if (imported.fileName === 'module' || imported.fileName === 'node:module') { opaque.add(file) } diff --git a/config/scripts/ci-unit-indirect-inputs.mjs b/config/scripts/ci-unit-indirect-inputs.mjs new file mode 100644 index 00000000000..31d2c560b0d --- /dev/null +++ b/config/scripts/ci-unit-indirect-inputs.mjs @@ -0,0 +1,84 @@ +import ts from 'typescript-api' +import { literalString, propertyName, unwrap } from './static-module-specifiers.mjs' + +const INDIRECT_NAME = + /^(?:readFile\w*|readdir\w*|glob(?:Sync|Stream|Iterate|SyncIterate|StreamSync)?|spawn(?:Sync)?|execFile(?:Sync)?|execSync|runProcess\w*|fork|Worker|createRequire|getBuiltinModule|eval|Function)$/ +const INDIRECT_CANDIDATE = new RegExp( + `\\b${INDIRECT_NAME.source.slice(1, -1)}\\b|\\b(?:import|require)\\s*\\(|\\bURL\\b` +) +const INDIRECT_MODULES = new Set([ + 'fs', + 'node:fs', + 'fs/promises', + 'node:fs/promises', + 'module', + 'node:module', + 'child_process', + 'node:child_process', + 'worker_threads', + 'node:worker_threads', + 'vm', + 'node:vm', + 'glob', + 'fast-glob', + 'globby', + 'tinyglobby' +]) + +export function hasIndirectUnitInputs(file, contents, imports) { + if (file.endsWith('.json')) { + return false + } + if (imports.some(({ fileName }) => INDIRECT_MODULES.has(fileName))) { + return true + } + if (!INDIRECT_CANDIDATE.test(contents)) { + return false + } + const source = ts.createSourceFile(file, contents, ts.ScriptTarget.Latest, false) + if (source.parseDiagnostics.length > 0) { + return true + } + function visit(node) { + if ( + ts.isTypeNode(node) || + ts.isInterfaceDeclaration(node) || + ts.isTypeAliasDeclaration(node) || + ((ts.isImportClause(node) || + ts.isImportSpecifier(node) || + ts.isExportDeclaration(node) || + ts.isExportSpecifier(node)) && + node.isTypeOnly) + ) { + return false + } + if (ts.isIdentifier(node) && INDIRECT_NAME.test(node.text)) { + return true + } + if (ts.isElementAccessExpression(node) && INDIRECT_NAME.test(propertyName(node) ?? '')) { + return true + } + if (ts.isCallExpression(node)) { + const callee = unwrap(node.expression) + if ( + (callee.kind === ts.SyntaxKind.ImportKeyword || + (ts.isIdentifier(callee) && callee.text === 'require')) && + literalString(node.arguments[0]) === null + ) { + return true + } + } + if ( + ts.isNewExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === 'URL' + ) { + const url = literalString(node.arguments?.[0]) + if (url === null || !/^(?:https?|wss?):/.test(url)) { + return true + } + } + return ts.forEachChild(node, visit) ?? false + } + return visit(source) +} diff --git a/config/scripts/ci-unit-indirect-inputs.test.mjs b/config/scripts/ci-unit-indirect-inputs.test.mjs new file mode 100644 index 00000000000..e15f7573f11 --- /dev/null +++ b/config/scripts/ci-unit-indirect-inputs.test.mjs @@ -0,0 +1,65 @@ +import { expect, it } from 'vitest' +import { buildUnitDependencyGraph } from './ci-unit-dependency-graph.mjs' +import { selectUnitFiles } from './ci-unit-selection.mjs' + +function selected(source) { + const sources = new Map([ + ['src/changed.ts', 'export const value = 1'], + ['src/changed.test.ts', "import './changed'"], + ['src/indirect.ts', source], + ['src/indirect.test.ts', "import './indirect'"] + ]) + const graph = buildUnitDependencyGraph(sources) + return selectUnitFiles(['src/changed.test.ts', 'src/indirect.test.ts'], ['src/changed.ts'], { + ...graph, + files: new Set(sources.keys()) + }).files +} + +it.each([ + '// spawn a process, readFileSync, glob, new URL(path)', + 'export const value = globalThis.value ?? global.value', + "export const prose = 'spawn readFileSync glob'", + 'export interface Config { spawnToken: string; Worker: string }', + 'export type Config = { readFile: string; global: boolean }', + "export const url = new URL('https://example.test/data')", + 'export const spawnToken = "opaque-token"' +])('does not retain unrelated tests for syntax that loads no repository inputs: %s', (source) => { + expect(selected(source)).toEqual(['src/changed.test.ts']) +}) + +it.each([ + "import { readFileSync as read } from 'node:fs'; export const value = read(path)", + "import * as fs from 'node:fs'; export const value = fs['read' + 'FileSync'](path)", + "import { spawn as launch } from 'node:child_process'; launch(binary)", + "import { glob as find } from 'tinyglobby'; find(pattern)", + "import * as vm from 'node:vm'; vm['run' + 'InNewContext'](code)", + "import * as hostModule from 'node:module'; export const loader = hostModule['create' + 'Require'](path)", + "const file = new URL('./fixture.txt', import.meta.url)", + 'const url = new URL(runtimeValue)', + 'import(computedModule)', + 'require(computedModule)', + "const read = fs['readFileSync']; read(path)", + 'const execute = runProcess; execute(command)', + 'const load = process.getBuiltinModule; load(moduleName)', + 'eval(source)', + 'const execute = new Function(source); execute()', + 'spawn( @@@' +])( + 'retains indirect consumers even for aliases, computed access and invalid syntax: %s', + (source) => { + expect(selected(source)).toEqual(['src/changed.test.ts', 'src/indirect.test.ts']) + } +) + +it('retains type-import ownership edges independently of runtime opacity', () => { + const graph = buildUnitDependencyGraph( + new Map([ + ['src/types.ts', 'export interface Worker { id: string }'], + ['src/consumer.ts', "import type { Worker } from './types'"], + ['src/value.json', '{"global":true}'] + ]) + ) + expect([...graph.reverse.get('src/types.ts')]).toEqual(['src/consumer.ts']) + expect(graph.opaque.size).toBe(0) +})