Files
renovate/lib/util/schema-utils/index.ts
Sebastian Poxhofer 16ef1715e9 refactor(linters): add rules for @sindresorhus/is helpers (#45229)
* 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>
2026-08-13 14:22:03 +00:00

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;
}