From 650150c1c4cebd1159113674a55b60c91fae1354 Mon Sep 17 00:00:00 2001 From: Jens Oliver Meiert Date: Wed, 12 Aug 2026 11:42:43 +0200 Subject: [PATCH 1/2] feat: enhance implicit target handling for CLI Change behavior to analyze the current directory when no file is specified. Add confirmation prompt for `--fix` without an explicit target to prevent unintended rewrites. Update documentation, tests, and dependencies to support changes. (This commit message was AI-generated.) Signed-off-by: Jens Oliver Meiert --- CHANGELOG.md | 7 +++++++ README.md | 4 ++-- bin/css-dedup.js | 34 ++++++++++++++++++++++++++++++- package-lock.json | 48 +++++++++++++++++++++++++------------------- package.json | 8 ++++---- src/cli/options.js | 11 ++++++---- test/cli.test.js | 50 ++++++++++++++++++++++++++++++++++++++++++---- 7 files changed, 126 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 387b8b3..7578d58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to CSS Dedup are documented in this file, which is (mostly) The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.12.0] - 2026-08-12 + +### Changed + +* Switched to analyzing the current directory when no file is given, instead of printing help and exiting “1” +* Changed to ask for confirmation before `--fix` rewrites a working directory nobody named; an explicit target (`css-dedup --fix .`) runs unprompted + ## [1.11.0] - 2026-08-05 ### Changed diff --git a/README.md b/README.md index 38d06e3..adfbd8c 100644 --- a/README.md +++ b/README.md @@ -65,10 +65,10 @@ The two aren’t always aligned, though: Folding a declaration into a shared sel ### CLI ```shell -npx css-dedup [options] +npx css-dedup [options] [file…] ``` -Pass one or more files—each is analyzed (and, with `--fix`, rewritten) independently. A directory is searched recursively for .css files (skipping node_modules and dotfolders); the result is unrolled into that same per-file list, so mixing files and directories works, too. Pass `-` instead of a file to read CSS from STDIN (can’t be combined with other file arguments); in `--fix` mode this prints the consolidated CSS to STDOUT, rather than writing a file, so it composes in a pipeline (status/summary output moves to STDERR in that case, keeping STDOUT pure CSS). +Pass one or more files—each is analyzed (and, with `--fix`, rewritten) independently. Without a file, CSS Dedup analyzes the current directory. A directory is searched recursively for .css files (skipping node_modules and dotfolders); the result is unrolled into that same per-file list, so mixing files and directories works, too. Pass `-` instead of a file to read CSS from STDIN (can’t be combined with other file arguments); in `--fix` mode this prints the consolidated CSS to STDOUT, rather than writing a file, so it composes in a pipeline (status/summary output moves to STDERR in that case, keeping STDOUT pure CSS). The input is CSS. A preprocessor source named as an argument (.scss, .sass, .less, .styl) is skipped. Run CSS Dedup on the compiled style sheet instead—duplication in a preprocessor source is often deliberate (one mixin used in ten places), it only becomes real duplication after compilation, and the byte figures the report is built around describe what actually ships. The reason for skipping rather than trying: Constructs like `@include`, `@extend`, `#{…}`, and `@if` decide what a rule finally contains, which is exactly what the merge-safety checks would need to see to know whether moving a declaration across rules is safe. diff --git a/bin/css-dedup.js b/bin/css-dedup.js index 5be18c9..1a05f63 100644 --- a/bin/css-dedup.js +++ b/bin/css-dedup.js @@ -1,5 +1,6 @@ #!/usr/bin/env node +import { createInterface } from 'node:readline'; import { styleText } from 'node:util'; import { computeFilePass, describePassError } from '../src/cli/file-pass.js'; import { plural, sumBy } from '../src/cli/format.js'; @@ -22,6 +23,35 @@ function showHelp(text, code) { process.exit(code); } +// `--fix` rewrites in place, which is the user’s call to make—but a run that +// names no target picks the whole working directory, and that is easy to hit +// from the wrong folder. Only the implicit tree is confirmed; an explicit +// `css-dedup --fix .` says the same thing out loud and passes straight through. +async function confirmImplicitFix() { + const cwd = process.cwd(); + + // Nothing can answer, and defaulting either way would be a guess: proceeding + // rewrites a tree nobody named, aborting turns a scripted run into a silent + // no-op. Naming the path in the script settles it. + if (!process.stdin.isTTY) { + fail(styleText('red', `Refusing \`--fix\` without a target—it would rewrite every \`.css\` file under ${cwd}. Name the path (\`css-dedup --fix .\`) to confirm.`)); + } + + process.stderr.write( + `${styleText('yellow', `\`--fix\` rewrites files in place—without a target, that is every \`.css\` file under ${cwd}. If you want to compare results and be able to revert, do this under version control.`)}\n` + + 'Do you want to continue? [y/N] ' + ); + + const rl = createInterface({ input: process.stdin }); + const answer = await new Promise(resolve => { + rl.once('line', line => resolve(line.trim().toLowerCase())); + rl.once('close', () => resolve('')); + }); + rl.close(); + + if (answer !== 'y') fail('Consolidation aborted.', 0); +} + // One target’s pass on this thread, in the shape a worker sends back async function runFilePass(css, options, { fix, quiet, isStdin, label }) { try { @@ -79,10 +109,12 @@ async function resolveFiles(positionals, ignorePathPatterns) { } async function main() { - const { values, positionals } = parseCliArgs(process.argv.slice(2), { fail, showHelp }); + const { values, positionals, implicitTarget } = parseCliArgs(process.argv.slice(2), { fail, showHelp }); const config = await loadConfig(values.config); const { options, ignorePathPatterns, exitZero, flags } = buildRunSettings(values, config); + if (flags.fix && implicitTarget) await confirmImplicitFix(); + const files = await resolveFiles(positionals, ignorePathPatterns); const multi = files.length > 1; const prefetched = await prefetchContents(files); diff --git a/package-lock.json b/package-lock.json index d80454a..0ffa3fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,23 +1,23 @@ { "name": "css-dedup", - "version": "1.11.0", + "version": "1.12.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "css-dedup", - "version": "1.11.0", + "version": "1.12.0", "license": "MIT", "dependencies": { - "postcss": "^8.5.25" + "postcss": "^8.5.26" }, "bin": { "css-dedup": "bin/css-dedup.js" }, "devDependencies": { "@eslint/js": "^10.0.1", - "eslint": "^10.8.0", - "globals": "^17.9.0", + "eslint": "^10.8.1", + "globals": "^17.10.0", "typescript": "^7.0.2" }, "engines": { @@ -612,9 +612,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", - "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { @@ -678,10 +678,14 @@ } }, "node_modules/eslint": { - "version": "10.8.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.0.tgz", - "integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==", + "version": "10.8.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.1.tgz", + "integrity": "sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ==", "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", @@ -914,10 +918,11 @@ } }, "node_modules/globals": { - "version": "17.9.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.9.0.tgz", - "integrity": "sha512-m/MvAW61QVU5VDNF1Vj8axt016h8w7L5TU1e9zlab7XIttAT2YAlCwl75K1fOqvMM9apmD7lbCIRhpfkhmxhCg==", + "version": "17.10.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.10.0.tgz", + "integrity": "sha512-V0kztuWST2k8A/VbxAY8+L+7+Rgo3fyA24IHRLrZp7HOzJjV0gHSaZUjK9lpP/IrBSNite2tZ1prhRkinRu1CA==", "dev": true, + "license": "MIT", "engines": { "node": ">=18" }, @@ -1060,9 +1065,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "funding": [ { "type": "github", @@ -1161,9 +1166,9 @@ "license": "ISC" }, "node_modules/postcss": { - "version": "8.5.25", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", - "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "funding": [ { "type": "opencollective", @@ -1178,8 +1183,9 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { - "nanoid": "^3.3.16", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, diff --git a/package.json b/package.json index c03d577..018fc2a 100644 --- a/package.json +++ b/package.json @@ -4,13 +4,13 @@ "css-dedup": "bin/css-dedup.js" }, "dependencies": { - "postcss": "^8.5.25" + "postcss": "^8.5.26" }, "description": "CSS declaration deduplicator for maintainability and performance optimization", "devDependencies": { "@eslint/js": "^10.0.1", - "eslint": "^10.8.0", - "globals": "^17.9.0", + "eslint": "^10.8.1", + "globals": "^17.10.0", "typescript": "^7.0.2" }, "engines": { @@ -60,5 +60,5 @@ }, "type": "module", "types": "src/index.d.ts", - "version": "1.11.0" + "version": "1.12.0" } diff --git a/src/cli/options.js b/src/cli/options.js index 9f4d6ee..3df1bfa 100644 --- a/src/cli/options.js +++ b/src/cli/options.js @@ -22,12 +22,12 @@ const OPTIONS_CONFIG = { help: { type: 'boolean', short: 'h', default: false }, }; -const HELP = `Usage: css-dedup [options] +const HELP = `Usage: css-dedup [options] [file…] Find (and optionally consolidate) duplicate CSS declarations. Arguments: - file One or more CSS files or directories to analyze (directories are searched recursively for .css files, skipping node_modules and dotfolders); pass \`-\` to read from STDIN instead. Preprocessor sources (.scss, .sass, .less, .styl) are skipped—run CSS Dedup on the compiled style sheet. + file One or more CSS files or directories to analyze, defaulting to the current directory (directories are searched recursively for .css files, skipping node_modules and dotfolders); pass \`-\` to read from STDIN instead. Preprocessor sources (.scss, .sass, .less, .styl) are skipped—run CSS Dedup on the compiled style sheet. Options: -f, --fix Consolidate declarations that are safe to merge automatically, rewriting each file in place (or printing to STDOUT for \`-\`) @@ -78,7 +78,7 @@ export function parseCliArgs(argv, { fail, showHelp }) { const { values, positionals } = parseArgs({ args: argv, options: OPTIONS_CONFIG, allowPositionals: true, strict: true }); - if (values.help || !positionals.length) showHelp(HELP, values.help ? 0 : 1); + if (values.help) showHelp(HELP, 0); if (positionals.includes('-') && positionals.length > 1) { fail('Cannot combine STDIN (`-`) with other file arguments.'); } @@ -87,7 +87,10 @@ export function parseCliArgs(argv, { fail, showHelp }) { if (values[key] && !values.fix) fail(`\`${flag}\` only applies together with \`--fix\` (${reason})`); } - return { values, positionals }; + // A run without a target reads the working directory + const implicitTarget = !positionals.length; + + return { values, positionals: implicitTarget ? ['.'] : positionals, implicitTarget }; } // Marks a failure as the user’s to fix (a bad pattern, an unloadable config) diff --git a/test/cli.test.js b/test/cli.test.js index f39c51c..ad94565 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -14,10 +14,52 @@ describe('CLI', () => { assert.strictEqual(status, 0); }); - test('Shows help and exits non-zero when no file is given', () => { - const { stdout, status } = run([]); - assert.ok(stdout.includes('Usage:')); - assert.strictEqual(status, 1); + test('Analyzes the working directory when no file is given', () => { + const dirTemp = makeTempDir('temp_implicit_target'); + fs.writeFileSync(path.join(dirTemp, 'implicit.css'), '.a { color: red; }\n.b { color: red; }\n'); + + try { + // Exit 1 is the finding, not a failure—`stderr` stays empty + const { stdout, stderr, status } = run([], { cwd: dirTemp }); + assert.ok(stdout.includes('implicit.css')); + assert.ok(stdout.includes('duplicate')); + assert.strictEqual(stderr, ''); + assert.strictEqual(status, 1); + } finally { + fs.rmSync(dirTemp, { recursive: true, force: true }); + } + }); + + test('Refuses `--fix` without a target when nothing can answer the prompt', () => { + const dirTemp = makeTempDir('temp_implicit_fix'); + const file = path.join(dirTemp, 'implicit.css'); + const css = '.a { color: red; }\n.b { color: red; }\n'; + fs.writeFileSync(file, css); + + try { + const { stderr, status } = run(['--fix'], { cwd: dirTemp }); + assert.ok(stderr.includes('Refusing `--fix` without a target')); + assert.strictEqual(status, 1); + assert.strictEqual(fs.readFileSync(file, 'utf8'), css, 'File should be untouched'); + } finally { + fs.rmSync(dirTemp, { recursive: true, force: true }); + } + }); + + test('Runs `--fix` without a prompt when the target is named explicitly', () => { + const dirTemp = makeTempDir('temp_explicit_fix'); + const file = path.join(dirTemp, 'explicit.css'); + fs.writeFileSync(file, '.a { color: red; }\n.b { color: red; }\n'); + + try { + const { stderr, status } = run(['--fix', '.'], { cwd: dirTemp }); + assert.ok(!stderr.includes('Refusing')); + assert.ok(!stderr.includes('Do you want to continue?')); + assert.strictEqual(status, 0); + assert.match(fs.readFileSync(file, 'utf8'), RE_MERGED_AB); + } finally { + fs.rmSync(dirTemp, { recursive: true, force: true }); + } }); test('Excludes a selector via `-i` (short for `--ignore-selector`)', () => { From d5f2d416fc99c7ea17be7e622dca6b04b378fb84 Mon Sep 17 00:00:00 2001 From: Jens Oliver Meiert Date: Wed, 12 Aug 2026 14:23:58 +0200 Subject: [PATCH 2/2] feat: add `--fix` prompt tests for CLI Introduce tests to validate the confirmation prompt behavior for `--fix` when no explicit target is provided. Added utility `runTty` to simulate TTY inputs during test runs and ensure comprehensive behavior coverage. (This commit message was AI-generated.) Signed-off-by: Jens Oliver Meiert --- test/cli.test.js | 37 ++++++++++++++++++++++++++++++++++++- test/helpers.js | 14 ++++++++++++-- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/test/cli.test.js b/test/cli.test.js index ad94565..e4165f4 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -5,7 +5,7 @@ import path from 'node:path'; import { availableParallelism } from 'node:os'; import { poolSize, shouldParallelize } from '../src/cli/pool.js'; import { dedup } from '../src/index.js'; -import { BEST_CELL, RE_MERGED_AB, RE_MERGED_AC, RE_PAYOFF_FIX, RE_SYNTAX_ERROR, RE_SYNTAX_ERROR_UNCLOSED, RE_WITHHELD_ONE, cssGrowing, cssGrowingAggressive, cssShrinkingAggressive, dirTest, findingsRow, fixturesDir, makeTempDir, run, runColor } from './helpers.js'; +import { BEST_CELL, RE_MERGED_AB, RE_MERGED_AC, RE_PAYOFF_FIX, RE_SYNTAX_ERROR, RE_SYNTAX_ERROR_UNCLOSED, RE_WITHHELD_ONE, cssGrowing, cssGrowingAggressive, cssShrinkingAggressive, dirTest, findingsRow, fixturesDir, makeTempDir, run, runColor, runTty } from './helpers.js'; describe('CLI', () => { test('Shows help with `--help`', () => { @@ -62,6 +62,41 @@ describe('CLI', () => { } }); + test('Runs `--fix` without a target once the prompt is answered', () => { + const dirTemp = makeTempDir('temp_prompt_accept'); + const file = path.join(dirTemp, 'prompted.css'); + fs.writeFileSync(file, '.a { color: red; }\n.b { color: red; }\n'); + + try { + const { stderr, status } = runTty(['--fix'], 'y\n', { cwd: dirTemp }); + assert.ok(stderr.includes('Do you want to continue?')); + assert.strictEqual(status, 0); + assert.match(fs.readFileSync(file, 'utf8'), RE_MERGED_AB); + } finally { + fs.rmSync(dirTemp, { recursive: true, force: true }); + } + }); + + // Anything that isn’t `y` leaves the tree alone, including the empty answer + // an EOF resolves to—the same outcome by design, so both are asserted here + for (const [label, input] of [['a declined prompt', 'n\n'], ['EOF', '']]) { + test(`Aborts \`--fix\` without a target on ${label}`, () => { + const dirTemp = makeTempDir('temp_prompt_decline'); + const file = path.join(dirTemp, 'prompted.css'); + const css = '.a { color: red; }\n.b { color: red; }\n'; + fs.writeFileSync(file, css); + + try { + const { stderr, status } = runTty(['--fix'], input, { cwd: dirTemp }); + assert.ok(stderr.includes('Consolidation aborted.')); + assert.strictEqual(status, 0, 'Declining is not a failure'); + assert.strictEqual(fs.readFileSync(file, 'utf8'), css, 'File should be untouched'); + } finally { + fs.rmSync(dirTemp, { recursive: true, force: true }); + } + }); + } + test('Excludes a selector via `-i` (short for `--ignore-selector`)', () => { const dirTemp = makeTempDir('temp_ignore_selector'); const file = path.join(dirTemp, 'legacy.css'); diff --git a/test/helpers.js b/test/helpers.js index 89af6f9..9ef5974 100644 --- a/test/helpers.js +++ b/test/helpers.js @@ -63,8 +63,8 @@ export const RE_SYNTAX_ERROR_UNCLOSED = /Unclosed block/; // leaves `stdout`/`stderr` null and reports the cause on `result.error`— // defaulted to empty strings here so a caller doesn’t get a confusing throw // from string handling instead of the actual failure. -function spawnCli(args, { env, ...spawnOptions } = {}) { - const result = spawnSync('node', [scriptPath, ...args], { +function spawnCli(args, { env, nodeArgs = [], ...spawnOptions } = {}) { + const result = spawnSync('node', [...nodeArgs, scriptPath, ...args], { encoding: 'utf-8', timeout: 30_000, ...(env ? { env } : {}), @@ -88,6 +88,16 @@ export function run(args, spawnOptions = {}) { }; } +// `--fix` only prompts when STDIN is a TTY, which `spawnSync` never provides. +// The preload flips that one flag, leaving STDIN the ordinary pipe `input` +// writes the answer into—so the prompt is exercised without the CLI needing a +// test-only escape hatch of its own. Pass `''` for `input` to answer with EOF. +const PRELOAD_TTY = 'data:text/javascript,process.stdin.isTTY = true;'; + +export function runTty(args, input, spawnOptions = {}) { + return run(args, { ...spawnOptions, input, nodeArgs: ['--import', PRELOAD_TTY] }); +} + // `run()` strips color codes—`node:util`’s `styleText` skips them itself // once STDOUT isn’t a TTY, which `spawnSync` never gives it—so highlighting // tests force color on and read the raw (unstripped) output instead