@codefast/cli 0.3.16-canary.3 → 0.4.0-canary.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +69 -62
  2. package/dist/arrange/analyze.mjs +2 -2
  3. package/dist/arrange/command.mjs +51 -27
  4. package/dist/arrange/domain/analyze-service.mjs +2 -2
  5. package/dist/arrange/domain/ast/collectors-cn.mjs +1 -1
  6. package/dist/arrange/domain/ast/collectors-tv.mjs +2 -2
  7. package/dist/arrange/domain/ast/helpers.mjs +2 -2
  8. package/dist/arrange/domain/ast/simplify-targets.mjs +109 -0
  9. package/dist/arrange/domain/ast/targets.mjs +25 -10
  10. package/dist/arrange/domain/grouping-service.mjs +1 -1
  11. package/dist/arrange/domain/grouping.mjs +5 -5
  12. package/dist/arrange/domain/imports.mjs +50 -2
  13. package/dist/arrange/domain/token-classifier.mjs +12 -12
  14. package/dist/arrange/output.mjs +11 -3
  15. package/dist/arrange/process-file.mjs +1 -1
  16. package/dist/arrange/scan-target.mjs +10 -1
  17. package/dist/arrange/simplify-process-file.mjs +35 -0
  18. package/dist/arrange/simplify-sync.mjs +32 -0
  19. package/dist/arrange/suggest.mjs +1 -1
  20. package/dist/arrange/sync.mjs +1 -1
  21. package/dist/arrange/workspace.mjs +1 -1
  22. package/dist/cli.mjs +1 -1
  23. package/dist/core/cli/result-handle.mjs +1 -1
  24. package/dist/core/source-text-edit.mjs +1 -1
  25. package/dist/core/workspace/resolver.mjs +3 -3
  26. package/dist/mirror/cli-result.mjs +2 -1
  27. package/dist/mirror/cli-schema.mjs +2 -1
  28. package/dist/mirror/command.mjs +12 -11
  29. package/dist/mirror/domain/exports.mjs +2 -2
  30. package/dist/mirror/output.mjs +3 -0
  31. package/dist/mirror/supplement-exports.mjs +2 -2
  32. package/dist/mirror/sync-reporter.mjs +4 -1
  33. package/dist/mirror/sync-workspace-package.mjs +4 -4
  34. package/dist/mirror/sync.mjs +2 -1
  35. package/dist/mirror/write-exports.mjs +3 -2
  36. package/dist/tag/command.mjs +6 -6
  37. package/dist/tag/output.mjs +1 -1
  38. package/dist/tag/sync.mjs +1 -1
  39. package/dist/tag/target-runner.mjs +1 -1
  40. package/package.json +9 -9
