From e0d9e98478651de66bda5177aa4d7a74e9a328cd Mon Sep 17 00:00:00 2001 From: Steve Konves Date: Sat, 16 Aug 2025 15:17:36 -0700 Subject: [PATCH] Add no null properties rule --- src/no-null-properties.ts | 214 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 src/no-null-properties.ts diff --git a/src/no-null-properties.ts b/src/no-null-properties.ts new file mode 100644 index 0000000..b4defa7 --- /dev/null +++ b/src/no-null-properties.ts @@ -0,0 +1,214 @@ +import { + decodeRange, + Enum, + getEnumByName, + getTypeByName, + getUnionByName, + Rule, + Service, + Type, + Union, + Violation, +} from 'basketry'; +import { parseSeverity } from './utils'; + +const noNullProperties: Rule = (service, options) => { + const violations: Violation[] = []; + + const optionsAllowHttpVerbs = options?.allowHttpVerbs; + const allowHttpVerbs = new Set( + Array.isArray(optionsAllowHttpVerbs) + ? optionsAllowHttpVerbs.map((verb: any) => `${verb}`.toLowerCase()) + : [], + ); + + const serviceInfo = new ServiceInfo(service, allowHttpVerbs); + + for (const type of serviceInfo.types) { + for (const property of type.properties) { + if (property.value.isNullable) { + const { range, sourceIndex } = decodeRange( + property.value.isNullable.loc ?? property.name.loc ?? property.loc, + ); + + violations.push({ + code: 'basketry/no-null-properties', + message: 'Property may not be null.', + range, + severity: parseSeverity(options?.severity, 'error'), + sourcePath: service.sourcePaths[sourceIndex], + }); + } + } + } + + return violations; +}; + +export default noNullProperties; + +class ServiceInfo { + constructor( + private readonly service: Service, + private readonly allowHttpVerbs: Set, + ) { + this.init(); + } + + private readonly _inputEnums = new Map(); + private readonly _outputEnums = new Map(); + + private readonly _inputTypes = new Map(); + private readonly _outputTypes = new Map(); + + private readonly _inputUnions = new Map(); + private readonly _outputUnions = new Map(); + + get types(): Iterable { + const names = new Set([ + ...this._inputTypes.keys(), + ...this._outputTypes.keys(), + ]); + + return Array.from(names) + .sort((a, b) => a.localeCompare(b)) + .map( + (name) => this._inputTypes.get(name) ?? this._outputTypes.get(name)!, + ); + } + + get inputTypes(): Iterable { + return Array.from(this._inputTypes.values()).sort((a, b) => + a.name.value.localeCompare(b.name.value), + ); + } + + get outputTypes(): Iterable { + return Array.from(this._outputTypes.values()).sort((a, b) => + a.name.value.localeCompare(b.name.value), + ); + } + + get unions(): Iterable { + const names = new Set([ + ...this._inputUnions.keys(), + ...this._outputUnions.keys(), + ]); + + return Array.from(names) + .sort((a, b) => a.localeCompare(b)) + .map( + (name) => this._inputUnions.get(name) ?? this._outputUnions.get(name)!, + ); + } + + get inputUnions(): Iterable { + return Array.from(this._inputUnions.values()).sort((a, b) => + a.name.value.localeCompare(b.name.value), + ); + } + + get outputUnions(): Iterable { + return Array.from(this._outputUnions.values()).sort((a, b) => + a.name.value.localeCompare(b.name.value), + ); + } + + get enums(): Iterable { + const names = new Set([ + ...this._inputEnums.keys(), + ...this._outputEnums.keys(), + ]); + + return Array.from(names) + .sort((a, b) => a.localeCompare(b)) + .map( + (name) => this._inputEnums.get(name) ?? this._outputEnums.get(name)!, + ); + } + + private init(): void { + for (const int of this.service.interfaces) { + for (const method of int.methods) { + for (const param of method.parameters) { + const httpMethod = int.protocols?.http + ?.flatMap((route) => route.methods) + .find((m) => m.name.value === method.name.value); + + if (httpMethod && this.allowHttpVerbs.has(httpMethod.verb.value)) { + continue; + } + + for (const t of this.traverse(param.value.typeName.value, 'input')) { + // noop + } + } + + if (method.returns?.value.kind === 'ComplexValue') { + for (const t of this.traverse( + method.returns.value.typeName.value, + 'output', + )) { + // noop + } + } + } + } + } + + private *traverse( + typeName: string, + mode: 'input' | 'output', + ): Iterable { + if (mode === 'input' && this._inputTypes.has(typeName)) return; + if (mode === 'output' && this._outputTypes.has(typeName)) return; + if (mode === 'input' && this._inputUnions.has(typeName)) return; + if (mode === 'output' && this._outputUnions.has(typeName)) return; + if (mode === 'input' && this._inputEnums.has(typeName)) return; + if (mode === 'output' && this._outputEnums.has(typeName)) return; + + const type = getTypeByName(this.service, typeName); + const e = getEnumByName(this.service, typeName); + const union = getUnionByName(this.service, typeName); + + if (type) { + if (mode === 'input') { + this._inputTypes.set(typeName, type); + } else { + this._outputTypes.set(typeName, type); + } + yield type; + + for (const prop of type.properties) { + if (prop.value.kind === 'ComplexValue') { + yield* this.traverse(prop.value.typeName.value, mode); + } + } + + if (type.mapProperties) { + const { key, value } = type.mapProperties; + if (key.value.kind === 'ComplexValue') { + yield* this.traverse(key.value.typeName.value, mode); + } + if (value.value.kind === 'ComplexValue') { + yield* this.traverse(value.value.typeName.value, mode); + } + } + } else if (union) { + if (mode === 'input') { + this._inputUnions.set(typeName, union); + } else { + this._outputUnions.set(typeName, union); + } + for (const member of union.members) { + yield* this.traverse(member.typeName.value, mode); + } + } else if (e) { + if (mode === 'input') { + this._inputEnums.set(typeName, e); + } else { + this._outputEnums.set(typeName, e); + } + } + } +}