Files
renovate/lib/util/fs/index.ts
Sebastian Poxhofer 716baf630b fix(linters): add rules for filesystem helpers and Date (#44685)
* 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>
2026-07-25 16:00:44 +00:00

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