package/README.md CHANGED
@@ -18,8 +18,8 @@ A small developer CLI for maintenance tasks in a TypeScript monorepo — Tailwin
18
18
  - [Global options](#global-options)
19
19
  - [Exit codes](#exit-codes)
20
20
  - [`arrange`](#arrange)
21
- - [`mirror sync`](#mirror-sync)
22
- - [`tag` / `annotate`](#tag--annotate)
21
+ - [`mirror`](#mirror)
22
+ - [`tag`](#tag)
23
23
  - [Configuration (`codefast.config.*`)](#configuration-codefastconfig)
24
24
  - [Full skeleton](#full-skeleton)
25
25
  - [`mirror` configuration](#mirror-configuration)
@@ -40,7 +40,9 @@ Three recurring maintenance chores you don't want to script by hand:
40
40
 
41
41
  - **`arrange`** — regroup Tailwind class strings inside `cn()` / `tv()` calls in render-pipeline order.
42
42
  - **`mirror`** — regenerate `package.json` `exports` fields from built `dist/` trees across a pnpm workspace.
43
- - **`tag`** (alias **`annotate`**) — add `@since <version>` JSDoc tags to exported declarations that are missing version metadata.
43
+ - **`tag`** — add `@since <version>` JSDoc tags to exported declarations that are missing version metadata.
44
+
45
+ Each top-level command **performs its action by default** (writing files). Pass `--dry-run` to preview without writing. `arrange` additionally exposes a read-only `inspect` report.
44
46
 
45
47
  ```mermaid
46
48
  flowchart LR
@@ -49,19 +51,16 @@ flowchart LR
49
51
  R --> M[mirror]
50
52
  R --> T[tag]
51
53
 
52
- A --> A0[analyze]
53
- A --> A1[preview]
54
- A --> A2[apply]
55
- A --> A3[group]
56
-
57
- M --> M0[sync]
54
+ A --> A0[inspect]
55
+ A --> A1[simplify]
56
+ A --> A2[group]
58
57
  ```
59
58
 
60
59
  ---
61
60
 
62
61
  ## Requirements
63
62
 
64
- - Node.js `>= 22.0.0`
63
+ - Node.js `>= 24.0.0`
65
64
  - pnpm (recommended — the CLI discovers workspaces via `pnpm-workspace.yaml`)
66
65
 
67
66
  ---
@@ -87,17 +86,17 @@ npx @codefast/cli --help
87
86
  ## Quick Start
88
87
 
89
88
  ```bash
90
- # Analyze Tailwind classes in the nearest package
91
- codefast arrange analyze
89
+ # Inspect Tailwind classes in the nearest package (read-only report)
90
+ codefast arrange inspect
92
91
 
93
92
  # Preview proposed rewrites — no files written
94
- codefast arrange preview packages/ui/src/components
93
+ codefast arrange --dry-run packages/ui/src/components
95
94
 
96
95
  # Apply after reviewing
97
- codefast arrange apply packages/ui/src/components
96
+ codefast arrange packages/ui/src/components
98
97
 
99
98
  # Regenerate every package's `exports` from built dist/
100
- codefast mirror sync
99
+ codefast mirror
101
100
 
102
101
  # Add @since <version> to exported APIs under ./src
103
102
  codefast tag
@@ -113,15 +112,17 @@ codefast tag
113
112
  | `-V`, `--version` | Print the CLI version and exit. |
114
113
  | `-h`, `--help` | Show contextual help for the invoked command. |
115
114
 
115
+ > **Placement.** Global flags must come **before** the command name (git-style), e.g. `codefast --no-color mirror`. A flag after the command name binds to that command.
116
+
116
117
  ---
117
118
 
118
119
  ## Exit codes
119
120
 
120
- | Code | Meaning |
121
- | ---- | -------------------------------------------------------------------------------------------------------- |
122
- | `0` | Success. |
123
- | `1` | General failure (missing paths, infrastructure errors, partial failures in `mirror sync`, failed hooks). |
124
- | `2` | Invalid invocation or input (Zod schema validation on CLI requests — `CLI_EXIT_USAGE`). |
121
+ | Code | Meaning |
122
+ | ---- | --------------------------------------------------------------------------------------------------- |
123
+ | `0` | Success. |
124
+ | `1` | General failure (missing paths, infrastructure errors, partial failures in `mirror`, failed hooks). |
125
+ | `2` | Invalid invocation or input (Zod schema validation on CLI requests — `CLI_EXIT_USAGE`). |
125
126
 
126
127
  Diagnostics go to **stderr**; primary command output goes to **stdout**. When a subcommand accepts `--json`, only the JSON object is written to stdout and all human progress is suppressed, so the stream stays pipeline-safe.
127
128
 
@@ -137,25 +138,28 @@ When `[target]` is omitted, `arrange` auto-detects the **nearest package directo
137
138
 
138
139
  ### Workflow
139
140
 
140
- | Step | Command | Effect |
141
- | ---- | ----------------------------------- | ------------------------------------- |
142
- | 1 | `codefast arrange analyze [target]` | Report only — no files changed |
143
- | 2 | `codefast arrange preview [target]` | Show exactly what `apply` would write |
144
- | 3 | `codefast arrange apply [target]` | Write the changes |
141
+ | Step | Command | Effect |
142
+ | ---- | ------------------------------------- | ---------------------------------------------- |
143
+ | 1 | `codefast arrange inspect [target]` | Report only — no files changed |
144
+ | 2 | `codefast arrange --dry-run [target]` | Show exactly what a bare `arrange` would write |
145
+ | 3 | `codefast arrange [target]` | Write the changes |
145
146
 
146
- ### Flags (preview / apply)
147
+ ### Flags (`arrange`)
147
148
 
148
149
  | Flag | Description |
149
150
  | -------------------- | --------------------------------------------------------------------------- |
151
+ | `--dry-run` | Preview the rewrite without writing files. |
150
152
  | `--with-class-name` | Append `className` as the last argument when rewriting a `cn(...)` call. |
151
153
  | `--cn-import <spec>` | Override the module specifier used when a missing `cn` import is added. |
152
154
  | `--json` | Print a single JSON object on stdout; suppresses human progress and colors. |
153
155
 
154
- `analyze` also supports `--json`.
156
+ `inspect` also supports `--json`. `simplify` accepts `--dry-run` and `--json`.
157
+
158
+ > **Test files are skipped.** Directory scans exclude `*.test.*` / `*.spec.*` files — a `cn(...)` inside an assertion is test data, not styling to reformat. Pass such a file explicitly to override.
155
159
 
156
160
  ### `arrange group` — one-shot classification
157
161
 
158
- Groups a class string without touching the filesystem. Useful for checking how classes would be grouped before running `apply`:
162
+ Groups a class string without touching the filesystem. Useful for checking how classes would be grouped before running `arrange`:
159
163
 
160
164
  ```bash
161
165
  # Quoted string
@@ -176,44 +180,47 @@ codefast arrange group --tv "flex items-center gap-2"
176
180
 
177
181
  ### `--json` payloads
178
182
 
179
- | Subcommand | Payload highlights |
180
- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
181
- | `analyze` | `schemaVersion`, `analyzeRootPath`, full `report` (same data as the human report). |
182
- | `preview` / `apply` | `schemaVersion`, `write`, `ok` (`false` if the `onAfterWrite` hook failed), full `result` (`filePaths`, `modifiedFiles`, `totalFound`, …). |
183
- | `group` | `schemaVersion`, `primaryLine`, `bucketsCommentLine`. |
183
+ | Subcommand | Payload highlights |
184
+ | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
185
+ | `arrange` | `schemaVersion`, `write`, `ok` (`false` if the `onAfterWrite` hook failed), full `result` (`filePaths`, `modifiedFiles`, `totalFound`, …). |
186
+ | `inspect` | `schemaVersion`, `analyzeRootPath`, full `report` (same data as the human report). |
187
+ | `simplify` | `schemaVersion`, `write`, `ok`, full `result`. |
188
+ | `group` | `schemaVersion`, `primaryLine`, `bucketsCommentLine`. |
184
189
 
185
190
  ---
186
191
 
187
- ## `mirror sync`
192
+ ## `mirror`
188
193
 
189
194
  Scans built `dist/` trees and regenerates the `exports` field of every workspace package. Run it from anywhere inside the monorepo — the workspace root is discovered via `pnpm-workspace.yaml`.
190
195
 
191
196
  ```bash
192
- codefast mirror sync # all workspace packages
193
- codefast mirror sync packages/ui # one package (path relative to repo root)
194
- codefast mirror sync -v # verbose diagnostics
195
- codefast mirror sync --json # JSON summary for scripts / CI
197
+ codefast mirror # all workspace packages
198
+ codefast mirror packages/ui # one package (path relative to repo root)
199
+ codefast mirror --dry-run # preview — report changes without writing
200
+ codefast mirror -v # verbose diagnostics
201
+ codefast mirror --json # JSON summary for scripts / CI
196
202
  ```
197
203
 
198
- | Flag | Description |
199
- | ----------------- | --------------------------------------------------------------------- |
200
- | `-v`, `--verbose` | Print extra diagnostics. |
201
- | `--json` | Print a single `{ schemaVersion, ok, elapsedSeconds, stats }` object. |
204
+ | Flag | Description |
205
+ | ----------------- | ---------------------------------------------------------------------------- |
206
+ | `--dry-run` | Report what would change without writing any `package.json`. |
207
+ | `-v`, `--verbose` | Print extra diagnostics. |
208
+ | `--json` | Print a single `{ schemaVersion, ok, write, elapsedSeconds, stats }` object. |
202
209
 
203
- > **Build first.** `mirror sync` reads from `dist/`. Run your build before syncing or exports will reflect stale output.
210
+ > **Build first.** `mirror` reads from `dist/`. Run your build before syncing or exports will reflect stale output.
204
211
 
205
212
  Exit code is `1` when any package fails (`stats.packagesErrored > 0`), `0` otherwise.
206
213
 
207
214
  ---
208
215
 
209
- ## `tag` / `annotate`
216
+ ## `tag`
210
217
 
211
218
  Scans `.ts` / `.tsx` sources and adds `@since <current-package-version>` to exported declarations that don't already carry one. The version is read from the nearest `package.json` walking up from the target path.
212
219
 
213
220
  ```bash
214
221
  codefast tag # auto-discover workspace packages from cwd
215
- codefast tag packages/ui/src # annotate a custom target
216
- codefast annotate --dry-run # preview only, do not write
222
+ codefast tag packages/ui/src # tag a custom target
223
+ codefast tag --dry-run # preview only, do not write
217
224
  codefast tag --json # JSON summary for scripts / CI
218
225
  ```
219
226
 
@@ -296,7 +303,7 @@ export default {
296
303
 
297
304
  #### Skipping a package
298
305
 
299
- Set a package to `false` to exclude it from `codefast mirror sync` entirely:
306
+ Set a package to `false` to exclude it from `codefast mirror` entirely:
300
307
 
301
308
  ```javascript
302
309
  mirror: {
@@ -362,7 +369,7 @@ Keeps the existing `package.json#exports` map exactly as-is and only fills in mi
362
369
 
363
370
  #### `css`
364
371
 
365
- Controls CSS file export generation. `mirror sync` scans `dist/` for `.css` files and writes wildcard or per-file export entries.
372
+ Controls CSS file export generation. `mirror` scans `dist/` for `.css` files and writes wildcard or per-file export entries.
366
373
 
367
374
  ```javascript
368
375
  // Shorthand: auto-detect all CSS files in dist/
@@ -387,7 +394,7 @@ Controls CSS file export generation. `mirror sync` scans `dist/` for `.css` file
387
394
 
388
395
  When `css` is omitted, CSS files found in `dist/` are still exported by default.
389
396
 
390
- #### What `mirror sync` writes
397
+ #### What `mirror` writes
391
398
 
392
399
  For a package with `dist/button.mjs`, `dist/button.d.ts`, and `source: true`, the generated export entry looks like:
393
400
 
@@ -430,22 +437,22 @@ tag: {
430
437
 
431
438
  ```javascript
432
439
  arrange: {
433
- // Called after files are written by `codefast arrange apply`
440
+ // Called after files are written by `codefast arrange`
434
441
  onAfterWrite: ({ files }) => {
435
442
  execSync(`oxfmt ${files.join(" ")}`, { stdio: "inherit" });
436
443
  },
437
444
  }
438
445
  ```
439
446
 
440
- | Field | Type | Description |
441
- | -------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------ |
442
- | `onAfterWrite` | `(ctx: { files: string[] }) => void \| Promise<void>` | Lifecycle hook — runs after `arrange apply` writes files. Not called by `arrange preview`. |
447
+ | Field | Type | Description |
448
+ | -------------- | ----------------------------------------------------- | -------------------------------------------------------------------------------- |
449
+ | `onAfterWrite` | `(ctx: { files: string[] }) => void \| Promise<void>` | Lifecycle hook — runs after `arrange` writes files. Not called with `--dry-run`. |
443
450
 
444
451
  ---
445
452
 
446
453
  ## Lifecycle hooks
447
454
 
448
- Both `tag` and `arrange apply` call `onAfterWrite` immediately after writing files to disk. The hook receives the list of written file paths and can run any synchronous or asynchronous work — formatters, linters, codegen, notifications.
455
+ Both `tag` and `arrange` call `onAfterWrite` immediately after writing files to disk. The hook receives the list of written file paths and can run any synchronous or asynchronous work — formatters, linters, codegen, notifications.
449
456
 
450
457
  ```javascript
451
458
  export default {
@@ -467,8 +474,8 @@ export default {
467
474
  Contract:
468
475
 
469
476
  - `tag.onAfterWrite` fires after `codefast tag` writes JSDoc annotations.
470
- - `arrange.onAfterWrite` fires after `codefast arrange apply` rewrites class strings.
471
- - Hook is **not** called on `--dry-run` or `arrange preview`.
477
+ - `arrange.onAfterWrite` fires after `codefast arrange` rewrites class strings.
478
+ - Hook is **not** called on `--dry-run`.
472
479
  - Hooks may be synchronous or `async` (`void | Promise<void>`).
473
480
  - If the hook throws or rejects, the command reports the error on stderr and exits with code `1`.
474
481
 
@@ -509,14 +516,14 @@ To change placement, extend `classifyBareUtility` in `packages/cli/src/arrange/d
509
516
  **`codefast: command not found`**
510
517
  Install globally with `pnpm add -g @codefast/cli`, or run via `pnpm dlx @codefast/cli <command>`.
511
518
 
512
- **`mirror sync` writes little or no output**
513
- Packages must be built first. Ensure `dist/` exists by running your build step, then re-run `codefast mirror sync`.
519
+ **`mirror` writes little or no output**
520
+ Packages must be built first. Ensure `dist/` exists by running your build step, then re-run `codefast mirror`.
514
521
 
515
- **Unexpected class reorder after `arrange apply`**
516
- Run `arrange preview` first and smoke-test the UI. Some components rely on cascade-sensitive ordering that `arrange` cannot detect automatically.
522
+ **Unexpected class reorder after `arrange`**
523
+ Run `arrange --dry-run` first and smoke-test the UI. Some components rely on cascade-sensitive ordering that `arrange` cannot detect automatically.
517
524
 
518
525
  **`--json` output mixed with progress lines**
519
- Some shells buffer progress writes on stderr into stdout when piping — redirect stderr explicitly: `codefast mirror sync --json 2>/dev/null | jq`.
526
+ Some shells buffer progress writes on stderr into stdout when piping — redirect stderr explicitly: `codefast mirror --json 2>/dev/null | jq`.
520
527
 
521
528
  ---
522
529
 
@@ -538,7 +545,7 @@ A few naming conventions:
538
545
 
539
546
  - **`codefast <command>`** refers to CLI commands exposed via the `bin` entry in `@codefast/cli`.
540
547
  - **Scripts in `packages/cli/package.json`** (`build`, `test`, …) are package-local dev scripts, not CLI commands.
541
- - The root `package.json` includes optional convenience wrappers (e.g. `cli:mirror-sync`, `cli:arrange-analyze`) for common dev workflows.
548
+ - The root `package.json` includes optional convenience wrappers (e.g. `cli:mirror`, `cli:arrange-inspect`) for common dev workflows.
542
549
 
543
550
  ---
544
551
 
@@ -1,8 +1,8 @@
1
- import { AppError, messageFrom } from "../core/errors.mjs";
2
- import { err, ok } from "../core/result.mjs";
3
1
  import { accumulateAnalyzeReportForSourceFile, createEmptyAnalyzeReport } from "./domain/analyze-service.mjs";
2
+ import { AppError, messageFrom } from "../core/errors.mjs";
4
3
  import { scanArrangeTargets } from "./scan-target.mjs";
5
4
  import { parseDomainSourceFile } from "./source-parse.mjs";
5
+ import { err, ok } from "../core/result.mjs";
6
6
  //#region src/arrange/analyze.ts
7
7
  /**
8
8
  * @since 0.3.16-canary.0
@@ -1,15 +1,16 @@
1
- import { parseWithSchema } from "../core/schema-parse.mjs";
2
- import "../core/exit-codes.mjs";
3
1
  import { logger } from "../core/logger.mjs";
4
- import { consumeCliAppError, runCliResultAsync } from "../core/cli/result-handle.mjs";
5
- import { nodeFilesystem } from "../core/filesystem/node.mjs";
6
- import { arrangeAnalyzeDirectoryRequestSchema, arrangeSuggestGroupsRequestSchema, arrangeSyncRunRequestSchema } from "./cli-schema.mjs";
7
- import { prepareArrangeWorkspace } from "./workspace.mjs";
8
2
  import { analyzeDirectory } from "./analyze.mjs";
9
- import { runArrangeSync } from "./sync.mjs";
3
+ import { arrangeAnalyzeDirectoryRequestSchema, arrangeSuggestGroupsRequestSchema, arrangeSyncRunRequestSchema } from "./cli-schema.mjs";
4
+ import { printAnalyzeReport, printGroupFilePreviewFromWork, printSimplifyResult, printSyncResult } from "./output.mjs";
5
+ import { runArrangeSimplify } from "./simplify-sync.mjs";
10
6
  import { suggestCnGroupsFromCli } from "./suggest.mjs";
11
- import { printAnalyzeReport, printGroupFilePreviewFromWork, printSyncResult } from "./output.mjs";
7
+ import { runArrangeSync } from "./sync.mjs";
8
+ import { prepareArrangeWorkspace } from "./workspace.mjs";
12
9
  import { readOptionalPositionalArg } from "../core/cli/positional.mjs";
10
+ import "../core/exit-codes.mjs";
11
+ import { consumeCliAppError, runCliResultAsync } from "../core/cli/result-handle.mjs";
12
+ import { nodeFilesystem } from "../core/filesystem/node.mjs";
13
+ import { parseWithSchema } from "../core/schema-parse.mjs";
13
14
  import process from "node:process";
14
15
  import { Command } from "commander";
15
16
  //#region src/arrange/command.ts
@@ -17,22 +18,8 @@ import { Command } from "commander";
17
18
  * @since 0.3.16-canary.0
18
19
  */
19
20
  function createArrangeCommand() {
20
- const cmd = new Command("arrange").description("Analyze and regroup Tailwind classes in cn() / tv() calls (Tailwind v4)");
21
- cmd.command("analyze").description("Report long strings, nested cn in tv(), and related findings").argument("[target]", "Directory or file (default: nearest package directory from cwd)").option("--json", "Print one JSON object on stdout instead of a human report", false).action(async (target, opts) => {
22
- const prelude = await prepareArrangeWorkspace(nodeFilesystem, {
23
- currentWorkingDirectory: process.cwd(),
24
- rawTarget: readOptionalPositionalArg(target)
25
- });
26
- if (!consumeCliAppError(prelude)) return;
27
- const { resolvedTarget } = prelude.value;
28
- const parsed = parseWithSchema(arrangeAnalyzeDirectoryRequestSchema, { analyzeRootPath: resolvedTarget });
29
- if (!consumeCliAppError(parsed)) return;
30
- const outcome = analyzeDirectory(nodeFilesystem, parsed.value.analyzeRootPath);
31
- if (!consumeCliAppError(outcome)) return;
32
- if (opts.json) logger.out(formatArrangeAnalyzeJsonOutput(resolvedTarget, outcome.value));
33
- else printAnalyzeReport(resolvedTarget, outcome.value);
34
- });
35
- const previewOrApply = (write) => async (target, opts) => {
21
+ const cmd = new Command("arrange").description("Regroup Tailwind classes in cn() / tv() calls in render-pipeline order").enablePositionalOptions().argument("[target]", "Directory or file (default: nearest package directory from cwd)").option("--dry-run", "Preview suggested replacements without writing files", false).option("--with-classname, --with-class-name", "Append className as final cn() argument", false).option("--cn-import <spec>", "Override module specifier when adding cn import").option("--json", "Print one JSON object on stdout (suppresses human progress)", false).action(async (target, opts) => {
22
+ const write = !opts.dryRun;
36
23
  const prelude = await prepareArrangeWorkspace(nodeFilesystem, {
37
24
  currentWorkingDirectory: process.cwd(),
38
25
  rawTarget: readOptionalPositionalArg(target)
@@ -57,9 +44,46 @@ function createArrangeCommand() {
57
44
  printSyncResult(value, write);
58
45
  return exitCodeForArrangeSyncResult(value);
59
46
  });
60
- };
61
- cmd.command("preview").description("Dry-run: print suggested replacements without writing files").argument("[target]", "Directory or file (default: nearest package directory from cwd)").option("--with-classname, --with-class-name", "Append className as final cn() argument", false).option("--cn-import <spec>", "Override module specifier when adding cn import").option("--json", "Print one JSON object on stdout (suppresses human progress)", false).action(previewOrApply(false));
62
- cmd.command("apply").description("Apply grouping and cn-in-tv unwrap edits to files").argument("[target]", "Directory or file (default: nearest package directory from cwd)").option("--with-classname, --with-class-name", "Append className as final cn() argument", false).option("--cn-import <spec>", "Override module specifier when adding cn import").option("--json", "Print one JSON object on stdout (suppresses human progress)", false).action(previewOrApply(true));
47
+ });
48
+ cmd.command("inspect").description("Report long strings, nested cn in tv(), and related findings (read-only)").argument("[target]", "Directory or file (default: nearest package directory from cwd)").option("--json", "Print one JSON object on stdout instead of a human report", false).action(async (target, opts) => {
49
+ const prelude = await prepareArrangeWorkspace(nodeFilesystem, {
50
+ currentWorkingDirectory: process.cwd(),
51
+ rawTarget: readOptionalPositionalArg(target)
52
+ });
53
+ if (!consumeCliAppError(prelude)) return;
54
+ const { resolvedTarget } = prelude.value;
55
+ const parsed = parseWithSchema(arrangeAnalyzeDirectoryRequestSchema, { analyzeRootPath: resolvedTarget });
56
+ if (!consumeCliAppError(parsed)) return;
57
+ const outcome = analyzeDirectory(nodeFilesystem, parsed.value.analyzeRootPath);
58
+ if (!consumeCliAppError(outcome)) return;
59
+ if (opts.json) logger.out(formatArrangeAnalyzeJsonOutput(resolvedTarget, outcome.value));
60
+ else printAnalyzeReport(resolvedTarget, outcome.value);
61
+ });
62
+ cmd.command("simplify").description("Flatten grouped arrays and static-only cn() calls back to plain strings in tv() slots").argument("[target]", "Directory or file (default: nearest package directory from cwd)").option("--dry-run", "Show what simplify would change without writing files", false).option("--json", "Print one JSON object on stdout (suppresses human progress)", false).action(async (target, opts) => {
63
+ const write = !opts.dryRun;
64
+ const prelude = await prepareArrangeWorkspace(nodeFilesystem, {
65
+ currentWorkingDirectory: process.cwd(),
66
+ rawTarget: readOptionalPositionalArg(target)
67
+ });
68
+ if (!consumeCliAppError(prelude)) return;
69
+ const { resolvedTarget } = prelude.value;
70
+ await runCliResultAsync(runArrangeSimplify(nodeFilesystem, {
71
+ targetPath: resolvedTarget,
72
+ write
73
+ }), (value) => {
74
+ if (opts.json) {
75
+ logger.out(JSON.stringify({
76
+ schemaVersion: 1,
77
+ ok: true,
78
+ write,
79
+ result: value
80
+ }));
81
+ return 0;
82
+ }
83
+ printSimplifyResult(value, write);
84
+ return 0;
85
+ });
86
+ });
63
87
  cmd.command("group").description("Try grouping on a pasted class string (stdout: cn(...) or tv array with --tv)").argument("<tokens...>", "Class tokens (quote a single string if it contains spaces)").option("--tv", "Emit tv()-style array instead of cn() call", false).option("--with-classname, --with-class-name", "Append className as final cn() argument", false).option("--json", "Print one JSON object on stdout instead of plain lines", false).action(async (classTokenSeries, opts) => {
64
88
  const parsed = parseWithSchema(arrangeSuggestGroupsRequestSchema, {
65
89
  inlineClasses: classTokenSeries.join(" ").trim(),
@@ -1,9 +1,9 @@
1
- import "./constants.mjs";
2
- import { tokenizeClassString } from "./tailwind-token.mjs";
3
1
  import { forEachDomainChild, isDomainCallExpression, isDomainJsxAttribute, isDomainObjectLiteralExpression } from "./ast/ast-node.mjs";
2
+ import "./constants.mjs";
4
3
  import { forEachStringLiteralInClassExpression } from "./ast/collectors-cn.mjs";
5
4
  import { jsxClassNameStaticLiteral } from "./ast/collectors-jsx.mjs";
6
5
  import { buildKnownCnTvBindings, isCnOrTvIdentifier, lineOf } from "./ast/helpers.mjs";
6
+ import { tokenizeClassString } from "./tailwind-token.mjs";
7
7
  import { collectCnCallsInsideTv, traverseTvObject } from "./ast/collectors-tv.mjs";
8
8
  //#region src/arrange/domain/analyze-service.ts
9
9
  /**
@@ -1,5 +1,5 @@
1
- import "../constants.mjs";
2
1
  import { isDomainArrayLiteralExpression, isDomainTailwindClassLiteral } from "./ast-node.mjs";
2
+ import "../constants.mjs";
3
3
  //#region src/arrange/domain/ast/collectors-cn.ts
4
4
  /**
5
5
  * @since 0.3.16-canary.0
@@ -1,8 +1,8 @@
1
- import "../constants.mjs";
2
- import { tokenizeClassString } from "../tailwind-token.mjs";
3
1
  import { forEachDomainChild, isDomainArrayLiteralExpression, isDomainCallExpression, isDomainObjectLiteralExpression, isDomainPropertyAssignment, isDomainSpreadElement, isDomainTailwindClassLiteral } from "./ast-node.mjs";
2
+ import "../constants.mjs";
4
3
  import { CN_APPLY_LITERAL_WALK_OPTS, collectUnconditionalTailwindLiteralsFromCnArguments, forEachStringLiteralInClassExpression, isUnsafeLiteralForCnStyleApplySplit } from "./collectors-cn.mjs";
5
4
  import { buildKnownCnTvBindings, isCnOrTvIdentifier, propertyAssignmentNameText } from "./helpers.mjs";
5
+ import { tokenizeClassString } from "../tailwind-token.mjs";
6
6
  //#region src/arrange/domain/ast/collectors-tv.ts
7
7
  /**
8
8
  * @since 0.3.16-canary.0
@@ -1,5 +1,5 @@
1
- import { EMPTY_CN_TV_BINDINGS } from "../constants.mjs";
2
1
  import { isDomainIdentifier, isDomainImportDeclaration, isDomainNamedImports, isDomainNamespaceImport, isDomainPropertyAccessExpression, isDomainStringLiteral, lineOfSourcePosition } from "./ast-node.mjs";
2
+ import { EMPTY_CN_TV_BINDINGS } from "../constants.mjs";
3
3
  import { applyEditsDescending, indentOfLineContaining } from "../../../core/source-text-edit.mjs";
4
4
  //#region src/arrange/domain/ast/helpers.ts
5
5
  /**
@@ -21,7 +21,7 @@ const KNOWN_CN_TV_MODULES = new Set([
21
21
  function moduleLooksLikeCnTvReexport(moduleSpecifier) {
22
22
  const norm = moduleSpecifier.replace(/\\/g, "/");
23
23
  if (/(?:^|[./])utils(?:\/|$)/.test(norm)) return true;
24
- if (/\/utils\//.test(norm) || /\/utils$/.test(norm)) return true;
24
+ if (/\/utils\//.test(norm) || norm.endsWith("/utils")) return true;
25
25
  if (/(?:^|\/)cn\.tsx?$/.test(norm)) return true;
26
26
  return false;
27
27
  }
@@ -0,0 +1,109 @@
1
+ import { forEachDomainChild, isDomainArrayLiteralExpression, isDomainCallExpression, isDomainJsxAttribute, isDomainJsxExpression, isDomainObjectLiteralExpression, isDomainPropertyAssignment, isDomainSpreadElement, isDomainTailwindClassLiteral } from "./ast-node.mjs";
2
+ import "../constants.mjs";
3
+ import { indentOfLineContaining } from "../../../core/source-text-edit.mjs";
4
+ import { buildKnownCnTvBindings, isCnOrTvIdentifier } from "./helpers.mjs";
5
+ import { escapeTsStringLiteralContent } from "../source-text-formatters.mjs";
6
+ //#region src/arrange/domain/ast/simplify-targets.ts
7
+ function toFlatString(text) {
8
+ return `"${escapeTsStringLiteralContent(text.trim())}"`;
9
+ }
10
+ function joinLiterals(args) {
11
+ return args.filter(isDomainTailwindClassLiteral).map((lit) => lit.text).join(" ");
12
+ }
13
+ function isAllStaticLiterals(args) {
14
+ return args.length > 0 && args.every(isDomainTailwindClassLiteral);
15
+ }
16
+ /**
17
+ * For a cn() call that has both static and dynamic args:
18
+ * - Merge ALL static string literals (regardless of position) into one string.
19
+ * - Place the merged string first.
20
+ * - Append all dynamic args in their original relative order.
21
+ *
22
+ * Returns `null` when nothing changes (0 statics, or already 1 static at arg[0]).
23
+ */
24
+ function buildMixedCnReplacement(call, sourceText) {
25
+ const args = [...call.arguments];
26
+ if (args.length === 0) return null;
27
+ const staticTexts = [];
28
+ const dynamicSrcs = [];
29
+ for (const arg of args) if (isDomainTailwindClassLiteral(arg)) staticTexts.push(arg.text);
30
+ else dynamicSrcs.push(sourceText.slice(arg.pos, arg.end));
31
+ if (staticTexts.length === 0) return null;
32
+ const firstArg = args[0];
33
+ if (staticTexts.length === 1 && firstArg !== void 0 && isDomainTailwindClassLiteral(firstArg)) return null;
34
+ const flatStatic = staticTexts.join(" ").trim();
35
+ const baseIndent = indentOfLineContaining(sourceText, call.pos);
36
+ const argIndent = `${baseIndent} `;
37
+ return `cn(\n${[`${argIndent}"${escapeTsStringLiteralContent(flatStatic)}",`, ...dynamicSrcs.map((src) => `${argIndent}${src},`)].join("\n")}\n${baseIndent})`;
38
+ }
39
+ function collectTvArrayEdits(obj, results, depth) {
40
+ if (depth > 12) return;
41
+ for (const prop of obj.properties) {
42
+ if (!isDomainPropertyAssignment(prop)) continue;
43
+ const init = prop.initializer;
44
+ if (isDomainArrayLiteralExpression(init)) {
45
+ const elements = [...init.elements];
46
+ if (!elements.some(isDomainSpreadElement) && elements.length > 0 && isAllStaticLiterals(elements)) results.push({
47
+ start: init.pos,
48
+ end: init.end,
49
+ replacement: toFlatString(joinLiterals(elements)),
50
+ label: "tv-array"
51
+ });
52
+ } else if (isDomainObjectLiteralExpression(init)) collectTvArrayEdits(init, results, depth + 1);
53
+ }
54
+ }
55
+ /**
56
+ * Collect all simplify edits for a source file:
57
+ * - Arrays of pure string literals inside tv() slots → flat string
58
+ * - cn() calls whose every argument is a static string literal → flat string (unwrap cn)
59
+ * - cn() calls with mixed static + dynamic args → merge adjacent statics into one string
60
+ * - JSX className={cn(...all-static...)} → className="flat string"
61
+ *
62
+ * @since 0.3.16-canary.0
63
+ */
64
+ function collectSimplifyTargets(sourceFile) {
65
+ const sourceText = sourceFile.text;
66
+ const results = [];
67
+ const knownBindings = buildKnownCnTvBindings(sourceFile);
68
+ const seenCnPos = /* @__PURE__ */ new Set();
69
+ const visitNode = (node) => {
70
+ if (isDomainCallExpression(node)) {
71
+ if (isCnOrTvIdentifier(node.expression, "tv", knownBindings)) {
72
+ const arg0 = node.arguments[0];
73
+ if (arg0 && isDomainObjectLiteralExpression(arg0)) collectTvArrayEdits(arg0, results, 0);
74
+ } else if (isCnOrTvIdentifier(node.expression, "cn", knownBindings) && !seenCnPos.has(node.pos)) {
75
+ seenCnPos.add(node.pos);
76
+ const args = [...node.arguments];
77
+ if (isAllStaticLiterals(args)) {
78
+ const flat = joinLiterals(args);
79
+ const parent = node.parent;
80
+ if (parent && isDomainJsxExpression(parent) && parent.parent && isDomainJsxAttribute(parent.parent)) results.push({
81
+ start: parent.pos,
82
+ end: parent.end,
83
+ replacement: toFlatString(flat),
84
+ label: "jsx-cn"
85
+ });
86
+ else results.push({
87
+ start: node.pos,
88
+ end: node.end,
89
+ replacement: toFlatString(flat),
90
+ label: "cn-static"
91
+ });
92
+ } else {
93
+ const replacement = buildMixedCnReplacement(node, sourceText);
94
+ if (replacement !== null) results.push({
95
+ start: node.pos,
96
+ end: node.end,
97
+ replacement,
98
+ label: "cn-merge"
99
+ });
100
+ }
101
+ }
102
+ }
103
+ forEachDomainChild(node, visitNode);
104
+ };
105
+ for (const stmt of sourceFile.statements) visitNode(stmt);
106
+ return results;
107
+ }
108
+ //#endregion
109
+ export { collectSimplifyTargets };
@@ -1,12 +1,12 @@
1
+ import { forEachDomainChild, isDomainArrayLiteralExpression, isDomainJsxAttribute, isDomainPropertyAssignment, isDomainTailwindClassLiteral } from "./ast-node.mjs";
1
2
  import "../constants.mjs";
2
- import { tokenizeClassString } from "../tailwind-token.mjs";
3
- import { forEachDomainChild, isDomainArrayLiteralExpression, isDomainJsxAttribute, isDomainTailwindClassLiteral } from "./ast-node.mjs";
4
3
  import { isUnsafeLiteralForCnStyleApplySplit } from "./collectors-cn.mjs";
5
4
  import { jsxClassNameStaticLiteral } from "./collectors-jsx.mjs";
6
5
  import { endAfterOptionalCommaFollowingInSource, indentOfLineContaining, textPrefixFromLineStartToPosition } from "../../../core/source-text-edit.mjs";
6
+ import { tokenizeClassString } from "../tailwind-token.mjs";
7
7
  import { collectGroupableStringNodes, slotClassString } from "./collectors-tv.mjs";
8
- import { areCnTailwindPartitionsEquivalent, suggestCnGroups, summarizeGroupBucketLabels } from "../grouping.mjs";
9
8
  import { escapeTsStringLiteralContent, formatArray, formatArrayElementsAsSiblingLines, formatJsxCnAttributeValue } from "../source-text-formatters.mjs";
9
+ import { areCnTailwindPartitionsEquivalent, suggestCnGroups, summarizeGroupBucketLabels } from "../grouping.mjs";
10
10
  //#region src/arrange/domain/ast/targets.ts
11
11
  /**
12
12
  * @since 0.3.16-canary.0
@@ -62,7 +62,7 @@ function formatCnCallReplacement(stringNode, sourceText, withClassName) {
62
62
  for (const dynamicArgumentSource of dynamicArgTexts) allArgs.push(`${argIndent}${dynamicArgumentSource}`);
63
63
  if (withClassName) allArgs.push(`${argIndent}className`);
64
64
  const commaAfterLastArg = allArgs.length > 1;
65
- return `cn(\n${allArgs.map((argLine, lineIndex) => lineIndex < allArgs.length - 1 || commaAfterLastArg ? `${argLine},` : `${argLine}`).join("\n")}\n${baseIndent})`;
65
+ return `cn(\n${allArgs.map((argLine, lineIndex) => lineIndex < allArgs.length - 1 || commaAfterLastArg ? `${argLine},` : argLine).join("\n")}\n${baseIndent})`;
66
66
  }
67
67
  /**
68
68
  * @since 0.3.16-canary.0
@@ -88,14 +88,29 @@ function planGroupEditForTarget(target, textAfterUnwrap, withClassName) {
88
88
  if (areCnTailwindPartitionsEquivalent(target.item.nodes.map((classLiteral) => classLiteral.text), groups)) return;
89
89
  if (!target.item.cnCall) {
90
90
  const anchorClassLiteral = target.item.primaryClassLiteral;
91
- const parentArray = target.item.nodes.length > 1 && anchorClassLiteral.parent !== null && isDomainArrayLiteralExpression(anchorClassLiteral.parent) ? anchorClassLiteral.parent : null;
92
- const start = parentArray ? parentArray.pos : anchorClassLiteral.pos;
93
- const end = parentArray ? parentArray.end : endAfterOptionalCommaFollowingInSource(textAfterUnwrap, anchorClassLiteral.end);
94
- const baseIndent = indentOfLineContaining(textAfterUnwrap, start);
91
+ const parentNode = anchorClassLiteral.parent;
92
+ const parentArray = target.item.nodes.length > 1 && parentNode !== null && isDomainArrayLiteralExpression(parentNode) ? parentNode : null;
93
+ const bareStringProperty = !parentArray && parentNode !== null && isDomainPropertyAssignment(parentNode);
94
+ if (parentArray || bareStringProperty) {
95
+ const start = parentArray ? parentArray.pos : anchorClassLiteral.pos;
96
+ const end = parentArray ? parentArray.end : anchorClassLiteral.end;
97
+ const baseIndent = indentOfLineContaining(textAfterUnwrap, start);
98
+ return {
99
+ start,
100
+ end,
101
+ replacement: formatArray(groups).split("\n").map((line, lineIndex) => lineIndex === 0 ? line : `${baseIndent}${line}`).join("\n"),
102
+ bucketSummary: summarizeGroupBucketLabels(groups),
103
+ jsxCn: false,
104
+ lineSf: target.item.sf,
105
+ reportNode: anchorClassLiteral,
106
+ label: target.item.isTvContext ? "tv" : "cn"
107
+ };
108
+ }
109
+ const start = anchorClassLiteral.pos;
95
110
  return {
96
111
  start,
97
- end,
98
- replacement: parentArray ? formatArray(groups).split("\n").map((line, lineIndex) => lineIndex === 0 ? line : `${baseIndent}${line}`).join("\n") : formatArrayElementsAsSiblingLines(groups, textPrefixFromLineStartToPosition(textAfterUnwrap, start)),
112
+ end: endAfterOptionalCommaFollowingInSource(textAfterUnwrap, anchorClassLiteral.end),
113
+ replacement: formatArrayElementsAsSiblingLines(groups, textPrefixFromLineStartToPosition(textAfterUnwrap, start)),
99
114
  bucketSummary: summarizeGroupBucketLabels(groups),
100
115
  jsxCn: false,
101
116
  lineSf: target.item.sf,
@@ -42,7 +42,7 @@ function tryBuildGroupFileWorkPlan(input) {
42
42
  if (domainSfGrouped.text !== unwrap.textAfterUnwrap) throw new Error("Domain invariant: domainSfGrouped.text must match unwrap phase textAfterUnwrap");
43
43
  const groupTargets = collectGroupTargets(domainSfGrouped, filePath);
44
44
  if (unwrap.cnInTvCalls.length === 0 && groupTargets.length === 0) return null;
45
- const sortedTargets = [...groupTargets].sort((leftTarget, rightTarget) => targetReplaceStart(rightTarget) - targetReplaceStart(leftTarget));
45
+ const sortedTargets = [...groupTargets].toSorted((leftTarget, rightTarget) => targetReplaceStart(rightTarget) - targetReplaceStart(leftTarget));
46
46
  const plannedGroupEdits = [];
47
47
  for (const groupTarget of sortedTargets) {
48
48
  const plan = planGroupEditForTarget(groupTarget, unwrap.textAfterUnwrap, withClassName);