feat: phase 0 foundation — monorepo scaffold, backend, frontend, CI, docker

Establishes the skeleton every later phase builds on: Go+Gin API with
zap logging, /api/healthz, SQLite+goose runner; Vite+React18+TS strict+
Tailwind v4 frontend with bento-mono theme tokens; openapi.yaml driving
a generated TS client; oxlint with vendored anti-slop rules; GitHub CI;
multi-stage Dockerfile -> 33.6MB distroless image; docker compose up
serves the blank board.


Generated with [Devin](https://devin.ai)

Authored By: TDvorak <[email protected]>
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas Dvorak committed 2026-09-17 20:29:21 +02:00
1 parent e7f32949e9
commit b60821350a
46 files changed
+6415 -10

No files matched your search

@@ -0,0 +1,515 @@
import type { ESTree } from "@oxlint/plugins";
import {
createTypeAliasEnvironment,
hasVisibleTypeBinding,
visibleTypeAlias,
type TypeAliasEnvironment as LexicalTypeAliasEnvironment,
} from "./type-alias-resolution.ts";
const BUILT_INS = new Set([
"Record",
"Readonly",
"Partial",
"Required",
"Pick",
"Omit",
"PropertyKey",
"NonNullable",
]);
const TRANSPARENT_WRAPPERS = new Set(["Readonly", "Partial", "Required", "NonNullable"]);
type TypeAliasEnvironment = ReadonlyMap<string, ESTree.TSType>;
type ResolvedType = {
readonly type: ESTree.TSType;
readonly substitutions: TypeAliasEnvironment;
};
export type UnsafeDictionary = {
readonly kind: "unsafe-dictionary";
readonly unsafeValue: "any" | "empty-object" | "object" | "union" | "unknown";
};
export type WideningTargetKind =
| "anonymous object"
| "generic container"
| "object"
| "open dictionary"
| "unknown";
export type WideningTarget = {
readonly kind: WideningTargetKind;
};
export type TypeEnvironment = {
readonly interfaces: ReadonlyMap<string, readonly ESTree.TSInterfaceDeclaration[]>;
readonly typeAliases: LexicalTypeAliasEnvironment;
};
function declaredStatement(statement: ESTree.Statement): ESTree.Node | null {
return statement.type === "ExportNamedDeclaration" ||
statement.type === "ExportDefaultDeclaration"
? (statement.declaration ?? null)
: statement;
}
export function createTypeEnvironment(
program: ESTree.Program,
visitorKeys: Readonly<Record<string, readonly string[]>>,
): TypeEnvironment {
const interfaces = new Map<string, ESTree.TSInterfaceDeclaration[]>();
for (const statement of program.body) {
const declaration = declaredStatement(statement);
if (declaration?.type !== "TSInterfaceDeclaration") continue;
const declarations = interfaces.get(declaration.id.name) ?? [];
declarations.push(declaration);
interfaces.set(declaration.id.name, declarations);
}
return {
interfaces,
typeAliases: createTypeAliasEnvironment(program, visitorKeys),
};
}
function typeReferenceName(type: ESTree.TSTypeReference): string | null {
return type.typeName.type === "Identifier" ? type.typeName.name : null;
}
function isBuiltIn(
name: string,
use: ESTree.Node,
environment: TypeEnvironment,
): boolean {
return (
BUILT_INS.has(name) &&
!hasVisibleTypeBinding(name, use, environment.typeAliases)
);
}
function isUnappliedReferenceTo(type: ESTree.TSType, name: string): boolean {
const unwrapped = unwrapTransparentType(type);
return (
unwrapped.type === "TSTypeReference" &&
typeReferenceName(unwrapped) === name &&
(unwrapped.typeArguments === null ||
unwrapped.typeArguments === undefined ||
unwrapped.typeArguments.params.length === 0)
);
}
function unwrapTransparentType(type: ESTree.TSType): ESTree.TSType {
let current = type;
while (
current.type === "TSParenthesizedType" ||
(current.type === "TSTypeOperator" && current.operator === "readonly")
) {
current = current.typeAnnotation;
}
return current;
}
function isNeverType(type: ESTree.TSType): boolean {
return unwrapTransparentType(type).type === "TSNeverKeyword";
}
function isEffectivelyEmptyMember(member: ESTree.TSSignature): boolean {
return (
member.type === "TSPropertySignature" &&
member.optional === true &&
member.typeAnnotation !== null &&
member.typeAnnotation !== undefined &&
isNeverType(member.typeAnnotation.typeAnnotation)
);
}
function isEffectivelyEmptyTypeLiteral(type: ESTree.TSTypeLiteral): boolean {
return type.members.length === 0 || type.members.every(isEffectivelyEmptyMember);
}
function isEffectivelyEmptyInterface(
declarations: readonly ESTree.TSInterfaceDeclaration[],
): boolean {
if (declarations.length !== 1) return false;
const [type] = declarations;
return (
type !== undefined &&
type.extends.length === 0 &&
(type.body.body.length === 0 || type.body.body.every(isEffectivelyEmptyMember))
);
}
function resolvedSubstitutionArgument(
type: ESTree.TSType,
base: TypeAliasEnvironment,
resolving: ReadonlySet<string> = new Set(),
): ESTree.TSType {
const unwrapped = unwrapTransparentType(type);
if (unwrapped.type !== "TSTypeReference") return type;
const name = typeReferenceName(unwrapped);
if (name === null || resolving.has(name)) return type;
const substitution = base.get(name);
if (substitution === undefined) return type;
const nextResolving = new Set(resolving);
nextResolving.add(name);
return resolvedSubstitutionArgument(substitution, base, nextResolving);
}
function aliasSubstitution(
alias: ESTree.TSTypeAliasDeclaration,
type: ESTree.TSTypeReference,
base: TypeAliasEnvironment,
): TypeAliasEnvironment | null {
const parameters = alias.typeParameters?.params ?? [];
const arguments_ = type.typeArguments?.params ?? [];
const next = new Map(base);
for (const [index, parameter] of parameters.entries()) {
const argument = arguments_[index] ?? parameter.default;
if (argument === null || argument === undefined) return null;
next.set(parameter.name.name, resolvedSubstitutionArgument(argument, next));
}
return next;
}
function unsafeDirectValue(
type: ESTree.TSType,
environment: TypeEnvironment,
substitutions: TypeAliasEnvironment,
resolvingAliases: ReadonlySet<string>,
): UnsafeDictionary["unsafeValue"] | null {
const unwrapped = unwrapTransparentType(type);
if (unwrapped.type === "TSUnknownKeyword") return "unknown";
if (unwrapped.type === "TSAnyKeyword") return "any";
if (unwrapped.type === "TSObjectKeyword") return "object";
if (unwrapped.type === "TSTypeLiteral" && isEffectivelyEmptyTypeLiteral(unwrapped))
return "empty-object";
if (unwrapped.type === "TSUnionType") {
return unwrapped.types.some(
(member) => unsafeDirectValue(member, environment, substitutions, resolvingAliases) !== null,
)
? "union"
: null;
}
if (unwrapped.type === "TSIntersectionType") {
const unsafeMembers = unwrapped.types.map((member) =>
unsafeDirectValue(member, environment, substitutions, resolvingAliases),
);
if (unsafeMembers.includes("any")) return "any";
return unsafeMembers.length > 0 && unsafeMembers.every((member) => member !== null)
? unsafeMembers[0]
: null;
}
if (unwrapped.type !== "TSTypeReference") return null;
const name = typeReferenceName(unwrapped);
if (name === null) return null;
if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, unwrapped, environment)) {
const wrapped = unwrapped.typeArguments?.params[0];
return wrapped === undefined
? null
: unsafeDirectValue(wrapped, environment, substitutions, resolvingAliases);
}
const substitution = substitutions.get(name);
if (substitution !== undefined) {
return isUnappliedReferenceTo(substitution, name)
? null
: unsafeDirectValue(substitution, environment, substitutions, resolvingAliases);
}
const interfaceDeclarations = environment.interfaces.get(name);
if (interfaceDeclarations !== undefined) {
return isEffectivelyEmptyInterface(interfaceDeclarations) ? "empty-object" : null;
}
const alias = visibleTypeAlias(name, unwrapped, environment.typeAliases);
if (alias === null || resolvingAliases.has(name)) return null;
const nextSubstitutions = aliasSubstitution(alias, unwrapped, substitutions);
if (nextSubstitutions === null) return null;
const nextResolving = new Set(resolvingAliases);
nextResolving.add(name);
return unsafeDirectValue(alias.typeAnnotation, environment, nextSubstitutions, nextResolving);
}
function dictionaryValueTypes(
type: ESTree.TSType,
environment: TypeEnvironment,
substitutions: TypeAliasEnvironment,
resolvingAliases: ReadonlySet<string>,
): readonly ResolvedType[] {
const unwrapped = unwrapTransparentType(type);
if (unwrapped.type === "TSTypeLiteral") {
return unwrapped.members.flatMap((member): readonly ResolvedType[] =>
member.type === "TSIndexSignature" && member.typeAnnotation !== null
? [{ type: member.typeAnnotation.typeAnnotation, substitutions }]
: [],
);
}
if (unwrapped.type === "TSMappedType") {
return unwrapped.typeAnnotation === null
? []
: [{ type: unwrapped.typeAnnotation, substitutions }];
}
if (unwrapped.type !== "TSTypeReference") return [];
const name = typeReferenceName(unwrapped);
if (name === null) return [];
const substitution = substitutions.get(name);
if (substitution !== undefined) {
return isUnappliedReferenceTo(substitution, name)
? []
: dictionaryValueTypes(substitution, environment, substitutions, resolvingAliases);
}
if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, unwrapped, environment)) {
const wrapped = unwrapped.typeArguments?.params[0];
return wrapped === undefined
? []
: dictionaryValueTypes(wrapped, environment, substitutions, resolvingAliases);
}
if (name === "Record" && isBuiltIn(name, unwrapped, environment)) {
const value = unwrapped.typeArguments?.params[1] ?? null;
return value === null ? [] : [{ type: value, substitutions }];
}
if (
(name === "Pick" || name === "Omit") &&
isBuiltIn(name, unwrapped, environment)
) {
const source = unwrapped.typeArguments?.params[0];
return source === undefined
? []
: dictionaryValueTypes(source, environment, substitutions, resolvingAliases);
}
const alias = visibleTypeAlias(name, unwrapped, environment.typeAliases);
if (alias === null || resolvingAliases.has(name)) return [];
const nextSubstitutions = aliasSubstitution(alias, unwrapped, substitutions);
if (nextSubstitutions === null) return [];
const nextResolving = new Set(resolvingAliases);
nextResolving.add(name);
return dictionaryValueTypes(alias.typeAnnotation, environment, nextSubstitutions, nextResolving);
}
export function classifyUnsafeDictionaryValue(
valueType: ESTree.TSType,
environment: TypeEnvironment,
): UnsafeDictionary | null {
const unsafeValue = unsafeDirectValue(valueType, environment, new Map(), new Set());
return unsafeValue === null ? null : { kind: "unsafe-dictionary", unsafeValue };
}
export function classifyUnsafeDictionary(
type: ESTree.TSType,
environment: TypeEnvironment,
): UnsafeDictionary | null {
for (const valueType of dictionaryValueTypes(type, environment, new Map(), new Set())) {
const unsafeValue = unsafeDirectValue(
valueType.type,
environment,
valueType.substitutions,
new Set(),
);
if (unsafeValue !== null) return { kind: "unsafe-dictionary", unsafeValue };
}
return null;
}
export function classifyWideningTarget(
type: ESTree.TSType,
environment: TypeEnvironment,
): WideningTarget | null {
const unwrapped = unwrapTransparentType(type);
if (unwrapped.type === "TSUnknownKeyword") return { kind: "unknown" };
if (unwrapped.type === "TSObjectKeyword") return { kind: "object" };
if (unwrapped.type === "TSTypeLiteral") {
return unwrapped.members.some((member) => member.type === "TSIndexSignature")
? { kind: "open dictionary" }
: unwrapped.members.length > 0
? { kind: "anonymous object" }
: null;
}
if (unwrapped.type === "TSMappedType") return { kind: "open dictionary" };
if (unwrapped.type !== "TSTypeReference") return null;
const name = typeReferenceName(unwrapped);
if (name === null) return null;
if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, unwrapped, environment)) {
const wrapped = unwrapped.typeArguments?.params[0];
return wrapped === undefined ? null : classifyWideningTarget(wrapped, environment);
}
if (name === "Record" && isBuiltIn(name, unwrapped, environment)) {
return hasBroadRecordKey(unwrapped, environment, new Map())
? { kind: "open dictionary" }
: null;
}
const alias = visibleTypeAlias(name, unwrapped, environment.typeAliases);
if (alias === null) return null;
if ((alias.typeParameters?.params.length ?? 0) > 0) {
const substitutions = aliasSubstitution(alias, unwrapped, new Map());
const resolved =
substitutions === null
? null
: classifyAliasBroadTarget(
alias.typeAnnotation,
environment,
substitutions,
new Set([name]),
);
return resolved?.kind === "open dictionary" ? { kind: "generic container" } : null;
}
const substitutions = aliasSubstitution(alias, unwrapped, new Map());
if (substitutions === null) return null;
const resolved = classifyAliasBroadTarget(
alias.typeAnnotation,
environment,
substitutions,
new Set([name]),
);
return resolved;
}
function hasBroadRecordKey(
type: ESTree.TSTypeReference,
environment: TypeEnvironment,
substitutions: TypeAliasEnvironment,
): boolean {
const key = type.typeArguments?.params[0];
return key === undefined || isBroadMappedKey(key, environment, substitutions);
}
function isBroadMappedKey(
type: ESTree.TSType,
environment: TypeEnvironment,
substitutions: TypeAliasEnvironment,
visitedAliases: ReadonlySet<string> = new Set(),
): boolean {
const unwrapped = unwrapTransparentType(type);
if (
unwrapped.type === "TSStringKeyword" ||
unwrapped.type === "TSNumberKeyword" ||
unwrapped.type === "TSSymbolKeyword"
) {
return true;
}
if (unwrapped.type === "TSUnionType") {
return unwrapped.types.some((member) =>
isBroadMappedKey(member, environment, substitutions, visitedAliases),
);
}
if (unwrapped.type !== "TSTypeReference") return false;
const name = typeReferenceName(unwrapped);
if (name === null) return false;
const substitution = substitutions.get(name);
if (substitution !== undefined && !isUnappliedReferenceTo(substitution, name)) {
return isBroadMappedKey(substitution, environment, substitutions, visitedAliases);
}
if (name === "PropertyKey" && isBuiltIn(name, unwrapped, environment)) return true;
const alias = visibleTypeAlias(name, unwrapped, environment.typeAliases);
if (
alias === null ||
(alias.typeParameters?.params.length ?? 0) > 0 ||
visitedAliases.has(name)
) {
return false;
}
const nextVisited = new Set(visitedAliases);
nextVisited.add(name);
return isBroadMappedKey(alias.typeAnnotation, environment, substitutions, nextVisited);
}
function classifyAliasBroadTarget(
type: ESTree.TSType,
environment: TypeEnvironment,
substitutions: TypeAliasEnvironment,
resolvingAliases: ReadonlySet<string>,
): WideningTarget | null {
const unwrapped = unwrapTransparentType(type);
if (unwrapped.type === "TSUnknownKeyword") return { kind: "unknown" };
if (unwrapped.type === "TSObjectKeyword") return { kind: "object" };
if (unwrapped.type === "TSTypeLiteral") {
return unwrapped.members.some((member) => member.type === "TSIndexSignature")
? { kind: "open dictionary" }
: null;
}
if (unwrapped.type === "TSMappedType") {
return isBroadMappedKey(unwrapped.constraint, environment, substitutions)
? { kind: "open dictionary" }
: null;
}
if (unwrapped.type !== "TSTypeReference") return null;
const name = typeReferenceName(unwrapped);
if (name === null) return null;
const substitution = substitutions.get(name);
if (substitution !== undefined) {
return isUnappliedReferenceTo(substitution, name)
? null
: classifyAliasBroadTarget(
substitution,
environment,
substitutions,
resolvingAliases,
);
}
if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, unwrapped, environment)) {
const wrapped = unwrapped.typeArguments?.params[0];
return wrapped === undefined
? null
: classifyAliasBroadTarget(wrapped, environment, substitutions, resolvingAliases);
}
if (name === "Record" && isBuiltIn(name, unwrapped, environment)) {
return hasBroadRecordKey(unwrapped, environment, substitutions)
? { kind: "open dictionary" }
: null;
}
const alias = visibleTypeAlias(name, unwrapped, environment.typeAliases);
if (alias === null || resolvingAliases.has(name)) return null;
const nextSubstitutions = aliasSubstitution(alias, unwrapped, substitutions);
if (nextSubstitutions === null) return null;
const nextResolving = new Set(resolvingAliases);
nextResolving.add(name);
return classifyAliasBroadTarget(
alias.typeAnnotation,
environment,
nextSubstitutions,
nextResolving,
);
}
export function isPopulatedObjectExpression(expression: ESTree.Expression): boolean {
let current = expression;
while (
current.type === "ParenthesizedExpression" ||
current.type === "TSAsExpression" ||
current.type === "TSTypeAssertion" ||
current.type === "TSNonNullExpression"
) {
current = current.expression;
}
return current.type === "ObjectExpression" && current.properties.length > 0;
}
export function isKnownEvidenceExpression(expression: ESTree.Expression): boolean {
let current = expression;
while (
current.type === "ParenthesizedExpression" ||
current.type === "TSAsExpression" ||
current.type === "TSTypeAssertion" ||
current.type === "TSNonNullExpression" ||
current.type === "TSSatisfiesExpression"
) {
current = current.expression;
}
if (current.type === "ObjectExpression") return true;
return (
current.type === "ArrayExpression" ||
current.type === "ArrowFunctionExpression" ||
current.type === "ClassExpression" ||
current.type === "FunctionExpression" ||
current.type === "NewExpression" ||
current.type === "Literal" ||
current.type === "TemplateLiteral" ||
current.type === "UnaryExpression"
);
}
@@ -0,0 +1,49 @@
import type { ESTree, SourceCode } from "@oxlint/plugins";
export type FunctionParameter = ESTree.ParamPattern;
/** Return whether a type is or contains TypeScript's absorbing unknown top type. */
export function containsUnknownType(type: ESTree.TSType): boolean {
if (type.type === "TSUnknownKeyword") return true;
if (type.type === "TSParenthesizedType") return containsUnknownType(type.typeAnnotation);
return type.type === "TSUnionType" && type.types.some(containsUnknownType);
}
/** Return the TypeScript annotation attached to a function parameter or its wrapped binding. */
export function functionParameterTypeAnnotation(
parameter: FunctionParameter,
): ESTree.TSTypeAnnotation | null | undefined {
if (parameter.type === "TSParameterProperty") {
return functionParameterTypeAnnotation(parameter.parameter);
}
if (parameter.type === "RestElement") {
return parameter.typeAnnotation ?? functionParameterTypeAnnotation(parameter.argument);
}
if (parameter.type === "AssignmentPattern") {
return parameter.typeAnnotation ?? functionParameterTypeAnnotation(parameter.left);
}
return parameter.typeAnnotation;
}
/** Return only a function parameter's local binding, excluding its annotation and default value. */
export function functionParameterBindingName(
parameter: FunctionParameter,
sourceCode: SourceCode,
): string {
if (parameter.type === "TSParameterProperty") {
return functionParameterBindingName(parameter.parameter, sourceCode);
}
if (parameter.type === "AssignmentPattern") {
return functionParameterBindingName(parameter.left, sourceCode);
}
if (parameter.type === "RestElement") {
return functionParameterBindingName(parameter.argument, sourceCode);
}
if (parameter.type === "Identifier") return parameter.name;
const sourceText = sourceCode.getText(parameter);
const annotationStart = parameter.typeAnnotation?.start;
return annotationStart === undefined
? sourceText
: sourceText.slice(0, annotationStart - parameter.start).trimEnd();
}
@@ -0,0 +1,61 @@
import type { ESTree } from "@oxlint/plugins";
type VisitorKeys = Readonly<Record<string, readonly string[]>>;
function isNode(value: unknown): value is ESTree.Node {
return (
typeof value === "object" &&
value !== null &&
"type" in value &&
typeof value.type === "string"
);
}
function collectInferTypeParameterNames(
node: ESTree.Node,
visitorKeys: VisitorKeys,
names: Set<string>,
): void {
if (node.type === "TSInferType") names.add(node.typeParameter.name.name);
const record = node as unknown as Readonly<Record<string, unknown>>;
for (const key of visitorKeys[node.type] ?? []) {
const value = record[key];
if (isNode(value)) {
collectInferTypeParameterNames(value, visitorKeys, names);
continue;
}
if (!Array.isArray(value)) continue;
for (const child of value) {
if (isNode(child)) collectInferTypeParameterNames(child, visitorKeys, names);
}
}
}
/** Collect type binders that are in scope at a node and can shadow module aliases. */
export function lexicalTypeParameterNames(
node: ESTree.Node,
visitorKeys: VisitorKeys,
): ReadonlySet<string> {
const names = new Set<string>();
let descendant: ESTree.Node = node;
let current: ESTree.Node | null = node;
while (current !== null && current.type !== "Program") {
if ("typeParameters" in current) {
for (const parameter of current.typeParameters?.params ?? []) {
names.add(parameter.name.name);
}
}
if (
current.type === "TSMappedType" &&
(descendant === current.nameType || descendant === current.typeAnnotation)
) {
names.add(current.key.name);
}
if (current.type === "TSConditionalType" && descendant === current.trueType) {
collectInferTypeParameterNames(current.extendsType, visitorKeys, names);
}
descendant = current;
current = current.parent;
}
return names;
}
+15
View File
@@ -0,0 +1,15 @@
import type { ESTree, Scope, SourceCode, Variable } from "@oxlint/plugins";
/** Resolve an identifier to its binding by walking lexical scopes upward. */
export function resolveVariable(
sourceCode: SourceCode,
identifier: ESTree.IdentifierReference,
): Variable | null {
let scope: Scope | null = sourceCode.getScope(identifier);
while (scope !== null) {
const variable = scope.set.get(identifier.name);
if (variable !== undefined) return variable;
scope = scope.upper;
}
return null;
}
@@ -0,0 +1,250 @@
import type { ESTree } from "@oxlint/plugins";
import { lexicalTypeParameterNames } from "./lexical-type-parameters.ts";
type VisitorKeys = Readonly<Record<string, readonly string[]>>;
type TypeScope = ESTree.Node;
type TypeBinding = {
readonly alias: ESTree.TSTypeAliasDeclaration | null;
readonly name: string;
readonly scope: TypeScope;
};
type Substitution = {
readonly substitutions: Substitutions;
readonly type: ESTree.TSType;
};
type Substitutions = ReadonlyMap<string, Substitution>;
export type TypeAliasEnvironment = {
readonly aliases: readonly ESTree.TSTypeAliasDeclaration[];
readonly bindingsByName: ReadonlyMap<string, readonly TypeBinding[]>;
readonly visitorKeys: VisitorKeys;
};
export type ResolvedTypeMatcher = (
type: ESTree.TSType,
matches: (child: ESTree.TSType) => boolean,
) => boolean;
const environmentsByProgram = new WeakMap<ESTree.Program, TypeAliasEnvironment>();
function isNode(value: unknown): value is ESTree.Node {
return (
typeof value === "object" &&
value !== null &&
"type" in value &&
typeof value.type === "string"
);
}
function enclosingTypeScope(node: ESTree.Node): TypeScope {
let current: ESTree.Node | null = node.parent;
while (current !== null) {
if (
current.type === "Program" ||
current.type === "BlockStatement" ||
current.type === "TSModuleBlock" ||
current.type === "StaticBlock" ||
current.type === "SwitchStatement"
) {
return current;
}
current = current.parent;
}
return node;
}
function declaredTypeBinding(node: ESTree.Node): {
readonly alias: ESTree.TSTypeAliasDeclaration | null;
readonly name: string;
} | null {
if (node.type === "TSTypeAliasDeclaration") {
return { alias: node, name: node.id.name };
}
if (
node.type === "TSInterfaceDeclaration" ||
node.type === "TSEnumDeclaration" ||
node.type === "ClassDeclaration" ||
node.type === "ClassExpression"
) {
return node.id === null ? null : { alias: null, name: node.id.name };
}
if (
node.type === "ImportSpecifier" ||
node.type === "ImportDefaultSpecifier" ||
node.type === "ImportNamespaceSpecifier"
) {
return { alias: null, name: node.local.name };
}
return null;
}
function collectTypeBindings(
node: ESTree.Node,
visitorKeys: VisitorKeys,
bindingsByName: Map<string, TypeBinding[]>,
aliases: ESTree.TSTypeAliasDeclaration[],
): void {
const declared = declaredTypeBinding(node);
if (declared !== null) {
const bindings = bindingsByName.get(declared.name) ?? [];
bindings.push({ ...declared, scope: enclosingTypeScope(node) });
bindingsByName.set(declared.name, bindings);
if (declared.alias !== null) aliases.push(declared.alias);
}
// SAFETY: Oxlint's visitor keys identify only ESTree child-node properties.
const fields = node as unknown as Readonly<Record<string, unknown>>;
for (const key of visitorKeys[node.type] ?? []) {
const value = fields[key];
if (isNode(value)) {
collectTypeBindings(value, visitorKeys, bindingsByName, aliases);
continue;
}
if (!Array.isArray(value)) continue;
for (const child of value) {
if (isNode(child)) {
collectTypeBindings(child, visitorKeys, bindingsByName, aliases);
}
}
}
}
/** Collect every lexical type alias and competing type binding in a program. */
export function createTypeAliasEnvironment(
program: ESTree.Program,
visitorKeys: VisitorKeys,
): TypeAliasEnvironment {
const cached = environmentsByProgram.get(program);
if (cached !== undefined) return cached;
const bindingsByName = new Map<string, TypeBinding[]>();
const aliases: ESTree.TSTypeAliasDeclaration[] = [];
collectTypeBindings(program, visitorKeys, bindingsByName, aliases);
const environment = { aliases, bindingsByName, visitorKeys };
environmentsByProgram.set(program, environment);
return environment;
}
function ancestorDistance(ancestor: ESTree.Node, node: ESTree.Node): number | null {
let current: ESTree.Node | null = node;
let distance = 0;
while (current !== null) {
if (current === ancestor) return distance;
current = current.parent;
distance += 1;
}
return null;
}
function nearestTypeBindings(
name: string,
use: ESTree.Node,
environment: TypeAliasEnvironment,
): readonly TypeBinding[] {
const candidates = environment.bindingsByName.get(name) ?? [];
let nearestDistance = Number.POSITIVE_INFINITY;
let nearest: TypeBinding[] = [];
for (const candidate of candidates) {
const distance = ancestorDistance(candidate.scope, use);
if (distance === null || distance > nearestDistance) continue;
if (distance === nearestDistance) {
nearest.push(candidate);
continue;
}
nearestDistance = distance;
nearest = [candidate];
}
return nearest;
}
/** Resolve the nearest visible alias with this name, respecting lexical shadowing. */
export function visibleTypeAlias(
name: string,
use: ESTree.Node,
environment: TypeAliasEnvironment,
): ESTree.TSTypeAliasDeclaration | null {
if (lexicalTypeParameterNames(use, environment.visitorKeys).has(name)) return null;
const bindings = nearestTypeBindings(name, use, environment);
return bindings.length === 1 ? (bindings[0]?.alias ?? null) : null;
}
/** Return whether a local declaration shadows a built-in type at this use. */
export function hasVisibleTypeBinding(
name: string,
use: ESTree.Node,
environment: TypeAliasEnvironment,
): boolean {
return (
lexicalTypeParameterNames(use, environment.visitorKeys).has(name) ||
nearestTypeBindings(name, use, environment).length > 0
);
}
function typeReferenceName(type: ESTree.TSTypeReference): string | null {
return type.typeName.type === "Identifier" ? type.typeName.name : null;
}
function aliasSubstitutions(
alias: ESTree.TSTypeAliasDeclaration,
reference: ESTree.TSTypeReference,
base: Substitutions,
): Substitutions | null {
const parameters = alias.typeParameters?.params ?? [];
const arguments_ = reference.typeArguments?.params ?? [];
const next = new Map(base);
for (const [index, parameter] of parameters.entries()) {
const explicitArgument = arguments_[index];
const argument = explicitArgument ?? parameter.default;
if (argument === null || argument === undefined) return null;
const argumentSubstitutions = explicitArgument === undefined ? next : base;
next.set(parameter.name.name, {
type: argument,
substitutions: new Map(argumentSubstitutions),
});
}
return next;
}
/** Match a type after resolving visible aliases and substituting their type parameters. */
export function resolvedTypeMatches(
type: ESTree.TSType,
environment: TypeAliasEnvironment,
matcher: ResolvedTypeMatcher,
): boolean {
const evaluate = (
current: ESTree.TSType,
substitutions: Substitutions,
resolvingAliases: ReadonlySet<ESTree.TSTypeAliasDeclaration>,
): boolean => {
if (current.type === "TSTypeReference") {
const name = typeReferenceName(current);
if (name !== null) {
const substitution = substitutions.get(name);
if (substitution !== undefined && !current.typeArguments?.params.length) {
return evaluate(
substitution.type,
substitution.substitutions,
resolvingAliases,
);
}
const alias = visibleTypeAlias(name, current, environment);
if (alias !== null && !resolvingAliases.has(alias)) {
const nextSubstitutions = aliasSubstitutions(alias, current, substitutions);
if (nextSubstitutions !== null) {
const nextResolving = new Set(resolvingAliases);
nextResolving.add(alias);
return evaluate(alias.typeAnnotation, nextSubstitutions, nextResolving);
}
}
}
}
return matcher(current, (child) =>
evaluate(child, substitutions, resolvingAliases),
);
};
return evaluate(type, new Map(), new Set());
}