mirror of
https://github.com/renovatebot/renovate.git
synced 2026-10-27 07:50:47 +00:00
16ef1715e9
* feat(lint): add renovate/prefer-is-helpers and renovate/prefer-is-object rules Flag ad-hoc type checks that have @sindresorhus/is equivalents: - .filter(Boolean) -> .filter(isTruthy) - typeof x === 'string' -> isString(x) - x === null || x === undefined -> isNullOrUndefined(x) (and negation) The typeof x === 'object' shape lives in a separate suggestion-only rule (renovate/prefer-is-object, kept at warn) because isObject()/isPlainObject() are not drop-in equivalents: null, arrays and functions behave differently. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor: fix renovate/prefer-is-helpers violations Replace ad-hoc type checks with @sindresorhus/is helpers across lib/: - 13x .filter(Boolean) -> .filter(isTruthy) - 36x typeof x ==/!== 'string' -> isString(x) / !isString(x) - 6x x === null || x === undefined -> isNullOrUndefined(x) (and negation) Promote renovate/prefer-is-helpers to error now that the codebase is clean. renovate/prefer-is-object stays at warn: its 15 remaining sites guard parsed YAML/JSON or unknown values where isObject()/isPlainObject() are not provably equivalent (null/array/function semantics). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor: enforce renovate/prefer-is-object at error level Resolve the 15 remaining warnings and raise the rule from warn to error: - Migrate 10 sites to isObject() where the guarded value provably cannot be a function (parsed YAML/JSON, typed exec command arguments, or test-controlled fixtures): helm-values, woodpecker, bun, pre-commit managers, exec utils, file package cache, and the github http spec. In the bun manager this also fixes a latent crash where a null `workspaces` value would throw on the `in` operator. - Keep 5 sites as reasoned oxlint-disable comments where excluding functions is load-bearing: the byte-exact fingerprint canonicalizer (2), the circularity walker, the zod result guard, and the generic hasKey helper. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
457 lines
12 KiB
TypeScript
457 lines
12 KiB
TypeScript
import ini from 'ini';
|
|
import JSON5 from 'json5';
|
|
import { DateTime } from 'luxon';
|
|
import type { JsonArray } from 'type-fest';
|
|
import { z } from 'zod/v4';
|
|
import { logger } from '../../logger/index.ts';
|
|
import type { PackageDependency } from '../../modules/manager/types.ts';
|
|
import { parseJsonc } from '../common.ts';
|
|
import { parse as parseToml } from '../toml.ts';
|
|
import type { YamlOptions } from '../yaml.ts';
|
|
import { parseSingleYaml, parseYaml } from '../yaml.ts';
|
|
|
|
interface ErrorContext<T> {
|
|
error: z.ZodError;
|
|
input: T;
|
|
}
|
|
|
|
interface LooseOpts<T> {
|
|
onError?: (err: ErrorContext<T>) => void;
|
|
}
|
|
|
|
/**
|
|
* Works like `z.array()`, but drops wrong elements instead of invalidating the whole array.
|
|
*
|
|
* **Important**: non-array inputs are still invalid.
|
|
* Use `LooseArray(...).catch([])` to handle it.
|
|
*
|
|
* @param Elem Schema for array elements
|
|
* @param onError Callback for errors
|
|
* @returns Schema for array
|
|
*/
|
|
export function LooseArray<Schema extends z.ZodTypeAny>(
|
|
Elem: Schema,
|
|
{ onError }: LooseOpts<unknown[]> = {},
|
|
): z.ZodType<z.TypeOf<Schema>[], any> {
|
|
if (!onError) {
|
|
// Avoid error-related computations inside the loop
|
|
return z.array(z.any()).transform((input) => {
|
|
const output: z.infer<Schema>[] = [];
|
|
for (const x of input) {
|
|
const parsed = Elem.safeParse(x);
|
|
if (parsed.success) {
|
|
output.push(parsed.data);
|
|
}
|
|
}
|
|
return output;
|
|
});
|
|
}
|
|
|
|
return z.array(z.any()).transform((input) => {
|
|
const output: z.infer<Schema>[] = [];
|
|
const issues: z.ZodIssue[] = [];
|
|
|
|
for (let idx = 0; idx < input.length; idx += 1) {
|
|
const x = input[idx];
|
|
const parsed = Elem.safeParse(x);
|
|
|
|
if (parsed.success) {
|
|
output.push(parsed.data);
|
|
continue;
|
|
}
|
|
|
|
for (const issue of parsed.error.issues) {
|
|
issues.push({ ...issue, path: [idx, ...issue.path] });
|
|
}
|
|
}
|
|
|
|
if (issues.length) {
|
|
const error = new z.ZodError(issues);
|
|
onError({ error, input });
|
|
}
|
|
|
|
return output;
|
|
});
|
|
}
|
|
|
|
type LooseRecordResult<ValueSchema extends z.ZodTypeAny> = z.ZodType<
|
|
Record<string, z.TypeOf<ValueSchema>>,
|
|
any
|
|
>;
|
|
|
|
type LooseRecordOpts = LooseOpts<Record<string, unknown>>;
|
|
|
|
/**
|
|
* Works like `z.record()`, but drops wrong elements instead of invalidating the whole record.
|
|
*
|
|
* **Important**: non-record inputs other are still invalid.
|
|
* Use `LooseRecord(...).catch({})` to handle it.
|
|
*
|
|
* @param KeyValue Schema for record keys
|
|
* @param ValueValue Schema for record values
|
|
* @param onError Callback for errors
|
|
* @returns Schema for record
|
|
*/
|
|
export function LooseRecord<ValueSchema extends z.ZodTypeAny>(
|
|
Value: ValueSchema,
|
|
): LooseRecordResult<ValueSchema>;
|
|
export function LooseRecord<
|
|
KeySchema extends z.ZodTypeAny,
|
|
ValueSchema extends z.ZodTypeAny,
|
|
>(Key: KeySchema, Value: ValueSchema): LooseRecordResult<ValueSchema>;
|
|
export function LooseRecord<ValueSchema extends z.ZodTypeAny>(
|
|
Value: ValueSchema,
|
|
{ onError }: LooseRecordOpts,
|
|
): LooseRecordResult<ValueSchema>;
|
|
export function LooseRecord<
|
|
KeySchema extends z.ZodTypeAny,
|
|
ValueSchema extends z.ZodTypeAny,
|
|
>(
|
|
Key: KeySchema,
|
|
Value: ValueSchema,
|
|
{ onError }: LooseRecordOpts,
|
|
): LooseRecordResult<ValueSchema>;
|
|
export function LooseRecord<
|
|
KeySchema extends z.ZodTypeAny,
|
|
ValueSchema extends z.ZodTypeAny,
|
|
>(
|
|
arg1: ValueSchema | KeySchema,
|
|
arg2?: ValueSchema | LooseOpts<Record<string, unknown>>,
|
|
arg3?: LooseRecordOpts,
|
|
): LooseRecordResult<ValueSchema> {
|
|
let Key: z.ZodType = z.any();
|
|
let Value: ValueSchema;
|
|
let opts: LooseRecordOpts = {};
|
|
if (arg2 && arg3) {
|
|
Key = arg1 as KeySchema;
|
|
Value = arg2 as ValueSchema;
|
|
opts = arg3;
|
|
} else if (arg2) {
|
|
if (arg2 instanceof z.ZodType) {
|
|
Key = arg1 as KeySchema;
|
|
Value = arg2;
|
|
} else {
|
|
Value = arg1 as ValueSchema;
|
|
opts = arg2;
|
|
}
|
|
} else {
|
|
Value = arg1 as ValueSchema;
|
|
}
|
|
|
|
const { onError } = opts;
|
|
if (!onError) {
|
|
// Avoid error-related computations inside the loop
|
|
return z.record(z.string(), z.any()).transform((input) => {
|
|
const output: Record<string, z.infer<ValueSchema>> = {};
|
|
for (const [inputKey, inputVal] of Object.entries(input)) {
|
|
const parsedKey = Key.safeParse(inputKey);
|
|
const parsedValue = Value.safeParse(inputVal);
|
|
if (parsedKey.success && parsedValue.success) {
|
|
output[parsedKey.data as string] = parsedValue.data;
|
|
}
|
|
}
|
|
return output;
|
|
});
|
|
}
|
|
|
|
return z.record(z.string(), z.any()).transform((input) => {
|
|
const output: Record<string, z.infer<ValueSchema>> = {};
|
|
const issues: z.ZodIssue[] = [];
|
|
|
|
for (const [inputKey, inputVal] of Object.entries(input)) {
|
|
const parsedKey = Key.safeParse(inputKey);
|
|
if (!parsedKey.success) {
|
|
for (const issue of parsedKey.error.issues) {
|
|
issues.push({ ...issue, path: [inputKey, ...issue.path] });
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const parsedValue = Value.safeParse(inputVal);
|
|
if (!parsedValue.success) {
|
|
for (const issue of parsedValue.error.issues) {
|
|
issues.push({ ...issue, path: [inputKey, ...issue.path] });
|
|
}
|
|
continue;
|
|
}
|
|
|
|
output[parsedKey.data as string] = parsedValue.data;
|
|
}
|
|
|
|
if (issues.length) {
|
|
const error = new z.ZodError(issues);
|
|
onError({ error, input });
|
|
}
|
|
|
|
return output;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Accepts `null`, `undefined`, or an absent key and normalizes all to
|
|
* `undefined`. Keeps the output type as `T | undefined` (never `null`), so
|
|
* assignment into non-nullable targets needs no `?? undefined` coalescing.
|
|
*/
|
|
export function Nullish<Schema extends z.ZodTypeAny>(
|
|
schema: Schema,
|
|
): z.ZodOptional<z.ZodType<z.output<Schema> | undefined>> {
|
|
return schema
|
|
.nullable()
|
|
.transform((value) => value ?? undefined)
|
|
.optional();
|
|
}
|
|
|
|
function deepNullishRewrite(node: z.ZodTypeAny): z.ZodTypeAny {
|
|
if (node instanceof z.ZodOptional) {
|
|
return Nullish(deepNullishRewrite(node.unwrap() as z.ZodTypeAny));
|
|
}
|
|
if (node instanceof z.ZodObject) {
|
|
const shape: Record<string, z.ZodTypeAny> = {};
|
|
for (const [key, value] of Object.entries(node.shape)) {
|
|
shape[key] = deepNullishRewrite(value as z.ZodTypeAny);
|
|
}
|
|
return z.object(shape);
|
|
}
|
|
if (node instanceof z.ZodArray) {
|
|
return z.array(deepNullishRewrite(node.element as z.ZodTypeAny));
|
|
}
|
|
if (node instanceof z.ZodRecord) {
|
|
return z.record(
|
|
node.keyType,
|
|
deepNullishRewrite(node.valueType as z.ZodTypeAny),
|
|
);
|
|
}
|
|
if (node instanceof z.ZodUnion) {
|
|
const options = node.options as z.ZodTypeAny[];
|
|
return z.union(
|
|
options.map(deepNullishRewrite) as [
|
|
z.ZodTypeAny,
|
|
z.ZodTypeAny,
|
|
...z.ZodTypeAny[],
|
|
],
|
|
);
|
|
}
|
|
if (node instanceof z.ZodDefault) {
|
|
return deepNullishRewrite(node.unwrap() as z.ZodTypeAny).default(
|
|
node.def.defaultValue,
|
|
);
|
|
}
|
|
if (node instanceof z.ZodNullable) {
|
|
return z.nullable(deepNullishRewrite(node.unwrap() as z.ZodTypeAny));
|
|
}
|
|
if (node instanceof z.ZodPipe) {
|
|
return z.pipe(
|
|
node.def.in as z.ZodTypeAny,
|
|
deepNullishRewrite(node.def.out as z.ZodTypeAny),
|
|
);
|
|
}
|
|
return node;
|
|
}
|
|
|
|
/**
|
|
* Walks `schema` at build time and replaces every `.optional()` position with
|
|
* `Nullish` semantics — accepting `null`/`undefined`/absent and normalizing all
|
|
* to `undefined`. Recurses through `object`, `array`, `record`, `union`,
|
|
* `default`, `nullable`, and the **output side** of `pipe`/transform nodes
|
|
* (e.g. `Json.pipe(…)`).
|
|
*
|
|
* Intentional `.nullable()` fields are preserved (null kept), distinguishing
|
|
* this from a blunt null-stripping preprocessor.
|
|
*
|
|
* The pipe **input** side (the decoder, e.g. `Json`) is left untouched.
|
|
* **`LooseArray`/`LooseRecord` remain opaque** because their element schema is
|
|
* captured in a closure — wrap the inner schema directly for those
|
|
* (e.g. `LooseArray(DeepNullish(Inner))`).
|
|
*
|
|
* Object modifiers (`.strict()`/`.catchall()`/`.passthrough()`/object-level
|
|
* `.refine()`) are dropped by the `z.object(shape)` rebuild.
|
|
*/
|
|
export function DeepNullish<Schema extends z.ZodTypeAny>(
|
|
schema: Schema,
|
|
): z.ZodType<z.output<Schema>> {
|
|
return deepNullishRewrite(schema) as z.ZodType<z.output<Schema>>;
|
|
}
|
|
|
|
export const Json = z.string().transform((str, ctx): unknown => {
|
|
try {
|
|
return JSON.parse(str);
|
|
} catch {
|
|
ctx.addIssue({ code: 'custom', message: 'Invalid JSON' });
|
|
return z.NEVER;
|
|
}
|
|
});
|
|
|
|
export const Json5 = z.string().transform((str, ctx): unknown => {
|
|
try {
|
|
return JSON5.parse(str);
|
|
} catch {
|
|
ctx.addIssue({ code: 'custom', message: 'Invalid JSON5' });
|
|
return z.NEVER;
|
|
}
|
|
});
|
|
|
|
export const Jsonc = z.string().transform((str, ctx): unknown => {
|
|
try {
|
|
return parseJsonc(str);
|
|
} catch {
|
|
ctx.addIssue({ code: 'custom', message: 'Invalid JSONC' });
|
|
return z.NEVER;
|
|
}
|
|
});
|
|
|
|
export const UtcDate = z
|
|
.string()
|
|
.describe('ISO 8601 string')
|
|
.transform((str, ctx): DateTime => {
|
|
const date = DateTime.fromISO(str, { zone: 'utc' });
|
|
if (!date.isValid) {
|
|
ctx.addIssue({ code: 'custom', message: 'Invalid date' });
|
|
return z.NEVER;
|
|
}
|
|
return date;
|
|
});
|
|
|
|
export const Yaml = z.string().transform((str, ctx): unknown => {
|
|
try {
|
|
return parseSingleYaml(str);
|
|
} catch {
|
|
ctx.addIssue({ code: 'custom', message: 'Invalid YAML' });
|
|
return z.NEVER;
|
|
}
|
|
});
|
|
|
|
export const MultidocYaml = z.string().transform((str, ctx): unknown => {
|
|
try {
|
|
return parseYaml(str);
|
|
} catch {
|
|
ctx.addIssue({ code: 'custom', message: 'Invalid YAML' });
|
|
return z.NEVER;
|
|
}
|
|
});
|
|
|
|
export function multidocYaml(
|
|
opts?: Omit<YamlOptions, 'customSchema'>,
|
|
): z.ZodType<JsonArray> {
|
|
return z.string().transform((str, ctx): JsonArray => {
|
|
try {
|
|
return parseYaml(str, opts) as JsonArray;
|
|
} catch {
|
|
ctx.addIssue({ code: 'custom', message: 'Invalid YAML' });
|
|
return z.NEVER;
|
|
}
|
|
});
|
|
}
|
|
|
|
export const Toml = z.string().transform((str, ctx) => {
|
|
try {
|
|
return parseToml(str);
|
|
} catch {
|
|
ctx.addIssue({ code: 'custom', message: 'Invalid TOML' });
|
|
return z.NEVER;
|
|
}
|
|
});
|
|
|
|
export const Ini = z.string().transform((str, ctx): Record<string, unknown> => {
|
|
try {
|
|
return ini.parse(str);
|
|
} catch /* v8 ignore next -- TODO: add test #40625 */ {
|
|
ctx.addIssue({ code: 'custom', message: 'Invalid INI' });
|
|
return z.NEVER;
|
|
}
|
|
});
|
|
|
|
export function withDepType<
|
|
Output extends PackageDependency[],
|
|
Schema extends z.ZodType<Output>,
|
|
>(schema: Schema, depType: string, force = true): z.ZodType<Output> {
|
|
return schema.transform((deps) => {
|
|
for (const dep of deps) {
|
|
if (!dep.depType || force) {
|
|
dep.depType = depType;
|
|
}
|
|
}
|
|
return deps;
|
|
});
|
|
}
|
|
|
|
export function withDebugMessage<Output>(
|
|
value: Output,
|
|
msg: string,
|
|
): (ctx: { error: unknown; input: unknown }) => Output {
|
|
return ({ error: err }) => {
|
|
logger.debug({ err }, msg);
|
|
return value;
|
|
};
|
|
}
|
|
|
|
export function withTraceMessage<Output>(
|
|
value: Output,
|
|
msg: string,
|
|
): (ctx: { error: unknown; input: unknown }) => Output {
|
|
return ({ error: err }) => {
|
|
logger.trace({ err }, msg);
|
|
return value;
|
|
};
|
|
}
|
|
|
|
function isCircular(value: unknown, visited = new Set<unknown>()): boolean {
|
|
// oxlint-disable-next-line renovate/prefer-is-object -- functions must stay non-circular leaves; isObject() matches functions and would send them into the property walk
|
|
if (value === null || typeof value !== 'object') {
|
|
return false;
|
|
}
|
|
|
|
if (visited.has(value)) {
|
|
return true;
|
|
}
|
|
|
|
const downstreamVisited = new Set(visited);
|
|
downstreamVisited.add(value);
|
|
|
|
if (Array.isArray(value)) {
|
|
for (const childValue of value) {
|
|
if (isCircular(childValue, downstreamVisited)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
const values = Object.values(value);
|
|
for (const ov of values) {
|
|
if (isCircular(ov, downstreamVisited)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
export const NotCircular = z.unknown().superRefine((val, ctx) => {
|
|
if (isCircular(val)) {
|
|
ctx.addIssue({
|
|
code: 'custom',
|
|
message: 'values cannot be circular data structures',
|
|
fatal: true,
|
|
});
|
|
|
|
return z.NEVER;
|
|
}
|
|
});
|
|
|
|
const StandardEmail = z.email();
|
|
|
|
// GitHub/Forgejo apps use addresses like `1234+name[bot]@users.noreply.github.com`.
|
|
// The `[bot]` marker is not valid in the local part per RFC 5322, so we strip it before
|
|
export const EmailAddress = z
|
|
.string()
|
|
.refine(
|
|
(value) => StandardEmail.safeParse(value.replace('[bot]@', '@')).success,
|
|
'Invalid email address',
|
|
);
|
|
export type EmailAddress = z.infer<typeof EmailAddress>;
|
|
|
|
export function isEmailAdress(value: string): boolean {
|
|
return EmailAddress.safeParse(value).success;
|
|
}
|