mirror of
https://github.com/renovatebot/renovate.git
synced 2026-10-27 07:50:47 +00:00
716baf630b
* feat(lint): add renovate/prefer-fs-util and renovate/prefer-luxon rules Two shared-util substitution rules, both at warn level: - renovate/prefer-fs-util flags value imports of fs, node:fs, fs/promises, node:fs/promises, fs-extra and fs-extra/esm in lib/ source files outside lib/util/fs/, pointing to the scoped helpers from lib/util/fs. Spec files and type-only imports are exempt. A config-only change via no-restricted-imports was considered but rejected: that rule is at error severity globally and per-path warn severity is not expressible, so the 13 existing call sites would break the baseline. - renovate/prefer-luxon flags new Date(...) and Date.now() in lib/ source files, pointing to Luxon per best-practices. Date/time utility modules (lib/util/date, lib/util/pretty-time, lib/util/timestamp) and spec files (vi.setSystemTime etc.) are exempt. Baseline: 13 prefer-fs-util and 45 prefer-luxon warnings, zero errors. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor: use Luxon instead of new Date() at remaining call sites Fixes all renovate/prefer-luxon violations for `new Date(...)`: - `new Date().toISOString()` -> `DateTime.utc().toISO()` (byte-identical output) in the http cache provider, schedule logging and PR cache. - `new Date()` -> `DateTime.now().toJSDate()` for the git commit-date fallback and the rubygems `syncedAt` markers (same `Date` values). - rust-release-channel now computes the Rust 1.0 release date and nightly version dates via `DateTime.utc(...)`; previously `new Date(y, m, d)` used the local timezone while the epoch used UTC, so the comparison was timezone-dependent at the boundary date. Tests run with TZ=UTC, where behavior is unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor: use lib/util/fs helpers in repository worker Fixes renovate/prefer-fs-util violations outside bootstrap code: - `fs.ensureDir()` -> `ensureDir()` from lib/util/fs (same fs-extra call underneath; it no-ops on an empty dir name, so the repository worker spec now simulates the init failure via a rejected `initRepo` instead of relying on `fs.ensureDir('')` throwing). - `fs.remove(privateCacheDir())` -> `cachePathExists()` + `rmCache()`, keeping the remove-if-present semantics while asserting cacheDir containment. Two call sites keep raw imports with reasoned disable comments: - lib/util/modules.ts synchronously enumerates Renovate's own installed module directories at import time. - lib/util/cache/package/impl/sqlite.ts receives cacheDir as an explicit argument during global initialization, before GlobalConfig (which the scoped cache helpers resolve against) contains cacheDir. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(lint): promote prefer-fs-util and prefer-luxon to error Both rules are narrowed to what is actually accepted practice before being promoted: prefer-luxon no longer flags `Date.now()`: it returns UTC-based epoch milliseconds used for elapsed-time measurement and TTL arithmetic, which is timezone-independent by construction. The mined reviewer feedback behind this rule ("use Luxon for date arithmetic outside util") targets timezone-dependent date construction/formatting, which cannot occur with a raw millis number; the rule keeps flagging `new Date(...)`. prefer-fs-util exempts code that legitimately needs raw fs access because the scoped lib/util/fs helpers resolve against GlobalConfig localDir/cacheDir, which does not exist yet or does not apply there: - lib/logger/: log-file streams at user-configured system paths during bootstrap; lib/util/fs also depends on the logger (import cycle). - lib/workers/global/: creates baseDir/cacheDir themselves and reads/writes user-supplied paths before GlobalConfig is seeded. - lib/config-validator.ts: standalone CLI without GlobalConfig. - lib/util/git/: owns the localDir lifecycle (clone, emptyDir, symlinks) and private-key files with sync cleanup on process exit. Also fixes a pre-existing tsc error in prefer-fs-util.js: `importKind` is a TypeScript-ESTree extension missing from eslint's base AST types. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(fs): make rmCache ignore missing paths All rmCache callers use it for cleanup, where a missing path is a no-op, so bake force: true in instead of guarding call sites with an existence check. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(linters): exempt lib/instrumentation from prefer-fs-util `lib/instrumentation/file-exporter.ts` landed on main while this branch was open and imports `node:fs/promises` directly. The exporter writes to a user-configured system path (`RENOVATE_TRACING_FILE_EXPORTER_PATH`) outside `localDir`/`cacheDir`, so the scoped helpers do not apply. `lib/instrumentation` is also deliberately kept out of the module graph of instrumented libraries (see the `⚠️` note in `lib/renovate.ts`), so it must not pull in `lib/util/fs` and its config/logger dependencies. Exempt the directory, as is already done for `lib/logger`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs: shorten doc --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
373 lines
9.9 KiB
TypeScript
373 lines
9.9 KiB
TypeScript
import stream from 'node:stream';
|
|
import util from 'node:util';
|
|
import { isNonEmptyString } from '@sindresorhus/is';
|
|
import { findUp } from 'find-up';
|
|
import fs from 'fs-extra';
|
|
import upath from 'upath';
|
|
import { GlobalConfig } from '../../config/global.ts';
|
|
import { logger } from '../../logger/index.ts';
|
|
import { logWarningIfUnicodeHiddenCharactersInPackageFile } from '../unicode.ts';
|
|
import { ensureCachePath, ensureLocalPath, isValidPath } from './util.ts';
|
|
|
|
export const pipeline = util.promisify(stream.pipeline);
|
|
|
|
export function getParentDir(fileName: string): string {
|
|
return upath.parse(fileName).dir;
|
|
}
|
|
|
|
export function getSiblingFileName(
|
|
fileName: string,
|
|
siblingName: string,
|
|
): string {
|
|
const subDirectory = getParentDir(fileName);
|
|
return upath.join(subDirectory, siblingName);
|
|
}
|
|
|
|
export async function readLocalFile(fileName: string): Promise<Buffer | null>;
|
|
export async function readLocalFile(
|
|
fileName: string,
|
|
encoding: 'utf8',
|
|
): Promise<string | null>;
|
|
export async function readLocalFile(
|
|
fileName: string,
|
|
encoding?: BufferEncoding,
|
|
): Promise<string | Buffer | null> {
|
|
const localFileName = ensureLocalPath(fileName);
|
|
try {
|
|
const fileContent = encoding
|
|
? await fs.readFile(localFileName, encoding)
|
|
: await fs.readFile(localFileName);
|
|
|
|
logWarningIfUnicodeHiddenCharactersInPackageFile(fileName, fileContent);
|
|
|
|
return fileContent;
|
|
} catch (err) {
|
|
logger.trace({ err }, 'Error reading local file');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function readLocalSymlink(
|
|
fileName: string,
|
|
): Promise<string | null> {
|
|
const localFileName = ensureLocalPath(fileName);
|
|
try {
|
|
const linkContent = await fs.readlink(localFileName);
|
|
return linkContent;
|
|
} catch (err) {
|
|
logger.trace({ err }, 'Error reading local symlink');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function writeLocalFile(
|
|
fileName: string,
|
|
fileContent: string | Buffer,
|
|
): Promise<void> {
|
|
const localFileName = ensureLocalPath(fileName);
|
|
await fs.outputFile(localFileName, fileContent);
|
|
}
|
|
|
|
export async function deleteLocalFile(fileName: string): Promise<void> {
|
|
// This a failsafe and hopefully will never be triggered
|
|
if (GlobalConfig.get('platform') === 'local') {
|
|
throw new Error('Cannot delete file when platform=local');
|
|
}
|
|
const localDir = GlobalConfig.get('localDir');
|
|
if (localDir) {
|
|
const localFileName = ensureLocalPath(fileName);
|
|
await fs.remove(localFileName);
|
|
}
|
|
}
|
|
|
|
export async function renameLocalFile(
|
|
fromFile: string,
|
|
toFile: string,
|
|
): Promise<void> {
|
|
const fromPath = ensureLocalPath(fromFile);
|
|
const toPath = ensureLocalPath(toFile);
|
|
await fs.move(fromPath, toPath);
|
|
}
|
|
|
|
export async function renameCacheFile(
|
|
fromFile: string,
|
|
toFile: string,
|
|
): Promise<void> {
|
|
const fromPath = ensureCachePath(fromFile);
|
|
const toPath = ensureCachePath(toFile);
|
|
await fs.rename(fromPath, toPath);
|
|
}
|
|
|
|
export async function ensureDir(dirName: string): Promise<void> {
|
|
// v8 ignore else -- TODO: add test #40625
|
|
if (isNonEmptyString(dirName)) {
|
|
await fs.ensureDir(dirName);
|
|
}
|
|
}
|
|
|
|
export async function ensureLocalDir(dirName: string): Promise<string> {
|
|
const fullPath = ensureLocalPath(dirName);
|
|
await fs.ensureDir(fullPath);
|
|
return fullPath;
|
|
}
|
|
|
|
export async function ensureCacheDir(name: string): Promise<string> {
|
|
const cacheDirName = ensureCachePath(`others/${name}`);
|
|
await fs.ensureDir(cacheDirName);
|
|
return cacheDirName;
|
|
}
|
|
|
|
/**
|
|
* Return the path of the private cache directory. This directory is wiped
|
|
* between repositories, so they can be used to store private registries' index
|
|
* without risk of that information leaking to other repositories/users.
|
|
*/
|
|
export function privateCacheDir(): string {
|
|
// TODO: types (#22198)
|
|
const cacheDir = GlobalConfig.get('cacheDir');
|
|
return upath.join(cacheDir, '__renovate-private-cache');
|
|
}
|
|
|
|
export async function localPathExists(pathName: string): Promise<boolean> {
|
|
// Works for both files as well as directories
|
|
const path = ensureLocalPath(pathName);
|
|
try {
|
|
const s = await fs.stat(path);
|
|
return !!s;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate local path without throwing.
|
|
* @param path Path to check
|
|
* @returns `true` if given `path` is a valid local path, otherwise `false`.
|
|
*/
|
|
export function isValidLocalPath(path: string): boolean {
|
|
return isValidPath(path, 'localDir');
|
|
}
|
|
|
|
/**
|
|
* Tries to find `otherFileName` in the directory where
|
|
* `existingFileNameWithPath` is, then in its parent directory, then in the
|
|
* grandparent, until we reach the top-level directory. All paths
|
|
* must be relative to `localDir`.
|
|
*/
|
|
export async function findLocalSiblingOrParent(
|
|
existingFileNameWithPath: string,
|
|
otherFileName: string,
|
|
): Promise<string | null> {
|
|
if (upath.isAbsolute(existingFileNameWithPath)) {
|
|
return null;
|
|
}
|
|
if (upath.isAbsolute(otherFileName)) {
|
|
return null;
|
|
}
|
|
|
|
let current = existingFileNameWithPath;
|
|
while (current !== '') {
|
|
current = getParentDir(current);
|
|
const candidate = upath.join(current, otherFileName);
|
|
if (await localPathExists(candidate)) {
|
|
return candidate;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Get files by name from directory
|
|
*/
|
|
export async function readLocalDirectory(path: string): Promise<string[]> {
|
|
const localPath = ensureLocalPath(path);
|
|
const fileList = await fs.readdir(localPath);
|
|
return fileList;
|
|
}
|
|
|
|
export function createCacheWriteStream(path: string): fs.WriteStream {
|
|
const fullPath = ensureCachePath(path);
|
|
return fs.createWriteStream(fullPath);
|
|
}
|
|
|
|
export function createCacheReadStream(path: string): fs.ReadStream {
|
|
const fullPath = ensureCachePath(path);
|
|
return fs.createReadStream(fullPath);
|
|
}
|
|
|
|
export async function localPathIsFile(pathName: string): Promise<boolean> {
|
|
const path = ensureLocalPath(pathName);
|
|
try {
|
|
const s = await fs.stat(path);
|
|
return s.isFile();
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export async function localPathIsSymbolicLink(
|
|
pathName: string,
|
|
): Promise<boolean> {
|
|
const path = ensureLocalPath(pathName);
|
|
try {
|
|
const s = await fs.lstat(path);
|
|
return s.isSymbolicLink();
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Find a file or directory by walking up parent directories within localDir
|
|
*/
|
|
|
|
export async function findUpLocal(
|
|
fileName: string | string[],
|
|
cwd: string,
|
|
): Promise<string | null> {
|
|
// TODO: types (#22198)
|
|
const localDir = GlobalConfig.get('localDir');
|
|
const absoluteCwd = upath.join(localDir, cwd);
|
|
const normalizedAbsoluteCwd = upath.normalizeSafe(absoluteCwd);
|
|
const res = await findUp(fileName, {
|
|
cwd: normalizedAbsoluteCwd,
|
|
type: 'file',
|
|
});
|
|
// Return null if nothing found
|
|
if (!isNonEmptyString(res) || !isNonEmptyString(localDir)) {
|
|
return null;
|
|
}
|
|
const safePath = upath.normalizeSafe(res);
|
|
// Return relative path if file is inside of local dir
|
|
if (safePath.startsWith(localDir)) {
|
|
let relativePath = safePath.replace(localDir, '');
|
|
// v8 ignore else -- TODO: add test #40625
|
|
if (relativePath.startsWith('/')) {
|
|
relativePath = relativePath.substring(1);
|
|
}
|
|
return relativePath;
|
|
}
|
|
// Return null if found file is outside of localDir
|
|
return null;
|
|
}
|
|
|
|
export function chmodLocalFile(
|
|
fileName: string,
|
|
mode: string | number,
|
|
): Promise<void> {
|
|
const fullFileName = ensureLocalPath(fileName);
|
|
return fs.chmod(fullFileName, mode);
|
|
}
|
|
|
|
export async function statLocalFile(
|
|
fileName: string,
|
|
): Promise<fs.Stats | null> {
|
|
const fullFileName = ensureLocalPath(fileName);
|
|
try {
|
|
return await fs.stat(fullFileName);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function statCacheFile(
|
|
pathName: string,
|
|
): Promise<fs.Stats | null> {
|
|
const path = ensureCachePath(pathName);
|
|
try {
|
|
return await fs.stat(path);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function listCacheDir(
|
|
path: string,
|
|
options: { recursive: boolean } = { recursive: false },
|
|
): Promise<string[]> {
|
|
const fullPath = ensureCachePath(path);
|
|
return fs.readdir(fullPath, {
|
|
encoding: 'utf-8',
|
|
recursive: options.recursive,
|
|
});
|
|
}
|
|
|
|
export async function rmCache(path: string): Promise<void> {
|
|
const fullPath = ensureCachePath(path);
|
|
// force: a missing path is a no-op, callers use this for cleanup
|
|
await fs.rm(fullPath, { recursive: true, force: true });
|
|
}
|
|
|
|
export async function cachePathExists(pathName: string): Promise<boolean> {
|
|
const path = ensureCachePath(pathName);
|
|
try {
|
|
const s = await fs.stat(path);
|
|
return !!s;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export async function cachePathIsFile(pathName: string): Promise<boolean> {
|
|
const path = ensureCachePath(pathName);
|
|
try {
|
|
const s = await fs.stat(path);
|
|
return s.isFile();
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export async function readCacheFile(fileName: string): Promise<Buffer>;
|
|
export async function readCacheFile(
|
|
fileName: string,
|
|
encoding: 'utf8',
|
|
): Promise<string>;
|
|
export function readCacheFile(
|
|
fileName: string,
|
|
encoding?: BufferEncoding,
|
|
): Promise<string | Buffer> {
|
|
const fullPath = ensureCachePath(fileName);
|
|
return encoding ? fs.readFile(fullPath, encoding) : fs.readFile(fullPath);
|
|
}
|
|
|
|
export function outputCacheFile(
|
|
file: string,
|
|
data: string | NodeJS.ArrayBufferView,
|
|
): Promise<void> {
|
|
const filePath = ensureCachePath(file);
|
|
return fs.outputFile(filePath, data);
|
|
}
|
|
|
|
export async function readSystemFile(fileName: string): Promise<Buffer>;
|
|
export async function readSystemFile(
|
|
fileName: string,
|
|
encoding: 'utf8',
|
|
): Promise<string>;
|
|
export function readSystemFile(
|
|
fileName: string,
|
|
encoding?: BufferEncoding,
|
|
): Promise<string | Buffer> {
|
|
return encoding ? fs.readFile(fileName, encoding) : fs.readFile(fileName);
|
|
}
|
|
|
|
export async function writeSystemFile(
|
|
fileName: string,
|
|
data: string | Buffer,
|
|
): Promise<void> {
|
|
await fs.outputFile(fileName, data);
|
|
}
|
|
|
|
export async function getLocalFiles(
|
|
fileNames: string[],
|
|
): Promise<Record<string, string | null>> {
|
|
const fileContentMap: Record<string, string | null> = {};
|
|
|
|
for (const fileName of fileNames) {
|
|
fileContentMap[fileName] = await readLocalFile(fileName, 'utf8');
|
|
}
|
|
|
|
return fileContentMap;
|
|
}
|