codemods/analyzers/sink.analyzer.ts
codemods/analyzers/sink.analyzer.ts is a file in GovLab Extension Host. 170 lines of code and 53 definitions.
import type { WriteFinding, WriteScope } from "../../types/analyzer.types.ts";
import { lineOf, relPath } from "../selectors/program.selector.ts";
import type { Edit } from "../../types/codemod.types.ts";
import { basename } from "node:path";
import ts from "typescript";
const PRIMITIVE = "writeFileSync";
const WRITER = "writeVerbatim";
const WRITER_MODULE = "@govlab/canonical-write";
const PRIMITIVE_MODULES = new Set(["fs", "node:fs"]);
const TEXT_ENCODING = "utf8";
const ENCODING_JOINER = "-";
const PLAIN_ARITY = 2;
const ENCODED_ARITY = 3;
const WRITER_IMPORT = `import { ${WRITER} } from "${WRITER_MODULE}";\n`;
const LINE_FEED = "\n";
const CARRIAGE_RETURN = "\r";
const isTextEncoding = function isTextEncoding(name: string): boolean {
return name.toLowerCase().split(ENCODING_JOINER).join("") === TEXT_ENCODING;
};
const moduleOf = function moduleOf(statement: ts.ImportDeclaration): string {
return ts.isStringLiteral(statement.moduleSpecifier) ? statement.moduleSpecifier.text : "";
};
const namedImportsOf = function namedImportsOf(statement: ts.ImportDeclaration): ts.NamedImports | null {
const bindings = statement.importClause?.namedBindings;
return bindings !== undefined && ts.isNamedImports(bindings) ? bindings : null;
};
const importsFrom = function importsFrom(source: ts.SourceFile, modules: ReadonlySet<string>): ts.ImportDeclaration[] {
return source.statements.filter(
(statement): statement is ts.ImportDeclaration =>
ts.isImportDeclaration(statement) && modules.has(moduleOf(statement)),
);
};
const primitiveSpecifier = function primitiveSpecifier(
statement: ts.ImportDeclaration,
): ts.ImportSpecifier | undefined {
return namedImportsOf(statement)?.elements.find(
(element) => element.name.text === PRIMITIVE && element.propertyName === undefined,
);
};
const referencesOf = function referencesOf(
checker: ts.TypeChecker,
source: ts.SourceFile,
specifier: ts.ImportSpecifier,
): ts.Identifier[] {
const target = checker.getSymbolAtLocation(specifier.name);
const out: ts.Identifier[] = [];
const visit = function visit(node: ts.Node): void {
if (ts.isImportDeclaration(node)) {
return;
}
if (ts.isIdentifier(node) && node.text === PRIMITIVE && checker.getSymbolAtLocation(node) === target) {
out.push(node);
}
ts.forEachChild(node, visit);
};
ts.forEachChild(source, visit);
return out;
};
const callOf = function callOf(reference: ts.Identifier): ts.CallExpression | null {
const { parent } = reference;
return ts.isCallExpression(parent) && parent.expression === reference ? parent : null;
};
const arityReason = function arityReason(call: ts.CallExpression): string | null {
const count = call.arguments.length;
if (count === PLAIN_ARITY) {
return null;
}
const encoding = call.arguments[PLAIN_ARITY];
if (count === ENCODED_ARITY && encoding !== undefined && ts.isStringLiteral(encoding)) {
return isTextEncoding(encoding.text)
? null
: `the write passes the '${encoding.text}' encoding, which the owner's verbatim write does not take`;
}
return `the write passes ${String(count)} arguments, where the owner's verbatim write takes a target and its content`;
};
const replacementOf = function replacementOf(source: ts.SourceFile, call: ts.CallExpression): string {
const [target, content] = call.arguments;
return `${WRITER}(${target?.getText(source) ?? ""}, ${content?.getText(source) ?? ""})`;
};
const lineEndAfter = function lineEndAfter(text: string, at: number): number {
if (text.charAt(at) === CARRIAGE_RETURN && text.charAt(at + 1) === LINE_FEED) {
return at + 2;
}
return text.charAt(at) === LINE_FEED ? at + 1 : at;
};
const namedText = function namedText(names: readonly string[]): string {
return `{ ${names.join(", ")} }`;
};
const writerImportEdits = function writerImportEdits(source: ts.SourceFile): Edit[] | null {
const existing = importsFrom(source, new Set([WRITER_MODULE])).find((statement) => namedImportsOf(statement));
const named = existing === undefined ? null : namedImportsOf(existing);
if (named === null) {
return null;
}
const names = named.elements.map((element) => element.getText(source));
if (named.elements.some((element) => element.name.text === WRITER)) {
return [];
}
return [{ end: named.getEnd(), replacement: namedText([...names, WRITER]), start: named.getStart(source) }];
};
const primitiveImportEdits = function primitiveImportEdits(
source: ts.SourceFile,
statement: ts.ImportDeclaration,
dropPrimitive: boolean,
writerLine: string,
): Edit[] {
const start = statement.getStart(source);
const named = namedImportsOf(statement);
const rest = (named?.elements ?? [])
.filter((element) => element.name.text !== PRIMITIVE)
.map((element) => element.getText(source));
const insert: Edit[] = writerLine.length > 0 ? [{ end: start, replacement: writerLine, start }] : [];
if (!dropPrimitive || named === null) {
return insert;
}
const soleBinding = rest.length === 0 && statement.importClause?.name === undefined;
if (soleBinding) {
const end = lineEndAfter(source.text, statement.getEnd());
return [{ end, replacement: writerLine, start }];
}
return [...insert, { end: named.getEnd(), replacement: namedText(rest), start: named.getStart(source) }];
};
const importEditsOf = function importEditsOf(
source: ts.SourceFile,
statement: ts.ImportDeclaration,
dropPrimitive: boolean,
): Edit[] {
const extended = writerImportEdits(source);
const writerLine = extended === null ? WRITER_IMPORT : "";
return [...(extended ?? []), ...primitiveImportEdits(source, statement, dropPrimitive, writerLine)];
};
const fileReason = function fileReason(source: ts.SourceFile, scope: WriteScope): string | null {
return scope.declaresWriter(source.fileName)
? null
: `the file's package does not declare ${WRITER_MODULE}, so the owner's writer cannot be imported here`;
};
export const scanSourceFile = function scanSourceFile(
checker: ts.TypeChecker,
source: ts.SourceFile,
scope: WriteScope,
): WriteFinding[] {
if (scope.owners.has(basename(source.fileName))) {
return [];
}
return importsFrom(source, PRIMITIVE_MODULES).flatMap((statement) => {
const specifier = primitiveSpecifier(statement);
if (specifier === undefined) {
return [];
}
const references = referencesOf(checker, source, specifier);
const blockedFile = fileReason(source, scope);
const calls = references.map(callOf);
const sites = calls.filter((call): call is ts.CallExpression => call !== null);
const reasons = sites.map((call) => blockedFile ?? arityReason(call));
const dropPrimitive = calls.every((call) => call !== null) && reasons.every((reason) => reason === null);
const importEdits = reasons.some((reason) => reason === null)
? importEditsOf(source, statement, dropPrimitive)
: [];
return sites.map((call, index) => ({
end: call.getEnd(),
file: relPath(source.fileName),
fileName: source.fileName,
importEdits,
line: lineOf(source, call),
reason: reasons[index] ?? null,
replacement: replacementOf(source, call),
start: call.getStart(source),
}));
});
};