diff --git a/scripts/build-externals/esbuild-config.mjs b/scripts/build-externals/esbuild-config.mjs index dba6d45e..0792a6d4 100644 --- a/scripts/build-externals/esbuild-config.mjs +++ b/scripts/build-externals/esbuild-config.mjs @@ -177,21 +177,6 @@ function createStubPlugin(stubMap = STUB_MAP) { } } -// Shared dependencies that exist as standalone bundle files in dist/external/. -// These must be marked external in bundles that would otherwise inline them, -// so that at runtime they resolve to the existing bundle wrappers. -const SHARED_EXTERNAL_DEPS = [ - 'debug', - 'has-flag', - 'p-map', - 'signal-exit', - 'spdx-correct', - 'spdx-expression-parse', - 'supports-color', - 'which', - 'yoctocolors-cjs', -] - /** * Get package-specific esbuild options. * @@ -209,12 +194,6 @@ export function getPackageSpecificOptions(packageName) { } else if (packageName === 'zod') { // Zod has localization files we don't need. opts.external = [...(opts.external || []), './locales/*'] - } else if (packageName === 'debug') { - // Mark supports-color as external - it exists as a standalone bundle wrapper. - opts.external = [...(opts.external || []), 'supports-color'] - } else if (packageName === 'pico-pack') { - // Mark p-map as external - it has its own standalone bundle. - opts.external = [...(opts.external || []), 'p-map'] } else if (packageName === 'external-pack') { // Inquirer packages have heavy dependencies we can exclude. opts.external = [...(opts.external || []), 'rxjs/operators'] @@ -223,10 +202,6 @@ export function getPackageSpecificOptions(packageName) { opts.footer = { js: 'if (module.exports && module.exports.default && Object.keys(module.exports).length === 1) { module.exports = module.exports.default; }', } - } else if (packageName === 'npm-pack') { - // Mark shared deps as external - they exist as standalone bundle wrappers. - // This eliminates ~100KB of duplication in the npm-pack bundle. - opts.external = [...(opts.external || []), ...SHARED_EXTERNAL_DEPS] } else if (packageName === '@socketregistry/packageurl-js') { // packageurl-js imports from socket-lib, creating a circular dependency. // Mark socket-lib imports as external to avoid bundling issues. @@ -256,6 +231,8 @@ export function getEsbuildConfig(entryPoint, outfile, packageOpts = {}) { entryPoints: [entryPoint], bundle: true, platform: 'node', + // Intentionally conservative: node18 ensures maximum compatibility + // for bundled externals consumed by downstream packages. target: 'node18', format: 'cjs', outfile, @@ -296,7 +273,7 @@ export function getEsbuildConfig(entryPoint, outfile, packageOpts = {}) { keepNames: true, // Additional optimizations: pure: ['console.log', 'console.debug', 'console.warn'], - drop: ['debugger', 'console'], + drop: ['debugger'], ignoreAnnotations: false, // Define compile-time constants for dead code elimination. define: { diff --git a/scripts/build-externals/orchestrator.mjs b/scripts/build-externals/orchestrator.mjs index af6edb3f..5fde8f0b 100644 --- a/scripts/build-externals/orchestrator.mjs +++ b/scripts/build-externals/orchestrator.mjs @@ -7,10 +7,14 @@ import { promises as fs } from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' +import { getDefaultLogger } from '@socketsecurity/lib-stable/logger' + import { bundlePackage } from './bundler.mjs' import { externalPackages, scopedPackages } from './config.mjs' import { ensureDir } from './copy-files.mjs' +const logger = getDefaultLogger() + const __dirname = path.dirname(fileURLToPath(import.meta.url)) const rootDir = path.resolve(__dirname, '..', '..') const distExternalDir = path.join(rootDir, 'dist', 'external') @@ -84,7 +88,7 @@ async function bundleAllPackages(options = {}) { } } catch { if (!quiet) { - console.log(` Skipping optional package ${scope}/${name}`) + logger.log(` Skipping optional package ${scope}/${name}`) } } } else { @@ -123,7 +127,7 @@ async function bundleAllPackages(options = {}) { } } catch { if (!quiet) { - console.log(` Skipping optional package ${scope}/${pkg}`) + logger.log(` Skipping optional package ${scope}/${pkg}`) } } } else { @@ -226,7 +230,7 @@ async function fixNodeGypStrings(dir, options = {}) { await fs.writeFile(filePath, fixed, 'utf8') if (!quiet) { - console.log( + logger.log( ` Fixed node-gyp string in ${path.relative(path.join(dir, '..', '..'), filePath)}`, ) } diff --git a/scripts/fix/commonjs-exports.mjs b/scripts/fix/commonjs-exports.mjs index 8cfc605e..db2d3f99 100644 --- a/scripts/fix/commonjs-exports.mjs +++ b/scripts/fix/commonjs-exports.mjs @@ -17,7 +17,7 @@ import { getDefaultLogger } from '@socketsecurity/lib-stable/logger' const logger = getDefaultLogger() const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const distDir = path.resolve(__dirname, '..', 'dist') +const distDir = path.resolve(__dirname, '..', '..', 'dist') /** * Process files in a directory and fix CommonJS exports. diff --git a/scripts/fix/path-aliases.mjs b/scripts/fix/path-aliases.mjs index 60926deb..9d3559fd 100644 --- a/scripts/fix/path-aliases.mjs +++ b/scripts/fix/path-aliases.mjs @@ -14,8 +14,8 @@ import { getDefaultLogger } from '@socketsecurity/lib-stable/logger' const logger = getDefaultLogger() const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const distDir = path.resolve(__dirname, '..', 'dist') -const _srcDir = path.resolve(__dirname, '..', 'src') +const distDir = path.resolve(__dirname, '..', '..', 'dist') +const _srcDir = path.resolve(__dirname, '..', '..', 'src') // Map of path aliases to their actual directories const pathAliases = { diff --git a/scripts/test/cover.mjs b/scripts/test/cover.mjs index 8901b347..8e279ebe 100644 --- a/scripts/test/cover.mjs +++ b/scripts/test/cover.mjs @@ -43,7 +43,10 @@ const buildResult = await spawn('node', ['scripts/build/main.mjs'], { }, }) if (buildResult.code !== 0) { - throw new Error('Build with source maps failed') + logger.error('Build with source maps failed') + process.exitCode = 1 + // eslint-disable-next-line unicorn/no-process-exit + process.exit() } // Run vitest with coverage enabled, capturing output diff --git a/scripts/test/filter.mjs b/scripts/test/filter.mjs index d99da733..a6566537 100644 --- a/scripts/test/filter.mjs +++ b/scripts/test/filter.mjs @@ -15,7 +15,7 @@ import { getDefaultLogger } from '@socketsecurity/lib-stable/logger' const logger = getDefaultLogger() const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const projectRoot = path.resolve(__dirname, '..') +const projectRoot = path.resolve(__dirname, '..', '..') // Find all coverage JSON files const coverageDir = path.join(projectRoot, 'coverage') diff --git a/scripts/test/main.mjs b/scripts/test/main.mjs index a41be04d..13eabce7 100644 --- a/scripts/test/main.mjs +++ b/scripts/test/main.mjs @@ -324,8 +324,13 @@ async function runIsolatedTests(options) { cwd: rootPath, env: { ...process.env, - NODE_OPTIONS: - `${process.env.NODE_OPTIONS || ''} --max-old-space-size=${process.env.CI ? 8192 : 4096} --unhandled-rejections=warn`.trim(), + NODE_OPTIONS: [ + ...(process.env.NODE_OPTIONS || '') + .split(/\s+/) + .filter(opt => opt && !opt.startsWith('--max-old-space-size')), + `--max-old-space-size=${process.env.CI ? 8192 : 4096}`, + '--unhandled-rejections=warn', + ].join(' '), VITEST: '1', }, stdio: 'inherit', diff --git a/scripts/validate/no-extraneous-dependencies.mjs b/scripts/validate/no-extraneous-dependencies.mjs index 85f42449..bac10c51 100644 --- a/scripts/validate/no-extraneous-dependencies.mjs +++ b/scripts/validate/no-extraneous-dependencies.mjs @@ -328,4 +328,7 @@ async function main() { } } -main() +main().catch(error => { + logger.fail(`Validation failed: ${error.message}`) + process.exitCode = 1 +}) diff --git a/src/abort.ts b/src/abort.ts index 63e7ad19..1fc01aec 100644 --- a/src/abort.ts +++ b/src/abort.ts @@ -44,7 +44,5 @@ export function createTimeoutSignal(ms: number): AbortSignal { if (ms <= 0) { throw new TypeError('timeout must be a positive number') } - const controller = new AbortController() - setTimeout(() => controller.abort(), ms) - return controller.signal + return AbortSignal.timeout(Math.ceil(ms)) } diff --git a/src/ansi.ts b/src/ansi.ts index 7e57ab57..9260b949 100644 --- a/src/ansi.ts +++ b/src/ansi.ts @@ -27,7 +27,7 @@ const ANSI_REGEX = /\x1b\[[0-9;]*m/g export function ansiRegex(options?: { onlyFirst?: boolean }): RegExp { const { onlyFirst } = options ?? {} // Valid string terminator sequences are BEL, ESC\, and 0x9c. - const ST = '(?:\\u0007\\u001B\\u005C|\\u009C)' + const ST = '(?:\\u0007|\\u001B\\u005C|\\u009C)' // OSC sequences only: ESC ] ... ST (non-greedy until the first ST). const osc = `(?:\\u001B\\][\\s\\S]*?${ST})` // CSI and related: ESC/C1, optional intermediates, optional params (supports ; and :) then final byte. diff --git a/src/argv/parse.ts b/src/argv/parse.ts index 4a574d9c..e56b9ce4 100644 --- a/src/argv/parse.ts +++ b/src/argv/parse.ts @@ -262,10 +262,5 @@ export function getPositionalArgs(startIndex = 2): string[] { * Check if a specific flag is present in argv. */ export function hasFlag(flag: string, argv = process.argv): boolean { - const flagVariants = [ - `--${flag}`, - // Short flag. - `-${flag.charAt(0)}`, - ] - return flagVariants.some(variant => argv.includes(variant)) + return argv.includes(`--${flag}`) } diff --git a/src/cache-with-ttl.ts b/src/cache-with-ttl.ts index 27ca6891..1ff42213 100644 --- a/src/cache-with-ttl.ts +++ b/src/cache-with-ttl.ts @@ -252,9 +252,18 @@ export function createTtlCache(options?: TtlCacheOptions): TtlCache { // Check persistent cache. const cacheEntry = await cacache.safeGet(fullKey) if (cacheEntry) { - const entry = JSON.parse( - cacheEntry.data.toString('utf8'), - ) as TtlCacheEntry + let entry: TtlCacheEntry + try { + entry = JSON.parse(cacheEntry.data.toString('utf8')) as TtlCacheEntry + } catch { + // Corrupted cache entry, treat as miss and remove. + try { + await cacache.remove(fullKey) + } catch { + // Ignore removal errors. + } + return undefined + } if (!isExpired(entry)) { // Update in-memory cache. if (opts.memoize) { diff --git a/src/dlx/manifest.ts b/src/dlx/manifest.ts index 52fdfeeb..70149890 100644 --- a/src/dlx/manifest.ts +++ b/src/dlx/manifest.ts @@ -54,6 +54,8 @@ import { getDefaultLogger } from '../logger' import { getSocketDlxDir } from '../paths/socket' import { processLock } from '../process-lock' +const fs = getFs() +const path = getPath() const logger = getDefaultLogger() /** @@ -148,8 +150,7 @@ export class DlxManifest { constructor(options: DlxManifestOptions = {}) { this.manifestPath = - options.manifestPath ?? - getPath().join(getSocketDlxDir(), MANIFEST_FILE_NAME) + options.manifestPath ?? path.join(getSocketDlxDir(), MANIFEST_FILE_NAME) this.lockPath = `${this.manifestPath}.lock` } @@ -159,7 +160,7 @@ export class DlxManifest { */ private readManifest(): Record { try { - if (!getFs().existsSync(this.manifestPath)) { + if (!fs.existsSync(this.manifestPath)) { return Object.create(null) } @@ -191,7 +192,7 @@ export class DlxManifest { data: Record, ): Promise { // Ensure directory exists. - const manifestDir = getPath().dirname(this.manifestPath) + const manifestDir = path.dirname(this.manifestPath) try { safeMkdirSync(manifestDir, { recursive: true }) } catch (error) { @@ -205,22 +206,13 @@ export class DlxManifest { const tempPath = `${this.manifestPath}.tmp` try { - getFs().writeFileSync(tempPath, content, 'utf8') - getFs().writeFileSync(this.manifestPath, content, 'utf8') - - // Clean up temp file. - try { - if (getFs().existsSync(tempPath)) { - getFs().unlinkSync(tempPath) - } - } catch { - // Cleanup failed, not critical. - } + fs.writeFileSync(tempPath, content, 'utf8') + fs.renameSync(tempPath, this.manifestPath) } catch (error) { // Clean up temp file on error. try { - if (getFs().existsSync(tempPath)) { - getFs().unlinkSync(tempPath) + if (fs.existsSync(tempPath)) { + fs.unlinkSync(tempPath) } } catch { // Best effort cleanup. @@ -235,20 +227,22 @@ export class DlxManifest { async clear(name: string): Promise { await processLock.withLock(this.lockPath, async () => { try { - if (!getFs().existsSync(this.manifestPath)) { + if (!fs.existsSync(this.manifestPath)) { return } - const content = getFs().readFileSync(this.manifestPath, 'utf8') + const content = fs.readFileSync(this.manifestPath, 'utf8') if (!content.trim()) { return } - const data = JSON.parse(content) as Record + const data = JSON.parse(content) as Record< + string, + ManifestEntry | StoreRecord + > delete data[name] - const updatedContent = JSON.stringify(data, null, 2) - getFs().writeFileSync(this.manifestPath, updatedContent, 'utf8') + await this.writeManifest(data) } catch (error) { logger.warn( `Failed to clear cache for ${name}: ${error instanceof Error ? error.message : String(error)}`, @@ -263,8 +257,8 @@ export class DlxManifest { async clearAll(): Promise { await processLock.withLock(this.lockPath, async () => { try { - if (getFs().existsSync(this.manifestPath)) { - getFs().unlinkSync(this.manifestPath) + if (fs.existsSync(this.manifestPath)) { + fs.unlinkSync(this.manifestPath) } } catch (error) { logger.warn( @@ -295,7 +289,7 @@ export class DlxManifest { */ getAllPackages(): string[] { try { - if (!getFs().existsSync(this.manifestPath)) { + if (!fs.existsSync(this.manifestPath)) { return [] } @@ -356,8 +350,8 @@ export class DlxManifest { // Read existing data. try { - if (getFs().existsSync(this.manifestPath)) { - const content = getFs().readFileSync(this.manifestPath, 'utf8') + if (fs.existsSync(this.manifestPath)) { + const content = fs.readFileSync(this.manifestPath, 'utf8') if (content.trim()) { data = JSON.parse(content) as Record } @@ -372,7 +366,7 @@ export class DlxManifest { data[name] = record // Ensure directory exists. - const manifestDir = getPath().dirname(this.manifestPath) + const manifestDir = path.dirname(this.manifestPath) try { safeMkdirSync(manifestDir, { recursive: true }) } catch (error) { @@ -386,22 +380,13 @@ export class DlxManifest { const tempPath = `${this.manifestPath}.tmp` try { - getFs().writeFileSync(tempPath, content, 'utf8') - getFs().writeFileSync(this.manifestPath, content, 'utf8') - - // Clean up temp file. - try { - if (getFs().existsSync(tempPath)) { - getFs().unlinkSync(tempPath) - } - } catch { - // Cleanup failed, not critical. - } + fs.writeFileSync(tempPath, content, 'utf8') + fs.renameSync(tempPath, this.manifestPath) } catch (error) { // Clean up temp file on error. try { - if (getFs().existsSync(tempPath)) { - getFs().unlinkSync(tempPath) + if (fs.existsSync(tempPath)) { + fs.unlinkSync(tempPath) } } catch { // Best effort cleanup. diff --git a/src/github.ts b/src/github.ts index 3d503690..20ebd5c2 100644 --- a/src/github.ts +++ b/src/github.ts @@ -762,9 +762,7 @@ export async function cacheFetchGhsa( } // Use getOrFetch to prevent race conditions (thundering herd). - const cached = await cache.getOrFetch(key, async () => { - const data = await fetchGhsaDetails(ghsaId, options) - return JSON.stringify(data) - }) - return JSON.parse(cached as string) as GhsaDetails + return (await cache.getOrFetch(key, async () => { + return await fetchGhsaDetails(ghsaId, options) + })) as GhsaDetails } diff --git a/src/http-request.ts b/src/http-request.ts index e4903248..3fc24e1b 100644 --- a/src/http-request.ts +++ b/src/http-request.ts @@ -998,6 +998,10 @@ async function httpRequestAttempt( resolve(response) }) + + res.on('error', (error: Error) => { + reject(error) + }) }, ) diff --git a/src/ipc.ts b/src/ipc.ts index 15f5b716..9677fcd5 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -602,6 +602,7 @@ export function waitForIpc( cleanup = onIpc(handleMessage) if (timeout > 0) { timeoutId = setTimeout(handleTimeout, timeout) + timeoutId.unref() } }) } diff --git a/src/memoization.ts b/src/memoization.ts index 7971407e..dd37ebb2 100644 --- a/src/memoization.ts +++ b/src/memoization.ts @@ -5,6 +5,11 @@ import { debugLog } from './debug' +/** + * Global registry of memoization cache clear functions. + */ +const cacheRegistry: Array<() => void> = [] + /** * Options for memoization behavior. */ @@ -69,6 +74,12 @@ export function memoize( const cache = new Map>() const accessOrder: string[] = [] + // Register for global clearing. + cacheRegistry.push(() => { + cache.clear() + accessOrder.length = 0 + }) + function evictLRU(): void { if (cache.size >= maxSize && accessOrder.length > 0) { const oldest = accessOrder.shift() @@ -94,17 +105,25 @@ export function memoize( // Check cache const cached = cache.get(key) - if (cached && !isExpired(cached)) { - cached.hits++ - // Move to end of access order (LRU) + if (cached) { + if (!isExpired(cached)) { + cached.hits++ + // Move to end of access order (LRU) + const index = accessOrder.indexOf(key) + if (index !== -1) { + accessOrder.splice(index, 1) + } + accessOrder.push(key) + + debugLog(`[memoize:${name}] hit`, { key, hits: cached.hits }) + return cached.value + } + // Clean up expired entry before re-caching. + cache.delete(key) const index = accessOrder.indexOf(key) if (index !== -1) { accessOrder.splice(index, 1) } - accessOrder.push(key) - - debugLog(`[memoize:${name}] hit`, { key, hits: cached.hits }) - return cached.value } // Cache miss - compute value @@ -158,6 +177,12 @@ export function memoizeAsync( const cache = new Map>>() const accessOrder: string[] = [] + // Register for global clearing. + cacheRegistry.push(() => { + cache.clear() + accessOrder.length = 0 + }) + function evictLRU(): void { if (cache.size >= maxSize && accessOrder.length > 0) { const oldest = accessOrder.shift() @@ -178,30 +203,48 @@ export function memoizeAsync( return Date.now() - entry.timestamp > ttl } + // Track in-flight refreshes to prevent thundering herd on TTL expiry. + const refreshing = new Set() + return async function memoized(...args: Args): Promise { const key = keyGen(...args) // Check cache const cached = cache.get(key) - if (cached && !isExpired(cached)) { - cached.hits++ - // Move to end of access order (LRU) + if (cached) { + if (!isExpired(cached)) { + cached.hits++ + // Move to end of access order (LRU) + const index = accessOrder.indexOf(key) + if (index !== -1) { + accessOrder.splice(index, 1) + } + accessOrder.push(key) + + debugLog(`[memoizeAsync:${name}] hit`, { key, hits: cached.hits }) + return await cached.value + } + // Expired but another caller is already refreshing — return stale. + if (refreshing.has(key)) { + debugLog(`[memoizeAsync:${name}] stale-dedup`, { key }) + return await cached.value + } + // Clean up expired entry before re-caching. + cache.delete(key) const index = accessOrder.indexOf(key) if (index !== -1) { accessOrder.splice(index, 1) } - accessOrder.push(key) - - debugLog(`[memoizeAsync:${name}] hit`, { key, hits: cached.hits }) - return await cached.value } // Cache miss - compute value debugLog(`[memoizeAsync:${name}] miss`, { key }) + refreshing.add(key) // Create promise and cache it immediately (for deduplication) const promise = fn(...args).then( result => { + refreshing.delete(key) // Success - update cache entry with resolved promise const entry = cache.get(key) if (entry) { @@ -210,6 +253,7 @@ export function memoizeAsync( return result }, error => { + refreshing.delete(key) // Failure - remove from cache to allow retry cache.delete(key) const index = accessOrder.indexOf(key) @@ -277,9 +321,10 @@ export function Memoize(options: MemoizeOptions = {}) { * Useful for testing or when you need to force recomputation. */ export function clearAllMemoizationCaches(): void { - // Note: This requires the memoized functions to be tracked globally. - // For now, this is a placeholder that logs the intent. debugLog('[memoize:all] clear', { action: 'clear-all-caches' }) + for (const clear of cacheRegistry) { + clear() + } } /** @@ -307,10 +352,9 @@ export function memoizeWeak( const cache = new WeakMap() return function memoized(key: K): Result { - const cached = cache.get(key) - if (cached !== undefined) { + if (cache.has(key)) { debugLog(`[memoizeWeak:${fn.name}] hit`) - return cached + return cache.get(key) as Result } debugLog(`[memoizeWeak:${fn.name}] miss`) diff --git a/src/packages/isolation.ts b/src/packages/isolation.ts index 1b0b7aa2..16b7a8b5 100644 --- a/src/packages/isolation.ts +++ b/src/packages/isolation.ts @@ -66,7 +66,15 @@ async function mergePackageJson( originalPkgJson: PackageJson | undefined, ): Promise { const fs = getFs() - const pkgJson = JSON.parse(await fs.promises.readFile(pkgJsonPath, 'utf8')) + let pkgJson: PackageJson + try { + pkgJson = JSON.parse(await fs.promises.readFile(pkgJsonPath, 'utf8')) + } catch (error) { + throw new Error( + `Failed to parse ${pkgJsonPath}: ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ) + } const mergedPkgJson = originalPkgJson ? { ...originalPkgJson, ...pkgJson } : pkgJson diff --git a/src/process-lock.ts b/src/process-lock.ts index bd1f7b1b..836ad68e 100644 --- a/src/process-lock.ts +++ b/src/process-lock.ts @@ -279,8 +279,16 @@ class ProcessLockManager { throw new Error(`Lock already exists: ${lockPath}`) } - // Atomic lock acquisition via mkdir with recursive to create parent dirs. - mkdirSync(lockPath, { recursive: true }) + // Ensure parent directory exists. + const lastSlash = lockPath.lastIndexOf('/') + if (lastSlash > 0) { + mkdirSync(lockPath.slice(0, lastSlash), { recursive: true }) + } + + // Atomic lock acquisition via mkdir (without recursive). + // Without recursive, mkdirSync throws EEXIST if another process + // created the directory between the existsSync check and here. + mkdirSync(lockPath) // Track lock for cleanup. this.activeLocks.add(lockPath) diff --git a/src/promise-queue.ts b/src/promise-queue.ts index 9d0ae228..7a492d8a 100644 --- a/src/promise-queue.ts +++ b/src/promise-queue.ts @@ -12,6 +12,7 @@ type QueuedTask = { export class PromiseQueue { private queue: Array> = [] private running = 0 + private idleResolvers: Array<() => void> = [] private readonly maxConcurrency: number private readonly maxQueueLength: number | undefined @@ -38,7 +39,10 @@ export class PromiseQueue { return await new Promise((resolve, reject) => { const task: QueuedTask = { fn, resolve, reject } - if (this.maxQueueLength && this.queue.length >= this.maxQueueLength) { + if ( + this.maxQueueLength !== undefined && + this.queue.length >= this.maxQueueLength + ) { // Drop oldest task to prevent memory buildup const droppedTask = this.queue.shift() if (droppedTask) { @@ -53,6 +57,7 @@ export class PromiseQueue { private runNext(): void { if (this.running >= this.maxConcurrency || this.queue.length === 0) { + this.notifyIdleIfNeeded() return } @@ -73,19 +78,24 @@ export class PromiseQueue { }) } + private notifyIdleIfNeeded(): void { + if (this.running === 0 && this.queue.length === 0) { + for (const resolve of this.idleResolvers) { + resolve() + } + this.idleResolvers = [] + } + } + /** * Wait for all queued and running tasks to complete */ async onIdle(): Promise { + if (this.running === 0 && this.queue.length === 0) { + return + } return await new Promise(resolve => { - const check = () => { - if (this.running === 0 && this.queue.length === 0) { - resolve() - } else { - setImmediate(check) - } - } - check() + this.idleResolvers.push(resolve) }) } @@ -107,6 +117,11 @@ export class PromiseQueue { * Clear all pending tasks from the queue (does not affect running tasks) */ clear(): void { + const pending = this.queue this.queue = [] + for (const task of pending) { + task.reject(new Error('Task cancelled: queue cleared')) + } + this.notifyIdleIfNeeded() } } diff --git a/src/promises.ts b/src/promises.ts index 5e5eb751..58e24dd6 100644 --- a/src/promises.ts +++ b/src/promises.ts @@ -678,9 +678,7 @@ export async function pRetry( // eslint-disable-next-line no-await-in-loop return await callbackFn(...(args || []), { signal }) } catch (e) { - if (error === UNDEFINED_TOKEN) { - error = e - } + error = e if (attempts < 0) { break } diff --git a/src/stdio/progress.ts b/src/stdio/progress.ts index 9a3c0c8d..2b8d4364 100644 --- a/src/stdio/progress.ts +++ b/src/stdio/progress.ts @@ -279,7 +279,7 @@ export function createProgressIndicator( total: number, label?: string | undefined, ): string { - const percent = Math.floor((current / total) * 100) + const percent = total === 0 ? 0 : Math.floor((current / total) * 100) const progress = `${current}/${total}` let output = '' diff --git a/src/validation/json-parser.ts b/src/validation/json-parser.ts index 6fe1720a..afd8e2e2 100644 --- a/src/validation/json-parser.ts +++ b/src/validation/json-parser.ts @@ -12,7 +12,19 @@ import type { JsonParseOptions, JsonParseResult, Schema } from './types' -const { hasOwn: ObjectHasOwn } = Object +const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype']) + +/** + * JSON.parse reviver that rejects prototype pollution keys at any depth. + */ +function prototypePollutionReviver(key: string, value: unknown): unknown { + if (DANGEROUS_KEYS.has(key)) { + throw new Error( + 'JSON contains potentially malicious prototype pollution keys', + ) + } + return value +} /** * Safely parse JSON with optional schema validation and security controls. @@ -71,31 +83,16 @@ export function safeJsonParse( ) } - // Parse JSON + // Parse JSON (reviver checks prototype pollution keys at all depths). let parsed: unknown try { - parsed = JSON.parse(jsonString) + parsed = allowPrototype + ? JSON.parse(jsonString) + : JSON.parse(jsonString, prototypePollutionReviver) } catch (error) { throw new Error(`Failed to parse JSON: ${error}`) } - // Check for prototype pollution - if ( - !allowPrototype && - typeof parsed === 'object' && - parsed !== null && - !Array.isArray(parsed) - ) { - const dangerous = ['__proto__', 'constructor', 'prototype'] - for (const key of dangerous) { - if (ObjectHasOwn(parsed, key)) { - throw new Error( - 'JSON contains potentially malicious prototype pollution keys', - ) - } - } - } - // Validate against schema if provided if (schema) { const result = schema.safeParse(parsed) diff --git a/test/unit/argv-parse.test.mts b/test/unit/argv-parse.test.mts index 0e959fa6..7fab909d 100644 --- a/test/unit/argv-parse.test.mts +++ b/test/unit/argv-parse.test.mts @@ -388,9 +388,9 @@ describe('argv/parse', () => { expect(hasFlag('verbose', argv)).toBe(true) }) - it('should detect short flag', () => { + it('should not match short flags (only long flags)', () => { const argv = ['node', 'script.js', '-v'] - expect(hasFlag('verbose', argv)).toBe(true) + expect(hasFlag('verbose', argv)).toBe(false) }) it('should return false for missing flag', () => { @@ -425,8 +425,8 @@ describe('argv/parse', () => { expect(hasFlag('verbose', argv)).toBe(false) }) - it('should handle single letter flags', () => { - const argv = ['node', 'script.js', '-h'] + it('should handle single letter long flags', () => { + const argv = ['node', 'script.js', '--h'] expect(hasFlag('h', argv)).toBe(true) }) }) diff --git a/test/unit/promise-queue.test.mts b/test/unit/promise-queue.test.mts index 2ab115e1..c731c3ce 100644 --- a/test/unit/promise-queue.test.mts +++ b/test/unit/promise-queue.test.mts @@ -258,12 +258,14 @@ describe('PromiseQueue', () => { }) describe('clear', () => { - it('should clear pending tasks', async () => { + it('should clear pending tasks and reject their promises', async () => { const queue = new PromiseQueue(1) + // First task starts running (concurrency=1). queue.add(async () => await delay(100)) - queue.add(async () => await delay(10)) - queue.add(async () => await delay(10)) + // These are queued (pending). + const pending1 = queue.add(async () => await delay(10)) + const pending2 = queue.add(async () => await delay(10)) await delay(10) const beforeClear = queue.pendingCount @@ -271,6 +273,9 @@ describe('PromiseQueue', () => { queue.clear() expect(queue.pendingCount).toBe(0) expect(beforeClear).toBeGreaterThan(0) + + await expect(pending1).rejects.toThrow('Task cancelled: queue cleared') + await expect(pending2).rejects.toThrow('Task cancelled: queue cleared') }) it('should not affect running tasks', async () => { @@ -294,6 +299,7 @@ describe('PromiseQueue', () => { it('should allow new tasks after clear', async () => { const queue = new PromiseQueue(2) + // With concurrency=2, task starts immediately (not pending). queue.add(async () => await delay(50)) queue.clear()