refactor(scripts): use native TypeScript AST helpers

This commit is contained in:
Peter Steinberger
2026-07-14 10:21:56 -07:00
parent 0c8220ebf9
commit 0e3a46f8d4
7 changed files with 47 additions and 173 deletions
+11 -56
View File
@@ -9,8 +9,10 @@ import {
BUNDLED_PLUGIN_PATH_PREFIX,
BUNDLED_PLUGIN_ROOT_DIR,
} from "./lib/bundled-plugin-paths.mjs";
import { visitModuleSpecifiers } from "./lib/guard-inventory-utils.mjs";
import { optionalBundledClusterSet } from "./lib/optional-bundled-clusters.mjs";
import { escapeRegExp } from "./lib/regexp.mjs";
import { toLine } from "./lib/ts-guard-utils.mjs";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const srcRoot = path.join(repoRoot, "src");
@@ -157,10 +159,6 @@ async function walkAllCodeFiles(rootDir, options = {}) {
return out.toSorted((left, right) => normalizePath(left).localeCompare(normalizePath(right)));
}
function toLine(sourceFile, node) {
return sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
}
function resolveRelativeSpecifier(specifier, importerFile) {
if (!specifier.startsWith(".")) {
return null;
@@ -213,27 +211,9 @@ function collectPluginSdkImports(filePath, sourceFile) {
});
}
function visit(node) {
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
push("import", node.moduleSpecifier, node.moduleSpecifier.text);
} else if (
ts.isExportDeclaration(node) &&
node.moduleSpecifier &&
ts.isStringLiteral(node.moduleSpecifier)
) {
push("export", node.moduleSpecifier, node.moduleSpecifier.text);
} else if (
ts.isCallExpression(node) &&
node.expression.kind === ts.SyntaxKind.ImportKeyword &&
node.arguments.length === 1 &&
ts.isStringLiteral(node.arguments[0])
) {
push("dynamic-import", node.arguments[0], node.arguments[0].text);
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
visitModuleSpecifiers(ts, sourceFile, ({ kind, specifierNode, specifier }) => {
push(kind, specifierNode, specifier);
});
return entries;
}
@@ -245,15 +225,7 @@ async function collectCorePluginSdkImports() {
continue;
}
const source = await fs.readFile(filePath, "utf8");
const scriptKind =
filePath.endsWith(".tsx") || filePath.endsWith(".jsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
const sourceFile = ts.createSourceFile(
filePath,
source,
ts.ScriptTarget.Latest,
true,
scriptKind,
);
const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true);
inventory.push(...collectPluginSdkImports(filePath, sourceFile));
}
return inventory.toSorted(compareImports);
@@ -284,20 +256,11 @@ function collectOptionalClusterStaticImports(filePath, sourceFile) {
});
}
function visit(node) {
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
push("import", node.moduleSpecifier, node.moduleSpecifier.text);
} else if (
ts.isExportDeclaration(node) &&
node.moduleSpecifier &&
ts.isStringLiteral(node.moduleSpecifier)
) {
push("export", node.moduleSpecifier, node.moduleSpecifier.text);
visitModuleSpecifiers(ts, sourceFile, ({ kind, specifierNode, specifier }) => {
if (kind !== "dynamic-import") {
push(kind, specifierNode, specifier);
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
});
return entries;
}
@@ -310,15 +273,7 @@ async function collectOptionalClusterStaticLeaks() {
continue;
}
const source = await fs.readFile(filePath, "utf8");
const scriptKind =
filePath.endsWith(".tsx") || filePath.endsWith(".jsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
const sourceFile = ts.createSourceFile(
filePath,
source,
ts.ScriptTarget.Latest,
true,
scriptKind,
);
const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true);
inventory.push(...collectOptionalClusterStaticImports(filePath, sourceFile));
}
return inventory.toSorted((left, right) => {
+26 -55
View File
@@ -14,64 +14,44 @@ import {
const repoRoot = resolveRepoRoot(import.meta.url);
const defaultRoots = [path.join(repoRoot, "src"), path.join(repoRoot, "extensions")];
function readStringLiteral(node) {
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
return node.text;
}
return null;
}
function isTypeOnlyImportDeclaration(node) {
const clause = node.importClause;
if (!clause) {
return false;
}
if (clause.isTypeOnly) {
return true;
}
if (clause.name) {
return false;
}
const bindings = clause.namedBindings;
return (
Boolean(bindings) &&
ts.isNamedImports(bindings) &&
bindings.elements.length > 0 &&
bindings.elements.every((element) => element.isTypeOnly)
return Boolean(
clause &&
(ts.isTypeOnlyImportDeclaration(clause) ||
(!clause.name &&
ts.isNamedImports(clause.namedBindings) &&
clause.namedBindings.elements.length > 0 &&
clause.namedBindings.elements.every(ts.isTypeOnlyImportOrExportDeclaration))),
);
}
function isTypeOnlyExportDeclaration(node) {
if (node.isTypeOnly === true) {
return true;
}
const clause = node.exportClause;
return (
Boolean(clause) &&
ts.isNamedExports(clause) &&
clause.elements.length > 0 &&
clause.elements.every((element) => element.isTypeOnly)
node.isTypeOnly === true ||
Boolean(
clause &&
ts.isNamedExports(clause) &&
clause.elements.length > 0 &&
clause.elements.every(ts.isTypeOnlyImportOrExportDeclaration),
)
);
}
function readDeclarationName(node) {
function isExecuteDeclaration(node) {
if (
(ts.isFunctionDeclaration(node) ||
ts.isMethodDeclaration(node) ||
ts.isVariableDeclaration(node)) &&
node.name &&
ts.isIdentifier(node.name)
!ts.isFunctionDeclaration(node) &&
!ts.isMethodDeclaration(node) &&
!ts.isVariableDeclaration(node) &&
!ts.isPropertyAssignment(node)
) {
return node.name.text;
return false;
}
if (ts.isPropertyAssignment(node)) {
if (ts.isIdentifier(node.name) || ts.isStringLiteral(node.name)) {
return node.name.text;
}
}
return null;
const name = ts.getNameOfDeclaration(node);
return Boolean(
name && (ts.isIdentifier(name) || ts.isStringLiteral(name)) && name.text === "execute",
);
}
function isIgnoredTestHelperContent(content) {
@@ -99,7 +79,6 @@ export function findDynamicImportAdvisories(content, fileName = "source.ts") {
const staticRuntimeImports = new Map();
const dynamicImports = new Map();
const directExecuteImports = [];
const declarationStack = [];
const addLine = (map, specifier, line) => {
const lines = map.get(specifier) ?? [];
@@ -108,11 +87,6 @@ export function findDynamicImportAdvisories(content, fileName = "source.ts") {
};
const visit = (node) => {
const declarationName = readDeclarationName(node);
if (declarationName) {
declarationStack.push(declarationName);
}
if (
ts.isImportDeclaration(node) &&
ts.isStringLiteral(node.moduleSpecifier) &&
@@ -135,11 +109,11 @@ export function findDynamicImportAdvisories(content, fileName = "source.ts") {
node.expression.kind === ts.SyntaxKind.ImportKeyword &&
node.arguments.length > 0
) {
const specifier = readStringLiteral(node.arguments[0]);
const specifier = ts.isStringLiteralLike(node.arguments[0]) ? node.arguments[0].text : null;
if (specifier) {
const line = toLine(sourceFile, node);
addLine(dynamicImports, specifier, line);
if (declarationStack.includes("execute")) {
if (ts.findAncestor(node, isExecuteDeclaration)) {
directExecuteImports.push({
line,
reason: `direct dynamic import of "${specifier}" inside execute path; move it behind a cached loader`,
@@ -149,9 +123,6 @@ export function findDynamicImportAdvisories(content, fileName = "source.ts") {
}
ts.forEachChild(node, visit);
if (declarationName) {
declarationStack.pop();
}
};
visit(sourceFile);
+2 -12
View File
@@ -16,19 +16,9 @@ const repoRoot = resolveRepoRoot(import.meta.url);
const uiSourceDir = path.join(repoRoot, "ui", "src", "ui");
const allowedCallsites = new Set([path.join(uiSourceDir, "open-external-url.ts")]);
function asPropertyAccess(expression) {
if (ts.isPropertyAccessExpression(expression)) {
return expression;
}
if (typeof ts.isPropertyAccessChain === "function" && ts.isPropertyAccessChain(expression)) {
return expression;
}
return null;
}
function isRawWindowOpenCall(expression) {
const propertyAccess = asPropertyAccess(unwrapExpression(expression));
if (!propertyAccess || propertyAccess.name.text !== "open") {
const propertyAccess = unwrapExpression(expression);
if (!ts.isPropertyAccessExpression(propertyAccess) || propertyAccess.name.text !== "open") {
return false;
}
+1 -7
View File
@@ -71,13 +71,7 @@ async function collectViolations() {
for (const filePath of files) {
const sourceText = readFileSync(filePath, "utf8");
const sourceFile = ts.createSourceFile(
filePath,
sourceText,
ts.ScriptTarget.Latest,
true,
filePath.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
);
const sourceFile = ts.createSourceFile(filePath, sourceText, ts.ScriptTarget.Latest, true);
function push(kind, specifierNode, specifier) {
const subpath = parsePluginSdkSubpath(specifier);
+5 -20
View File
@@ -49,16 +49,6 @@ function normalizeRawCopyText(raw: string): string {
.trim();
}
function lineNumberForOffset(source: string, offset: number): number {
let line = 1;
for (let index = 0; index < offset && index < source.length; index += 1) {
if (source.charCodeAt(index) === 10) {
line += 1;
}
}
return line;
}
function parseDoubleQuotedString(raw: string): string {
try {
return JSON.parse(`"${raw}"`) as string;
@@ -118,6 +108,7 @@ export function collectControlUiRawCopyFromSource(params: {
const { filePath, source, sourceFile } = params;
const repoPath = toRepoPath(filePath);
const findings: RawCopyFinding[] = [];
const toLine = (offset: number) => sourceFile.getLineAndCharacterOfPosition(offset).line + 1;
const staticAttrPattern =
/\b(aria-label|placeholder|title)\s*=\s*"((?:(?!\$\{)[^"\\]|\\.)*?\p{L}(?:(?!\$\{)[^"\\]|\\.)*?)"/gu;
for (const match of source.matchAll(staticAttrPattern)) {
@@ -125,7 +116,7 @@ export function collectControlUiRawCopyFromSource(params: {
if (rawText) {
pushRawCopyFinding(findings, {
kind: "html-attribute",
line: lineNumberForOffset(source, match.index ?? 0),
line: toLine(match.index ?? 0),
name: match[1] ?? "attribute",
path: repoPath,
text: parseDoubleQuotedString(rawText),
@@ -140,7 +131,7 @@ export function collectControlUiRawCopyFromSource(params: {
if (rawText) {
pushRawCopyFinding(findings, {
kind: "object-property",
line: lineNumberForOffset(source, match.index ?? 0),
line: toLine(match.index ?? 0),
name: match[1] ?? "property",
path: repoPath,
text: parseDoubleQuotedString(rawText),
@@ -162,7 +153,7 @@ export function collectControlUiRawCopyFromSource(params: {
...node.template.templateSpans.map((span) => span.literal.text),
].join(INTERPOLATION_MARKER);
}
const line = lineNumberForOffset(source, node.template.getStart(sourceFile));
const line = toLine(node.template.getStart(sourceFile));
for (const match of logicalText.matchAll(attrPattern)) {
const rawText = match[2];
if (rawText?.includes(INTERPOLATION_MARKER)) {
@@ -199,13 +190,7 @@ async function collectFindings(): Promise<RawCopyFinding[]> {
const findings: RawCopyFinding[] = [];
for (const filePath of files.toSorted((left, right) => left.localeCompare(right))) {
const source = await readFile(filePath, "utf8");
const sourceFile = ts.createSourceFile(
filePath,
source,
ts.ScriptTarget.Latest,
true,
filePath.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
);
const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true);
findings.push(...collectControlUiRawCopyFromSource({ filePath, source, sourceFile }));
}
return findings;
+1 -3
View File
@@ -239,10 +239,9 @@ export function formatGroupedInventoryHuman(params, inventory) {
/** Parse TypeScript files and collect sorted inventory entries from each source file. */
export async function collectTypeScriptInventory(params) {
const inventory = [];
const scriptKind = params.scriptKind ?? params.ts.ScriptKind.TS;
for (const filePath of params.files) {
const cacheKey = `${scriptKind}:${filePath}`;
const cacheKey = filePath;
let sourceFile = parsedTypeScriptSourceCache.get(cacheKey);
if (!sourceFile) {
let source = sourceTextCache.get(filePath);
@@ -258,7 +257,6 @@ export async function collectTypeScriptInventory(params) {
source,
params.ts.ScriptTarget.Latest,
true,
scriptKind,
);
parsedTypeScriptSourceCache.set(cacheKey, sourceFile);
}
+1 -20
View File
@@ -200,27 +200,8 @@ function isTempDirHelperImportSpec(filePath, specifier) {
return stripKnownExtension(resolvedPath) === stripKnownExtension(TEMP_DIR_HELPER_PATH);
}
function scriptKindForFile(filePath) {
if (/\.[cm]?tsx$/u.test(filePath)) {
return ts.ScriptKind.TSX;
}
if (/\.[cm]?jsx$/u.test(filePath)) {
return ts.ScriptKind.JSX;
}
if (/\.[cm]?js$/u.test(filePath)) {
return ts.ScriptKind.JS;
}
return ts.ScriptKind.TS;
}
function createSourceFile(filePath, sourceText) {
return ts.createSourceFile(
filePath,
sourceText,
ts.ScriptTarget.Latest,
true,
scriptKindForFile(filePath),
);
return ts.createSourceFile(filePath, sourceText, ts.ScriptTarget.Latest, true);
}
function lineForNode(sourceFile, node) {