@codefast/cli 0.3.13 → 0.3.14-canary.1
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.
- package/README.md +202 -96
- package/dist/lib/arrange/domain/ast/ast-helpers.helper.mjs +3 -1
- package/dist/lib/arrange/domain/constants.domain.mjs +21 -7
- package/dist/lib/arrange/domain/grouping.domain.mjs +15 -5
- package/dist/lib/arrange/domain/tokenizer.util.mjs +15 -5
- package/dist/lib/arrange/infra/domain-source-parser.adapter.mjs +3 -1
- package/dist/lib/arrange/infra/ensure-cn-import.adapter.mjs +3 -1
- package/dist/lib/infra/config-reporter.adapter.mjs +3 -1
- package/dist/lib/mirror/infra/update-pkg.adapter.mjs +3 -1
- package/dist/lib/mirror/infra/workspace-packages.adapter.mjs +6 -2
- package/package.json +3 -3
- package/dist/lib/core/application/architecture-boundaries.policy.mjs +0 -311
package/README.md
CHANGED
|
@@ -1,10 +1,37 @@
|
|
|
1
1
|
# @codefast/cli
|
|
2
2
|
|
|
3
|
-
A
|
|
3
|
+
A small developer CLI for maintenance tasks in a TypeScript monorepo — Tailwind class arranging, `package.json` `exports` mirroring, and `@since` JSDoc tagging.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Table of Contents
|
|
8
|
+
|
|
9
|
+
- [Why @codefast/cli](#why-codefastcli)
|
|
10
|
+
- [Requirements](#requirements)
|
|
11
|
+
- [Installation](#installation)
|
|
12
|
+
- [Quick start](#quick-start)
|
|
13
|
+
- [Global options](#global-options)
|
|
14
|
+
- [Exit codes](#exit-codes)
|
|
15
|
+
- [`arrange`](#arrange)
|
|
16
|
+
- [`mirror sync`](#mirror-sync)
|
|
17
|
+
- [`tag` / `annotate`](#tag--annotate)
|
|
18
|
+
- [Configuration (`codefast.config.*`)](#configuration-codefastconfig)
|
|
19
|
+
- [Lifecycle hooks](#lifecycle-hooks)
|
|
20
|
+
- [Grouping philosophy — Render Pipeline Order](#grouping-philosophy--render-pipeline-order)
|
|
21
|
+
- [Troubleshooting](#troubleshooting)
|
|
22
|
+
- [Contributing (monorepo setup)](#contributing-monorepo-setup)
|
|
23
|
+
- [License](#license)
|
|
24
|
+
- [Changelog](#changelog)
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## Why @codefast/cli
|
|
29
|
+
|
|
30
|
+
Three recurring maintenance chores you don't want to script by hand:
|
|
31
|
+
|
|
32
|
+
- **`arrange`** — regroup Tailwind class strings inside `cn()` / `tv()` calls in render-pipeline order.
|
|
6
33
|
- **`mirror`** — regenerate `package.json` `exports` fields from built `dist/` trees across a pnpm workspace.
|
|
7
|
-
- **`tag`** (alias
|
|
34
|
+
- **`tag`** (alias **`annotate`**) — add `@since <version>` JSDoc tags to exported declarations that are missing version metadata.
|
|
8
35
|
|
|
9
36
|
```mermaid
|
|
10
37
|
flowchart LR
|
|
@@ -25,20 +52,8 @@ flowchart LR
|
|
|
25
52
|
|
|
26
53
|
## Requirements
|
|
27
54
|
|
|
28
|
-
- Node.js `>=22.0.0`
|
|
29
|
-
- pnpm (recommended)
|
|
30
|
-
|
|
31
|
-
---
|
|
32
|
-
|
|
33
|
-
## Exit codes
|
|
34
|
-
|
|
35
|
-
| Code | Meaning |
|
|
36
|
-
| ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
37
|
-
| `0` | Success. |
|
|
38
|
-
| `1` | General failure (missing paths, infrastructure errors, partial failures such as `mirror sync` with package errors, or hook / run errors for `tag` / `arrange`). |
|
|
39
|
-
| `2` | Invalid invocation or input (Zod / schema validation for CLI requests). |
|
|
40
|
-
|
|
41
|
-
Human-readable errors and diagnostics go to **stderr**; primary command output goes to **stdout**.
|
|
55
|
+
- Node.js `>= 22.0.0`
|
|
56
|
+
- pnpm (recommended — the CLI discovers workspaces via `pnpm-workspace.yaml`)
|
|
42
57
|
|
|
43
58
|
---
|
|
44
59
|
|
|
@@ -47,9 +62,15 @@ Human-readable errors and diagnostics go to **stderr**; primary command output g
|
|
|
47
62
|
```bash
|
|
48
63
|
# Install globally
|
|
49
64
|
pnpm add -g @codefast/cli
|
|
65
|
+
# or
|
|
66
|
+
npm install -g @codefast/cli
|
|
67
|
+
# or
|
|
68
|
+
yarn global add @codefast/cli
|
|
50
69
|
|
|
51
70
|
# Or run without installing
|
|
52
71
|
pnpm dlx @codefast/cli --help
|
|
72
|
+
# or
|
|
73
|
+
npx @codefast/cli --help
|
|
53
74
|
```
|
|
54
75
|
|
|
55
76
|
---
|
|
@@ -57,28 +78,55 @@ pnpm dlx @codefast/cli --help
|
|
|
57
78
|
## Quick start
|
|
58
79
|
|
|
59
80
|
```bash
|
|
60
|
-
#
|
|
81
|
+
# Analyze Tailwind classes in the nearest package
|
|
82
|
+
codefast arrange analyze
|
|
83
|
+
|
|
84
|
+
# Preview proposed rewrites — no files written
|
|
61
85
|
codefast arrange preview packages/ui/src/components
|
|
62
86
|
|
|
63
|
-
#
|
|
87
|
+
# Apply after reviewing
|
|
64
88
|
codefast arrange apply packages/ui/src/components
|
|
65
89
|
|
|
66
|
-
#
|
|
90
|
+
# Regenerate every package's `exports` from built dist/
|
|
67
91
|
codefast mirror sync
|
|
68
92
|
|
|
69
|
-
#
|
|
93
|
+
# Add @since <version> to exported APIs under ./src
|
|
70
94
|
codefast tag
|
|
71
95
|
```
|
|
72
96
|
|
|
73
97
|
---
|
|
74
98
|
|
|
99
|
+
## Global options
|
|
100
|
+
|
|
101
|
+
| Flag | Effect |
|
|
102
|
+
| ----------------- | ---------------------------------------------------------------------- |
|
|
103
|
+
| `--no-color` | Disable ANSI color output (also respected by JSON output suppression). |
|
|
104
|
+
| `-V`, `--version` | Print the CLI version and exit. |
|
|
105
|
+
| `-h`, `--help` | Show contextual help for the invoked command. |
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
## Exit codes
|
|
110
|
+
|
|
111
|
+
| Code | Meaning |
|
|
112
|
+
| ---- | -------------------------------------------------------------------------------------------------------- |
|
|
113
|
+
| `0` | Success. |
|
|
114
|
+
| `1` | General failure (missing paths, infrastructure errors, partial failures in `mirror sync`, failed hooks). |
|
|
115
|
+
| `2` | Invalid invocation or input (Zod schema validation on CLI requests — `CLI_EXIT_USAGE`). |
|
|
116
|
+
|
|
117
|
+
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.
|
|
118
|
+
|
|
119
|
+
---
|
|
120
|
+
|
|
75
121
|
## `arrange`
|
|
76
122
|
|
|
77
|
-
Reads `cn()` and `tv()` call sites, classifies each Tailwind utility, and rewrites the class
|
|
123
|
+
Reads `cn()` and `tv()` call sites, classifies each Tailwind utility, and rewrites the class string in render-pipeline order (see [Grouping philosophy](#grouping-philosophy--render-pipeline-order)).
|
|
78
124
|
|
|
79
|
-
###
|
|
125
|
+
### Target resolution
|
|
126
|
+
|
|
127
|
+
When `[target]` is omitted, `arrange` auto-detects the **nearest package directory** by walking up from the current working directory until it finds a `package.json`. Pass an explicit path (file or directory) to override.
|
|
80
128
|
|
|
81
|
-
|
|
129
|
+
### Workflow
|
|
82
130
|
|
|
83
131
|
| Step | Command | Effect |
|
|
84
132
|
| ---- | ----------------------------------- | ------------------------------------- |
|
|
@@ -86,63 +134,106 @@ Run the three subcommands in order:
|
|
|
86
134
|
| 2 | `codefast arrange preview [target]` | Show exactly what `apply` would write |
|
|
87
135
|
| 3 | `codefast arrange apply [target]` | Write the changes |
|
|
88
136
|
|
|
89
|
-
|
|
137
|
+
### Flags (preview / apply)
|
|
90
138
|
|
|
91
|
-
|
|
139
|
+
| Flag | Description |
|
|
140
|
+
| -------------------- | --------------------------------------------------------------------------- |
|
|
141
|
+
| `--with-class-name` | Append `className` as the last argument when rewriting a `cn(...)` call. |
|
|
142
|
+
| `--cn-import <spec>` | Override the module specifier used when a missing `cn` import is added. |
|
|
143
|
+
| `--json` | Print a single JSON object on stdout; suppresses human progress and colors. |
|
|
92
144
|
|
|
93
|
-
|
|
94
|
-
| -------------------- | ----------------------------------------------------------------------- |
|
|
95
|
-
| `--with-class-name` | Append `className` as the last argument when rewriting a `cn(...)` call |
|
|
96
|
-
| `--cn-import <spec>` | Override the module specifier used when adding a missing `cn` import |
|
|
145
|
+
`analyze` also supports `--json`.
|
|
97
146
|
|
|
98
|
-
### `arrange group` — one-shot
|
|
147
|
+
### `arrange group` — one-shot classification
|
|
99
148
|
|
|
100
|
-
Groups a
|
|
149
|
+
Groups a class string without touching the filesystem. Useful for checking how classes would be grouped before running `apply`:
|
|
101
150
|
|
|
102
151
|
```bash
|
|
152
|
+
# Quoted string
|
|
103
153
|
codefast arrange group "relative flex items-center h-10 w-full rounded-md bg-primary text-white hover:bg-primary/90"
|
|
154
|
+
|
|
155
|
+
# Or space-separated tokens (no quotes needed)
|
|
156
|
+
codefast arrange group relative flex items-center h-10 w-full rounded-md
|
|
157
|
+
|
|
158
|
+
# Emit a tv()-style array instead of a cn() call
|
|
159
|
+
codefast arrange group --tv "flex items-center gap-2"
|
|
104
160
|
```
|
|
105
161
|
|
|
106
|
-
|
|
162
|
+
| Flag | Description |
|
|
163
|
+
| ------------------- | -------------------------------------------------------------------- |
|
|
164
|
+
| `--tv` | Emit a `tv()`-style array literal instead of a `cn(...)` call. |
|
|
165
|
+
| `--with-class-name` | Append `className` to the emitted `cn(...)` call. |
|
|
166
|
+
| `--json` | Emit `{ schemaVersion, primaryLine, bucketsCommentLine }` on stdout. |
|
|
107
167
|
|
|
108
|
-
|
|
168
|
+
### `--json` payloads
|
|
109
169
|
|
|
110
|
-
| Subcommand | Payload highlights
|
|
111
|
-
| ------------------- |
|
|
112
|
-
| `analyze` | `schemaVersion`, `analyzeRootPath`, full `report` (same data as the human report).
|
|
113
|
-
| `preview` / `apply` | `schemaVersion`, `write`, `ok` (false if `onAfterWrite` hook failed), full `result` (`filePaths`, `modifiedFiles`, `totalFound`, …). |
|
|
114
|
-
| `group` | `schemaVersion`, `primaryLine`, `bucketsCommentLine`.
|
|
170
|
+
| Subcommand | Payload highlights |
|
|
171
|
+
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
172
|
+
| `analyze` | `schemaVersion`, `analyzeRootPath`, full `report` (same data as the human report). |
|
|
173
|
+
| `preview` / `apply` | `schemaVersion`, `write`, `ok` (`false` if the `onAfterWrite` hook failed), full `result` (`filePaths`, `modifiedFiles`, `totalFound`, …). |
|
|
174
|
+
| `group` | `schemaVersion`, `primaryLine`, `bucketsCommentLine`. |
|
|
115
175
|
|
|
116
176
|
---
|
|
117
177
|
|
|
118
178
|
## `mirror sync`
|
|
119
179
|
|
|
120
|
-
Scans built `dist/` trees and regenerates the `exports` field
|
|
180
|
+
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`.
|
|
121
181
|
|
|
122
182
|
```bash
|
|
123
|
-
codefast mirror sync
|
|
124
|
-
codefast mirror sync packages/ui
|
|
125
|
-
codefast mirror sync -v
|
|
126
|
-
codefast mirror sync --json
|
|
183
|
+
codefast mirror sync # all workspace packages
|
|
184
|
+
codefast mirror sync packages/ui # one package (path relative to repo root)
|
|
185
|
+
codefast mirror sync -v # verbose diagnostics
|
|
186
|
+
codefast mirror sync --json # JSON summary for scripts / CI
|
|
127
187
|
```
|
|
128
188
|
|
|
129
|
-
|
|
189
|
+
| Flag | Description |
|
|
190
|
+
| ----------------- | --------------------------------------------------------------------- |
|
|
191
|
+
| `-v`, `--verbose` | Print extra diagnostics. |
|
|
192
|
+
| `--json` | Print a single `{ schemaVersion, ok, elapsedSeconds, stats }` object. |
|
|
130
193
|
|
|
131
|
-
> **
|
|
194
|
+
> **Build first.** `mirror sync` reads from `dist/`. Run your build before syncing or exports will reflect stale output.
|
|
132
195
|
|
|
133
|
-
|
|
196
|
+
Exit code is `1` when any package fails (`stats.packagesErrored > 0`), `0` otherwise.
|
|
134
197
|
|
|
135
|
-
|
|
198
|
+
---
|
|
199
|
+
|
|
200
|
+
## `tag` / `annotate`
|
|
136
201
|
|
|
137
|
-
|
|
202
|
+
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.
|
|
203
|
+
|
|
204
|
+
```bash
|
|
205
|
+
codefast tag # auto-discover workspace packages from cwd
|
|
206
|
+
codefast tag packages/ui/src # annotate a custom target
|
|
207
|
+
codefast annotate --dry-run # preview only, do not write
|
|
208
|
+
codefast tag --json # JSON summary for scripts / CI
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
| Flag | Description |
|
|
212
|
+
| ----------- | ----------------------------------------------------------------- |
|
|
213
|
+
| `--dry-run` | Show summary without writing files. |
|
|
214
|
+
| `--json` | Print a single JSON summary on stdout; suppresses human progress. |
|
|
215
|
+
|
|
216
|
+
What it updates:
|
|
217
|
+
|
|
218
|
+
- Adds `/** @since <version> */` when an exported declaration has no JSDoc.
|
|
219
|
+
- Injects `@since <version>` into an existing JSDoc block that lacks one.
|
|
220
|
+
- Leaves declarations alone when `@since` is already present.
|
|
221
|
+
|
|
222
|
+
---
|
|
223
|
+
|
|
224
|
+
## Configuration (`codefast.config.*`)
|
|
225
|
+
|
|
226
|
+
Create `codefast.config.js`, `.mjs`, `.cjs`, or `.json` at the repo root. Each command reads its own slice.
|
|
227
|
+
|
|
228
|
+
```javascript
|
|
138
229
|
// codefast.config.mjs
|
|
230
|
+
import { execSync } from "node:child_process";
|
|
231
|
+
|
|
139
232
|
export default {
|
|
140
233
|
mirror: {
|
|
141
234
|
skipPackages: ["@acme/internal"],
|
|
142
235
|
pathTransformations: {
|
|
143
|
-
"@acme/ui": {
|
|
144
|
-
removePrefix: "./components/",
|
|
145
|
-
},
|
|
236
|
+
"@acme/ui": { removePrefix: "./components/" },
|
|
146
237
|
},
|
|
147
238
|
customExports: {
|
|
148
239
|
"@acme/ui": {
|
|
@@ -150,80 +241,76 @@ export default {
|
|
|
150
241
|
},
|
|
151
242
|
},
|
|
152
243
|
cssExports: {
|
|
244
|
+
// Shorthand: `true` enables default CSS export detection
|
|
245
|
+
"@acme/theme": true,
|
|
246
|
+
// Or configure explicitly:
|
|
153
247
|
"@acme/ui": {
|
|
154
248
|
enabled: true,
|
|
249
|
+
forceExportFiles: false,
|
|
155
250
|
customExports: {
|
|
156
251
|
"./tokens.css": "./dist/tokens.css",
|
|
157
252
|
},
|
|
158
253
|
},
|
|
159
254
|
},
|
|
160
255
|
},
|
|
256
|
+
|
|
257
|
+
tag: {
|
|
258
|
+
skipPackages: ["@acme/internal"],
|
|
259
|
+
onAfterWrite: ({ files }) => {
|
|
260
|
+
execSync(`oxfmt ${files.join(" ")}`, { stdio: "inherit" });
|
|
261
|
+
},
|
|
262
|
+
},
|
|
263
|
+
|
|
264
|
+
arrange: {
|
|
265
|
+
onAfterWrite: ({ files }) => {
|
|
266
|
+
execSync(`oxfmt ${files.join(" ")}`, { stdio: "inherit" });
|
|
267
|
+
},
|
|
268
|
+
},
|
|
161
269
|
};
|
|
162
270
|
```
|
|
163
271
|
|
|
164
|
-
|
|
272
|
+
Notes:
|
|
165
273
|
|
|
166
|
-
|
|
274
|
+
- Keys in `mirror.skipPackages` / `pathTransformations` / `customExports` / `cssExports` are **package names** from `package.json#name` (e.g. `@acme/ui`). Path-based keys such as `packages/ui` are deprecated and will be removed.
|
|
275
|
+
- `cssExports[pkg]` accepts a boolean shorthand or the full `{ enabled, customExports, forceExportFiles }` object.
|
|
276
|
+
- `tag.skipPackages` lists package names to skip entirely when `codefast tag` is run without an explicit target.
|
|
167
277
|
|
|
168
|
-
> **Security
|
|
278
|
+
> **Security.** `.js`, `.mjs`, and `.cjs` config files are loaded via `import()` — only run `codefast` inside repositories you trust.
|
|
169
279
|
|
|
170
280
|
---
|
|
171
281
|
|
|
172
|
-
## Lifecycle hooks
|
|
282
|
+
## Lifecycle hooks
|
|
173
283
|
|
|
174
|
-
`
|
|
284
|
+
Both `tag` and `arrange apply` expose an `onAfterWrite` hook so teams can plug in their own formatter, lint-fix step, or codemod without embedding one in the CLI core.
|
|
175
285
|
|
|
176
286
|
```javascript
|
|
177
|
-
import { execSync } from "node:child_process";
|
|
178
|
-
|
|
179
287
|
export default {
|
|
180
288
|
tag: {
|
|
181
|
-
onAfterWrite: ({ files }) => {
|
|
182
|
-
console.log(`Formatting ${files.length} files
|
|
183
|
-
|
|
289
|
+
onAfterWrite: async ({ files }) => {
|
|
290
|
+
console.log(`Formatting ${files.length} files…`);
|
|
291
|
+
// sync or async work
|
|
184
292
|
},
|
|
185
293
|
},
|
|
186
294
|
arrange: {
|
|
187
295
|
onAfterWrite: ({ files }) => {
|
|
188
|
-
|
|
296
|
+
/* … */
|
|
189
297
|
},
|
|
190
298
|
},
|
|
191
299
|
};
|
|
192
300
|
```
|
|
193
301
|
|
|
194
|
-
|
|
302
|
+
Contract:
|
|
195
303
|
|
|
196
304
|
- `tag.onAfterWrite?.({ files })` runs after `codefast tag` writes files.
|
|
197
305
|
- `arrange.onAfterWrite?.({ files })` runs after `codefast arrange apply` writes files.
|
|
198
|
-
- Hooks
|
|
199
|
-
- Hook failures are reported on stderr;
|
|
200
|
-
|
|
201
|
-
---
|
|
202
|
-
|
|
203
|
-
## `tag` / `annotate`
|
|
204
|
-
|
|
205
|
-
Scans `.ts` / `.tsx` source files and annotates exported declarations with `@since <current-package-version>`. This keeps API evolution visible and reduces documentation drift in long-lived codebases.
|
|
206
|
-
|
|
207
|
-
```bash
|
|
208
|
-
codefast tag # annotate exports in ./src
|
|
209
|
-
codefast tag packages/ui/src # annotate a custom target
|
|
210
|
-
codefast annotate --dry-run # preview only, do not write files
|
|
211
|
-
codefast tag --json # one JSON object on stdout (for scripts / CI)
|
|
212
|
-
```
|
|
213
|
-
|
|
214
|
-
What it updates:
|
|
215
|
-
|
|
216
|
-
- Adds `/** @since <version> */` when an exported declaration has no JSDoc.
|
|
217
|
-
- Injects `@since <version>` into an existing JSDoc block when missing.
|
|
218
|
-
- Leaves declarations unchanged when `@since` is already present.
|
|
219
|
-
|
|
220
|
-
The `<version>` value is read from the nearest `package.json` found by walking up from the target path.
|
|
306
|
+
- Hooks may be synchronous or asynchronous (`void | Promise<void>`).
|
|
307
|
+
- Hook failures are reported on stderr; both commands exit with code `1` when the hook rejects.
|
|
221
308
|
|
|
222
309
|
---
|
|
223
310
|
|
|
224
311
|
## Grouping philosophy — Render Pipeline Order
|
|
225
312
|
|
|
226
|
-
`arrange` does **not** sort classes alphabetically. Instead, it groups utilities in roughly the order the browser applies them — from the box's existence, through its shape and surface, to interactive behavior. This makes class strings easier to scan and
|
|
313
|
+
`arrange` does **not** sort classes alphabetically. Instead, it groups utilities in roughly the order the browser applies them — from the box's existence, through its shape and surface, to interactive behavior. This makes class strings easier to scan and diff.
|
|
227
314
|
|
|
228
315
|
**Existence → Position → Layout → Sizing → Spacing → Shape → Background → Shadow → Typography → Composite → Motion → Starting → Behavior → State → Selector**
|
|
229
316
|
|
|
@@ -245,9 +332,9 @@ The `<version>` value is read from the nearest `package.json` found by walking u
|
|
|
245
332
|
| **State** | Interactive and conditional variants (non-selector) | `hover:`, `md:`, `@md/sidebar:`, `data-[…]:` |
|
|
246
333
|
| **Selector** | Selector-driven variants | `[&…]:`, `*:`, `**:`, `has-*`, `group-[…]:` |
|
|
247
334
|
|
|
248
|
-
Adjacent buckets may be merged into
|
|
335
|
+
Adjacent buckets may be merged into a single string literal when declared _compatible_ (e.g. Layout + Sizing), which keeps `cn()` calls readable without flattening unrelated concerns.
|
|
249
336
|
|
|
250
|
-
To change
|
|
337
|
+
To change placement, edit `classifyBareUtility` in `src/lib/arrange/domain/tokenizer.util.ts` and add a matching case in `tokenizer.util.test.ts`.
|
|
251
338
|
|
|
252
339
|
---
|
|
253
340
|
|
|
@@ -257,25 +344,44 @@ To change a placement, edit `classifyBareUtility` in `src/lib/arrange/domain/tok
|
|
|
257
344
|
Install globally with `pnpm add -g @codefast/cli`, or run via `pnpm dlx @codefast/cli <command>`.
|
|
258
345
|
|
|
259
346
|
**`mirror sync` writes little or no output**
|
|
260
|
-
Packages must be built
|
|
347
|
+
Packages must be built first. Ensure `dist/` exists by running your build step, then re-run `codefast mirror sync`.
|
|
261
348
|
|
|
262
349
|
**Unexpected class reorder after `arrange apply`**
|
|
263
|
-
Run `arrange preview`
|
|
350
|
+
Run `arrange preview` first and smoke-test the UI. Some components rely on cascade-sensitive ordering that `arrange` cannot detect automatically.
|
|
351
|
+
|
|
352
|
+
**`--json` output mixed with progress lines**
|
|
353
|
+
Some shells buffer progress writes on stderr into stdout when piping — redirect stderr explicitly: `codefast mirror sync --json 2>/dev/null | jq`.
|
|
264
354
|
|
|
265
355
|
---
|
|
266
356
|
|
|
267
357
|
## Contributing (monorepo setup)
|
|
268
358
|
|
|
269
359
|
```bash
|
|
270
|
-
# Build the local CLI (produces dist/bin.
|
|
360
|
+
# Build the local CLI (produces dist/bin.mjs)
|
|
271
361
|
pnpm --filter @codefast/cli build
|
|
272
362
|
|
|
273
363
|
# Run the local entrypoint
|
|
274
364
|
pnpm exec codefast --help
|
|
365
|
+
|
|
366
|
+
# Test + type-check
|
|
367
|
+
pnpm --filter @codefast/cli test
|
|
368
|
+
pnpm --filter @codefast/cli check-types
|
|
275
369
|
```
|
|
276
370
|
|
|
277
|
-
A few naming conventions
|
|
371
|
+
A few naming conventions:
|
|
278
372
|
|
|
279
|
-
- **`codefast <command>`** refers to CLI commands exposed via the
|
|
373
|
+
- **`codefast <command>`** refers to CLI commands exposed via the `bin` entry in `@codefast/cli`.
|
|
280
374
|
- **Scripts in `packages/cli/package.json`** (`build`, `test`, …) are package-local dev scripts, not CLI commands.
|
|
281
|
-
- The root `package.json` includes optional convenience wrappers
|
|
375
|
+
- The root `package.json` includes optional convenience wrappers (e.g. `cli:mirror-sync`, `cli:arrange-analyze`) for common dev workflows.
|
|
376
|
+
|
|
377
|
+
---
|
|
378
|
+
|
|
379
|
+
## License
|
|
380
|
+
|
|
381
|
+
[MIT](https://opensource.org/licenses/MIT) — see [`package.json`](./package.json).
|
|
382
|
+
|
|
383
|
+
---
|
|
384
|
+
|
|
385
|
+
## Changelog
|
|
386
|
+
|
|
387
|
+
See [CHANGELOG.md](./CHANGELOG.md) for the full version history. Releases are also published on [npm](https://www.npmjs.com/package/@codefast/cli?activeTab=versions).
|
|
@@ -2,7 +2,9 @@ import { EMPTY_CN_TV_BINDINGS } from "../constants.domain.mjs";
|
|
|
2
2
|
import { applyEditsDescending, indentOfLineContaining } from "../../../shared/source-code/domain/text-edit.model.mjs";
|
|
3
3
|
import { isDomainIdentifier, isDomainImportDeclaration, isDomainNamedImports, isDomainNamespaceImport, isDomainPropertyAccessExpression, isDomainStringLiteral, lineOfSourcePosition } from "./ast-node.model.mjs";
|
|
4
4
|
//#region src/lib/arrange/domain/ast/ast-helpers.helper.ts
|
|
5
|
-
/**
|
|
5
|
+
/**
|
|
6
|
+
* Known module specifiers that export `cn` / `tv`.
|
|
7
|
+
*/ const KNOWN_CN_TV_MODULES = new Set([
|
|
6
8
|
"@codefast/tailwind-variants",
|
|
7
9
|
"clsx",
|
|
8
10
|
"class-variance-authority",
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
//#region src/lib/arrange/domain/constants.domain.ts
|
|
2
|
-
/**
|
|
2
|
+
/**
|
|
3
|
+
* Analyze report: long literal threshold (token count).
|
|
4
|
+
*/ const LONG_STRING_TOKEN_THRESHOLD = 18;
|
|
3
5
|
/**
|
|
4
6
|
* Minimum token count for a string to be considered a candidate for grouping
|
|
5
7
|
* in the apply/preview pipeline. Intentionally much lower than
|
|
@@ -9,15 +11,25 @@
|
|
|
9
11
|
* Minimum tokens a group must have to stand alone before singleton-merging.
|
|
10
12
|
* Set to 2 so single-token groups are not collapsed into unrelated buckets by the merger.
|
|
11
13
|
*/ const MIN_GROUP_TOKENS = 2;
|
|
12
|
-
/**
|
|
13
|
-
|
|
14
|
+
/**
|
|
15
|
+
* Dynamic max groups clamp: base bound.
|
|
16
|
+
*/ const MAX_GROUPS_BASE = 4;
|
|
17
|
+
/**
|
|
18
|
+
* Dynamic max groups clamp: upper cap.
|
|
19
|
+
*/ const MAX_GROUPS_CAP = 24;
|
|
14
20
|
/**
|
|
15
21
|
* Extra slots so a few state / aria groups do not force `capGroups` to merge
|
|
16
22
|
* unrelated buckets (e.g. `bg-border` + `outline-hidden`).
|
|
17
23
|
*/ const MAX_GROUPS_HEADROOM = 2;
|
|
18
|
-
/**
|
|
19
|
-
|
|
20
|
-
|
|
24
|
+
/**
|
|
25
|
+
* Maximum findings printed per category in the analyze report.
|
|
26
|
+
*/ const MAX_REPORT_LINES = 40;
|
|
27
|
+
/**
|
|
28
|
+
* Maximum recursion depth when traversing `tv()` object literals.
|
|
29
|
+
*/ const MAX_OBJECT_DEPTH = 12;
|
|
30
|
+
/**
|
|
31
|
+
* Maximum depth when peeling conditional / parens / arrays inside `cn(...)` args.
|
|
32
|
+
*/ const MAX_CLASS_EXPR_DEPTH = 12;
|
|
21
33
|
/**
|
|
22
34
|
* Maximum variant-stripping passes in {@link stripVariants}.
|
|
23
35
|
* Real-world Tailwind stacks rarely exceed 4–5 segments (e.g. `@md/sidebar:group-hover:dark:focus-visible:`);
|
|
@@ -82,7 +94,9 @@
|
|
|
82
94
|
* v3: sm: md: … — v4: @sm:, @min-[600px]:, @[480px]:, named @md/sidebar:,
|
|
83
95
|
* viewport md/sidebar:, min-[100px]: / max-[100px]:, …
|
|
84
96
|
*/ const RESPONSIVE_PREFIX = /^(?:@(?:min|max)-\[[^\]]+\]:|@\[[^\]]+\]:|@(?:[a-z0-9]+(?:-[a-z0-9]+)*)(?:\/[a-z][a-z0-9-]*)?:|(?:max-|min-)?(?:sm|md|lg|xl|2xl|3xl)(?:\/[a-z][a-z0-9-]*)?:|(?:max-|min-)\[[^\]]+\]:)/;
|
|
85
|
-
/**
|
|
97
|
+
/**
|
|
98
|
+
* State variant stems — hoisted to module scope (not recreated per call).
|
|
99
|
+
*/ const STATE_PREFIXES = new Set([
|
|
86
100
|
"hover",
|
|
87
101
|
"focus",
|
|
88
102
|
"focus-within",
|
|
@@ -5,7 +5,9 @@ import { bucketsCompatible, bucketsMergeCompatible, classifyToken, compositeSeco
|
|
|
5
5
|
* `cn()` grouping: bucket sequence is {@link BUCKET_ORDER} only; tokens are classified with
|
|
6
6
|
* {@link classifyToken}. Comparators here (`compareClassifiedTailwindTokensForCnGrouping`, …)
|
|
7
7
|
* are the single place for variant-aware sort — do not reintroduce parallel bucket ordering.
|
|
8
|
-
*/ /**
|
|
8
|
+
*/ /**
|
|
9
|
+
* Separates bucket id from variant key in {@link buildFirstVariantKeySourceIndex} map keys.
|
|
10
|
+
*/ const VARIANT_BUCKET_KEY_SEP = "\0";
|
|
9
11
|
function isVariantKeyedBucket(bucket) {
|
|
10
12
|
return bucket === "selector" || bucket === "state" || bucket === "starting";
|
|
11
13
|
}
|
|
@@ -70,7 +72,9 @@ function compareClassifiedTailwindTokensForCnGrouping(left, right, firstVariantK
|
|
|
70
72
|
for (let i = 0; i < staticPartitionSigs.length; i++) if (staticPartitionSigs[i] !== suggestedPartitionSigs[i]) return false;
|
|
71
73
|
return true;
|
|
72
74
|
}
|
|
73
|
-
/**
|
|
75
|
+
/**
|
|
76
|
+
* Dominant bucket of a whitespace-delimited class group (for merge heuristics).
|
|
77
|
+
*/ function dominantBucketOfGroup(groupStr) {
|
|
74
78
|
const counts = /* @__PURE__ */ new Map();
|
|
75
79
|
for (const classToken of tokenizeClassString(groupStr)) {
|
|
76
80
|
const tokenBucket = classifyToken(classToken);
|
|
@@ -88,7 +92,9 @@ function compareClassifiedTailwindTokensForCnGrouping(left, right, firstVariantK
|
|
|
88
92
|
}
|
|
89
93
|
return best;
|
|
90
94
|
}
|
|
91
|
-
/**
|
|
95
|
+
/**
|
|
96
|
+
* Dynamic cap: more tokens → allow more groups, within [BASE, CAP].
|
|
97
|
+
*/ function dynamicMaxGroups(tokenCount) {
|
|
92
98
|
const byTokens = Math.ceil(tokenCount / 2) + 2;
|
|
93
99
|
return Math.max(4, Math.min(24, byTokens));
|
|
94
100
|
}
|
|
@@ -136,7 +142,9 @@ function compareClassifiedTailwindTokensForCnGrouping(left, right, firstVariantK
|
|
|
136
142
|
}
|
|
137
143
|
return result;
|
|
138
144
|
}
|
|
139
|
-
/**
|
|
145
|
+
/**
|
|
146
|
+
* Tie-break when two merge candidates are both {@link bucketsMergeCompatible} (same bucket or COMPATIBLE_BUCKET_SETS).
|
|
147
|
+
*/ function capMergePenalty(leftBucket, rightBucket) {
|
|
140
148
|
if (leftBucket === rightBucket) return 0;
|
|
141
149
|
if (bucketsCompatible(leftBucket, rightBucket)) return 0;
|
|
142
150
|
return 500;
|
|
@@ -246,7 +254,9 @@ function suggestCnGroups(classString) {
|
|
|
246
254
|
const firstVariantKeySourceIndex = buildFirstVariantKeySourceIndex(classified);
|
|
247
255
|
classified.sort((left, right) => compareClassifiedTailwindTokensForCnGrouping(left, right, firstVariantKeySourceIndex));
|
|
248
256
|
const rawGroups = [];
|
|
249
|
-
/**
|
|
257
|
+
/**
|
|
258
|
+
* Bucket of the last token already placed in the current run (pairwise compat with `COMPATIBLE_BUCKET_SETS`).
|
|
259
|
+
*/ let lastBucketInRun = null;
|
|
250
260
|
let currentStateKey = null;
|
|
251
261
|
let currentTokens = [];
|
|
252
262
|
const flush = () => {
|
|
@@ -46,7 +46,9 @@ function stripVariants(token) {
|
|
|
46
46
|
}
|
|
47
47
|
return withoutVariants;
|
|
48
48
|
}
|
|
49
|
-
/**
|
|
49
|
+
/**
|
|
50
|
+
* Outermost variant segment: first `:` at bracket depth 0 → text before it.
|
|
51
|
+
*/ function firstLeadingVariantPrefix(token) {
|
|
50
52
|
const colonIdx = indexOfFirstVariantColon(token);
|
|
51
53
|
if (colonIdx === -1) return;
|
|
52
54
|
return token.slice(0, colonIdx);
|
|
@@ -93,7 +95,9 @@ function isStateToken(token) {
|
|
|
93
95
|
if (/^will-change/.test(b)) return 50;
|
|
94
96
|
return 99;
|
|
95
97
|
}
|
|
96
|
-
/**
|
|
98
|
+
/**
|
|
99
|
+
* Classify a **bare** utility (no `hover:` / `md:` / … prefixes).
|
|
100
|
+
*/ function classifyBareUtility(bareUtility) {
|
|
97
101
|
const b = bareUtility;
|
|
98
102
|
if (/^@container(?:\/[a-z][a-z0-9-]*)?$/i.test(b)) return "existence";
|
|
99
103
|
if (/^(?:hidden|contents|sr-only|not-sr-only|list-item|flow-root)$/.test(b) || /^(?:block|inline-block|inline)$/.test(b)) return "existence";
|
|
@@ -148,11 +152,15 @@ function classifyToken(token) {
|
|
|
148
152
|
const match = token.match(/^data-\[([^\]]*)\]/);
|
|
149
153
|
return match ? `data-[${match[1]}]` : "data";
|
|
150
154
|
}
|
|
151
|
-
/**
|
|
155
|
+
/**
|
|
156
|
+
* Same idea as {@link dataAttributeStem} for `aria-[…]:` variants.
|
|
157
|
+
*/ function ariaAttributeStem(token) {
|
|
152
158
|
const match = token.match(/^aria-\[([^\]]*)\]/);
|
|
153
159
|
return match ? `aria-[${match[1]}]` : "aria";
|
|
154
160
|
}
|
|
155
|
-
/**
|
|
161
|
+
/**
|
|
162
|
+
* `in-data-[…]:` — normalize on the full bracket expression like {@link dataAttributeStem}.
|
|
163
|
+
*/ function inDataAttributeStem(token) {
|
|
156
164
|
const match = token.match(/^in-data-\[([^\]]*)\]/);
|
|
157
165
|
return match ? `in-data-[${match[1]}]` : "in-data";
|
|
158
166
|
}
|
|
@@ -198,7 +206,9 @@ function bucketsCompatible(a, b) {
|
|
|
198
206
|
if (a === b) return true;
|
|
199
207
|
return COMPATIBLE_BUCKET_SETS.some((bucketSet) => bucketSet.has(a) && bucketSet.has(b));
|
|
200
208
|
}
|
|
201
|
-
/**
|
|
209
|
+
/**
|
|
210
|
+
* Like {@link bucketsCompatible}, but never merge two distinct state / starting / selector variant blobs.
|
|
211
|
+
*/ function bucketsMergeCompatible(a, b) {
|
|
202
212
|
if (a === "state" && b === "state") return false;
|
|
203
213
|
if (a === "starting" && b === "starting") return false;
|
|
204
214
|
if (a === "selector" && b === "selector") return false;
|
|
@@ -235,6 +235,8 @@ let _DomainSourceParserAdapter;
|
|
|
235
235
|
_initClass();
|
|
236
236
|
}
|
|
237
237
|
});
|
|
238
|
-
/**
|
|
238
|
+
/**
|
|
239
|
+
* Default instance without diagnostics logging (tests and legacy imports).
|
|
240
|
+
*/ const domainSourceParserAdapter = new _DomainSourceParserAdapter();
|
|
239
241
|
//#endregion
|
|
240
242
|
export { _DomainSourceParserAdapter as DomainSourceParserAdapter, domainSourceParserAdapter };
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { ensureCnImport as ensureCnImport$1 } from "../domain/imports.domain.mjs";
|
|
2
2
|
import { parseDomainSourceFile } from "./ts-ast-translator.adapter.mjs";
|
|
3
3
|
//#region src/lib/arrange/infra/ensure-cn-import.adapter.ts
|
|
4
|
-
/**
|
|
4
|
+
/**
|
|
5
|
+
* Parses source then applies domain cn import injection rules (public CLI-style entry).
|
|
6
|
+
*/ function ensureCnImport(sourceText, filePath, cnImportOverride) {
|
|
5
7
|
return ensureCnImport$1(parseDomainSourceFile(filePath, sourceText), cnImportOverride);
|
|
6
8
|
}
|
|
7
9
|
//#endregion
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
//#region src/lib/infra/config-reporter.adapter.ts
|
|
2
2
|
const YELLOW = "\x1B[33m";
|
|
3
3
|
const RESET = "\x1B[0m";
|
|
4
|
-
/**
|
|
4
|
+
/**
|
|
5
|
+
* User-visible lines for non-fatal issues returned with a successfully parsed config.
|
|
6
|
+
*/ function printConfigSchemaWarnings(logger, warnings) {
|
|
5
7
|
const { out } = logger;
|
|
6
8
|
for (const warningMessage of warnings) out(`${YELLOW}\u26A0\uFE0F ${warningMessage}${RESET}`);
|
|
7
9
|
}
|
|
@@ -27,7 +27,9 @@ function compareExportSpecifiers(leftSpecifier, rightSpecifier, originalPathBySp
|
|
|
27
27
|
if (originalPathComparison !== 0) return originalPathComparison;
|
|
28
28
|
return leftSpecifier.localeCompare(rightSpecifier);
|
|
29
29
|
}
|
|
30
|
-
/**
|
|
30
|
+
/**
|
|
31
|
+
* Shared rule: show `package.json#name` when it is a non-empty string; else folder basename.
|
|
32
|
+
*/ function resolvePackageDisplayName(packageJson, folderBasename) {
|
|
31
33
|
const declaredName = packageJson.name;
|
|
32
34
|
return typeof declaredName === "string" && declaredName.length > 0 ? declaredName : folderBasename;
|
|
33
35
|
}
|
|
@@ -6,7 +6,9 @@ import picomatch from "picomatch";
|
|
|
6
6
|
import { parse } from "yaml";
|
|
7
7
|
//#region src/lib/mirror/infra/workspace-packages.adapter.ts
|
|
8
8
|
const PNPM_WORKSPACE = "pnpm-workspace.yaml";
|
|
9
|
-
/**
|
|
9
|
+
/**
|
|
10
|
+
* Default when the workspace file is missing or has no `packages` key.
|
|
11
|
+
*/ const DEFAULT_INCLUDE = ["packages/*"];
|
|
10
12
|
function toPosix(filePath) {
|
|
11
13
|
return filePath.split(path.sep).join("/");
|
|
12
14
|
}
|
|
@@ -15,7 +17,9 @@ function isGlobPermissionError(caughtError) {
|
|
|
15
17
|
const code = caughtError.code;
|
|
16
18
|
return code === "EACCES" || code === "EPERM";
|
|
17
19
|
}
|
|
18
|
-
/**
|
|
20
|
+
/**
|
|
21
|
+
* Turn a pnpm `packages` glob into a `globSync` pattern that finds `package.json` files.
|
|
22
|
+
*/ function workspacePatternToPackageJsonGlob(pattern) {
|
|
19
23
|
const normalizedPattern = toPosix(pattern.trim()).replace(/\/+$/, "");
|
|
20
24
|
if (!normalizedPattern) return "**/package.json";
|
|
21
25
|
return `${normalizedPattern}/package.json`;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@codefast/cli",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.14-canary.1",
|
|
4
4
|
"description": "Developer CLI for the Codefast monorepo (arrange, mirror, tag)",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cli",
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
"typescript": "^6.0.2",
|
|
51
51
|
"yaml": "^2.8.3",
|
|
52
52
|
"zod": "^4.3.6",
|
|
53
|
-
"@codefast/di": "0.3.
|
|
53
|
+
"@codefast/di": "0.3.14-canary.1"
|
|
54
54
|
},
|
|
55
55
|
"devDependencies": {
|
|
56
56
|
"@types/node": "^25.6.0",
|
|
@@ -61,7 +61,7 @@
|
|
|
61
61
|
"unplugin-swc": "^1.5.9",
|
|
62
62
|
"vite": "^8.0.8",
|
|
63
63
|
"vitest": "^4.1.4",
|
|
64
|
-
"@codefast/typescript-config": "0.3.
|
|
64
|
+
"@codefast/typescript-config": "0.3.14-canary.1"
|
|
65
65
|
},
|
|
66
66
|
"engines": {
|
|
67
67
|
"node": ">=22.0.0"
|
|
@@ -1,311 +0,0 @@
|
|
|
1
|
-
//#region src/lib/core/application/architecture-boundaries.policy.ts
|
|
2
|
-
/** Product bounded contexts: no direct cross-imports between these (domain + application rules). */ const PRODUCT_BOUNDED_CONTEXTS = new Set([
|
|
3
|
-
"arrange",
|
|
4
|
-
"mirror",
|
|
5
|
-
"tag"
|
|
6
|
-
]);
|
|
7
|
-
const SHARED_SOURCE_CODE_CONTEXT = "shared-source-code";
|
|
8
|
-
const PATH_SEPARATOR = "/";
|
|
9
|
-
const LAYERS = new Set([
|
|
10
|
-
"domain",
|
|
11
|
-
"application",
|
|
12
|
-
"infra",
|
|
13
|
-
"presentation"
|
|
14
|
-
]);
|
|
15
|
-
/** Import roots that any product slice may depend on without going "through" another slice. */ const NEUTRAL_LIB_IMPORT_ROOTS = new Set([
|
|
16
|
-
"core",
|
|
17
|
-
"config",
|
|
18
|
-
"infra",
|
|
19
|
-
"shared"
|
|
20
|
-
]);
|
|
21
|
-
/** Internal imports: tsconfig `#/*` → `src/*`. */ const INTERNAL_LIB_PREFIX = "#/lib/";
|
|
22
|
-
function pathAfterInternalLibPrefix(specifier) {
|
|
23
|
-
if (!specifier.startsWith(INTERNAL_LIB_PREFIX)) return null;
|
|
24
|
-
return specifier.slice(6);
|
|
25
|
-
}
|
|
26
|
-
function internalLibSpecifierSegmentList(specifier) {
|
|
27
|
-
const afterPrefix = pathAfterInternalLibPrefix(specifier);
|
|
28
|
-
if (afterPrefix === null) return null;
|
|
29
|
-
const segments = afterPrefix.split(PATH_SEPARATOR).filter(Boolean);
|
|
30
|
-
return segments.length === 0 ? null : segments;
|
|
31
|
-
}
|
|
32
|
-
function normalizePathSeparators(pathValue) {
|
|
33
|
-
return pathValue.split("\\").join(PATH_SEPARATOR);
|
|
34
|
-
}
|
|
35
|
-
function splitPath(pathValue) {
|
|
36
|
-
return normalizePathSeparators(pathValue).split(PATH_SEPARATOR).filter(Boolean);
|
|
37
|
-
}
|
|
38
|
-
function basenamePath(pathValue) {
|
|
39
|
-
const parts = splitPath(pathValue);
|
|
40
|
-
return parts.length === 0 ? "" : parts[parts.length - 1] ?? "";
|
|
41
|
-
}
|
|
42
|
-
function dirnamePath(pathValue) {
|
|
43
|
-
const parts = normalizePathSeparators(pathValue).split(PATH_SEPARATOR);
|
|
44
|
-
parts.pop();
|
|
45
|
-
if (parts.length === 0) return PATH_SEPARATOR;
|
|
46
|
-
const joined = parts.join(PATH_SEPARATOR);
|
|
47
|
-
return joined === "" ? PATH_SEPARATOR : joined;
|
|
48
|
-
}
|
|
49
|
-
function joinPath(...segments) {
|
|
50
|
-
return segments.map((segment) => normalizePathSeparators(segment)).join(PATH_SEPARATOR).replace(/\/+/g, PATH_SEPARATOR);
|
|
51
|
-
}
|
|
52
|
-
function normalizeDotSegments(pathValue) {
|
|
53
|
-
const normalized = normalizePathSeparators(pathValue);
|
|
54
|
-
const isAbsolute = normalized.startsWith(PATH_SEPARATOR);
|
|
55
|
-
const segments = normalized.split(PATH_SEPARATOR);
|
|
56
|
-
const stack = [];
|
|
57
|
-
for (const segment of segments) {
|
|
58
|
-
if (!segment || segment === ".") continue;
|
|
59
|
-
if (segment === "..") {
|
|
60
|
-
if (stack.length > 0) stack.pop();
|
|
61
|
-
continue;
|
|
62
|
-
}
|
|
63
|
-
stack.push(segment);
|
|
64
|
-
}
|
|
65
|
-
const suffix = stack.join(PATH_SEPARATOR);
|
|
66
|
-
return isAbsolute ? `${PATH_SEPARATOR}${suffix}` : suffix;
|
|
67
|
-
}
|
|
68
|
-
function sharedSourceCodeLayerFromSpecifier(segments) {
|
|
69
|
-
if (segments.length < 3 || segments[0] !== "shared" || segments[1] !== "source-code") return null;
|
|
70
|
-
return segments[2] ?? null;
|
|
71
|
-
}
|
|
72
|
-
/**
|
|
73
|
-
* Extract `#/lib/...` and relative import specifiers from TypeScript source
|
|
74
|
-
* (static `from` / `import("...")` only).
|
|
75
|
-
*/ function extractImportSpecifiers(sourceText) {
|
|
76
|
-
const specifiers = [];
|
|
77
|
-
const fromPattern = /\bfrom\s+["']([^"']+)["']/g;
|
|
78
|
-
let fromMatch;
|
|
79
|
-
while ((fromMatch = fromPattern.exec(sourceText)) !== null) {
|
|
80
|
-
const cap = fromMatch[1];
|
|
81
|
-
if (cap !== void 0) specifiers.push(cap);
|
|
82
|
-
}
|
|
83
|
-
const importOnlyPattern = /^\s*import\s+["']([^"']+)["']\s*;/gm;
|
|
84
|
-
let importOnlyMatch;
|
|
85
|
-
while ((importOnlyMatch = importOnlyPattern.exec(sourceText)) !== null) {
|
|
86
|
-
const cap = importOnlyMatch[1];
|
|
87
|
-
if (cap !== void 0) specifiers.push(cap);
|
|
88
|
-
}
|
|
89
|
-
const importCallPattern = /\bimport\s*\(\s*["']([^"']+)["']\s*\)/g;
|
|
90
|
-
let importCallMatch;
|
|
91
|
-
while ((importCallMatch = importCallPattern.exec(sourceText)) !== null) {
|
|
92
|
-
const cap = importCallMatch[1];
|
|
93
|
-
if (cap !== void 0) specifiers.push(cap);
|
|
94
|
-
}
|
|
95
|
-
return specifiers;
|
|
96
|
-
}
|
|
97
|
-
function pathUnderCliSrcLib(absolutePath) {
|
|
98
|
-
const normalized = normalizePathSeparators(absolutePath);
|
|
99
|
-
const marker = `${PATH_SEPARATOR}src${PATH_SEPARATOR}lib${PATH_SEPARATOR}`;
|
|
100
|
-
const index = normalized.lastIndexOf(marker);
|
|
101
|
-
if (index === -1) return null;
|
|
102
|
-
return normalized.slice(index + marker.length);
|
|
103
|
-
}
|
|
104
|
-
function parseLibSourceLocation(absoluteFilePath) {
|
|
105
|
-
const tail = pathUnderCliSrcLib(absoluteFilePath);
|
|
106
|
-
if (tail === null) return null;
|
|
107
|
-
const parts = tail.split(PATH_SEPARATOR).filter(Boolean);
|
|
108
|
-
if (parts.length < 2) return null;
|
|
109
|
-
if (parts[0] === "shared" && parts[1] === "source-code" && parts.length >= 3) {
|
|
110
|
-
const layer = parts[2];
|
|
111
|
-
if (layer === void 0 || !LAYERS.has(layer)) return null;
|
|
112
|
-
const rest = parts.slice(3);
|
|
113
|
-
if (layer === "domain" && rest[0] === "__tests__") return {
|
|
114
|
-
context: SHARED_SOURCE_CODE_CONTEXT,
|
|
115
|
-
layer: "domain"
|
|
116
|
-
};
|
|
117
|
-
return {
|
|
118
|
-
context: SHARED_SOURCE_CODE_CONTEXT,
|
|
119
|
-
layer
|
|
120
|
-
};
|
|
121
|
-
}
|
|
122
|
-
const context = parts[0];
|
|
123
|
-
const layer = parts[1];
|
|
124
|
-
const rest = parts.slice(2);
|
|
125
|
-
if (context === void 0 || layer === void 0 || !LAYERS.has(layer)) return null;
|
|
126
|
-
if (layer === "domain" && rest[0] === "__tests__") return {
|
|
127
|
-
context,
|
|
128
|
-
layer
|
|
129
|
-
};
|
|
130
|
-
return {
|
|
131
|
-
context,
|
|
132
|
-
layer
|
|
133
|
-
};
|
|
134
|
-
}
|
|
135
|
-
function isArchitectureExcludedSourceFile(absoluteFilePath) {
|
|
136
|
-
const base = basenamePath(absoluteFilePath);
|
|
137
|
-
if (base.endsWith(".test.ts") || base.endsWith(".integration.test.ts")) return true;
|
|
138
|
-
return normalizePathSeparators(absoluteFilePath).split(PATH_SEPARATOR).includes("__tests__");
|
|
139
|
-
}
|
|
140
|
-
function resolveRelativeSpecifier(fromAbsoluteFile, specifier) {
|
|
141
|
-
if (!specifier.startsWith(".")) return null;
|
|
142
|
-
return normalizeDotSegments(joinPath(dirnamePath(fromAbsoluteFile), specifier));
|
|
143
|
-
}
|
|
144
|
-
function libTailAfterResolution(absoluteResolved) {
|
|
145
|
-
const tail = pathUnderCliSrcLib(absoluteResolved);
|
|
146
|
-
if (tail === null) return null;
|
|
147
|
-
return tail.split(PATH_SEPARATOR).filter(Boolean);
|
|
148
|
-
}
|
|
149
|
-
function lastSegmentOfLibSpecifier(specifier) {
|
|
150
|
-
const segments = internalLibSpecifierSegmentList(specifier);
|
|
151
|
-
if (segments === null) return null;
|
|
152
|
-
return segments[segments.length - 1] ?? null;
|
|
153
|
-
}
|
|
154
|
-
function importedModuleStem(specifier, fromAbsoluteFile) {
|
|
155
|
-
if (pathAfterInternalLibPrefix(specifier) !== null) return lastSegmentOfLibSpecifier(specifier);
|
|
156
|
-
const resolved = resolveRelativeSpecifier(fromAbsoluteFile, specifier);
|
|
157
|
-
if (resolved === null) return null;
|
|
158
|
-
const baseName = basenamePath(resolved.endsWith(".ts") ? resolved : `${resolved}.ts`);
|
|
159
|
-
return baseName.endsWith(".ts") ? baseName.slice(0, -3) : baseName;
|
|
160
|
-
}
|
|
161
|
-
function isPureDomainOrModelFileName(fileBaseName) {
|
|
162
|
-
return /\.(domain|model)\.ts$/.test(fileBaseName);
|
|
163
|
-
}
|
|
164
|
-
function isRuleAForbiddenImportedStem(moduleStem) {
|
|
165
|
-
return moduleStem.includes(".use-case") || moduleStem.includes(".adapter") || moduleStem.includes(".presenter");
|
|
166
|
-
}
|
|
167
|
-
function isRuleBForbiddenImportedStem(moduleStem) {
|
|
168
|
-
return moduleStem.includes(".adapter") || moduleStem.includes(".presenter");
|
|
169
|
-
}
|
|
170
|
-
function violationRuleAPureDomainModel(absoluteFilePath, fileBaseName, specifier) {
|
|
171
|
-
if (!isPureDomainOrModelFileName(fileBaseName)) return null;
|
|
172
|
-
const stem = importedModuleStem(specifier, absoluteFilePath);
|
|
173
|
-
if (stem === null || !isRuleAForbiddenImportedStem(stem)) return null;
|
|
174
|
-
return `${absoluteFilePath}: Rule A (pure domain/model) must not import orchestration or IO modules — blocked ${specifier} (stem "${stem}")`;
|
|
175
|
-
}
|
|
176
|
-
function violationRuleBApplicationIsolation(loc, absoluteFilePath, specifier) {
|
|
177
|
-
if (loc.layer !== "application") return null;
|
|
178
|
-
const stem = importedModuleStem(specifier, absoluteFilePath);
|
|
179
|
-
if (stem === null || !isRuleBForbiddenImportedStem(stem)) return null;
|
|
180
|
-
return `${absoluteFilePath}: Rule B (application isolation) must not import adapters or presenters — blocked ${specifier} (stem "${stem}")`;
|
|
181
|
-
}
|
|
182
|
-
function violationRuleCAntiCrossSlice(loc, specifier, fromAbsoluteFile) {
|
|
183
|
-
if (!PRODUCT_BOUNDED_CONTEXTS.has(loc.context)) return null;
|
|
184
|
-
const ruleCSegments = internalLibSpecifierSegmentList(specifier);
|
|
185
|
-
if (ruleCSegments !== null) {
|
|
186
|
-
const importedRoot = ruleCSegments[0];
|
|
187
|
-
if (importedRoot === void 0 || NEUTRAL_LIB_IMPORT_ROOTS.has(importedRoot)) return null;
|
|
188
|
-
if (importedRoot === loc.context) return null;
|
|
189
|
-
if (PRODUCT_BOUNDED_CONTEXTS.has(importedRoot)) return `${fromAbsoluteFile}: Rule C (slice isolation) context "${loc.context}" must not import "${specifier}" — use #/lib/shared/... or neutral roots (core, config, infra) instead`;
|
|
190
|
-
return null;
|
|
191
|
-
}
|
|
192
|
-
const resolved = resolveRelativeSpecifier(fromAbsoluteFile, specifier);
|
|
193
|
-
if (resolved === null) return null;
|
|
194
|
-
const parts = libTailAfterResolution(resolved.endsWith(".ts") ? resolved : `${resolved}.ts`);
|
|
195
|
-
if (parts === null || parts.length === 0) return null;
|
|
196
|
-
const importedContext = parts[0];
|
|
197
|
-
if (importedContext === void 0 || NEUTRAL_LIB_IMPORT_ROOTS.has(importedContext)) return null;
|
|
198
|
-
if (importedContext === loc.context) return null;
|
|
199
|
-
if (PRODUCT_BOUNDED_CONTEXTS.has(importedContext)) return `${fromAbsoluteFile}: Rule C (slice isolation) context "${loc.context}" must not resolve into sibling slice via ${specifier}`;
|
|
200
|
-
return null;
|
|
201
|
-
}
|
|
202
|
-
function violationDomainLayer(loc, specifier, fromAbsoluteFile) {
|
|
203
|
-
const domainSegments = internalLibSpecifierSegmentList(specifier);
|
|
204
|
-
if (domainSegments !== null) {
|
|
205
|
-
const segments = domainSegments;
|
|
206
|
-
if (segments[0] === "infra") return `${fromAbsoluteFile}: domain must not import ${specifier}`;
|
|
207
|
-
const sharedLayerImported = sharedSourceCodeLayerFromSpecifier(segments);
|
|
208
|
-
if (sharedLayerImported !== null && sharedLayerImported !== "domain") return `${fromAbsoluteFile}: domain must not import ${specifier}`;
|
|
209
|
-
if (segments[0] === loc.context && segments.length >= 2) {
|
|
210
|
-
const importLayer = segments[1];
|
|
211
|
-
if (importLayer === "application" || importLayer === "infra" || importLayer === "presentation") return `${fromAbsoluteFile}: domain must not import ${specifier}`;
|
|
212
|
-
}
|
|
213
|
-
const segmentZero = segments[0];
|
|
214
|
-
if (segments.length >= 2 && segments[1] === "domain" && segmentZero !== void 0 && PRODUCT_BOUNDED_CONTEXTS.has(segmentZero) && segmentZero !== loc.context) return `${fromAbsoluteFile}: domain (${loc.context}) must not import other bounded context domain ${specifier}`;
|
|
215
|
-
if (loc.context === "core" && segments.length >= 2 && segments[1] === "domain" && segmentZero !== void 0 && PRODUCT_BOUNDED_CONTEXTS.has(segmentZero)) return `${fromAbsoluteFile}: core/domain must not import bounded context domain ${specifier}`;
|
|
216
|
-
if (loc.context === "config" && segments.length >= 2 && segments[1] === "domain" && segmentZero !== void 0 && PRODUCT_BOUNDED_CONTEXTS.has(segmentZero)) return `${fromAbsoluteFile}: config/domain must not import bounded context domain ${specifier}`;
|
|
217
|
-
if (loc.context === SHARED_SOURCE_CODE_CONTEXT && segmentZero !== void 0 && PRODUCT_BOUNDED_CONTEXTS.has(segmentZero)) return `${fromAbsoluteFile}: shared source-code domain must not import product context ${specifier}`;
|
|
218
|
-
return null;
|
|
219
|
-
}
|
|
220
|
-
const resolved = resolveRelativeSpecifier(fromAbsoluteFile, specifier);
|
|
221
|
-
if (resolved === null) return null;
|
|
222
|
-
const parts = libTailAfterResolution(resolved);
|
|
223
|
-
if (parts === null || parts.length < 2) return null;
|
|
224
|
-
const relCtx = parts[0];
|
|
225
|
-
const relLayer = parts[1];
|
|
226
|
-
if (relCtx === void 0 || relLayer === void 0) return null;
|
|
227
|
-
if (relCtx === "infra") return `${fromAbsoluteFile}: domain must not resolve into infra via ${specifier}`;
|
|
228
|
-
if (relCtx === loc.context) {
|
|
229
|
-
if (relLayer === "application" || relLayer === "infra" || relLayer === "presentation") return `${fromAbsoluteFile}: domain must not resolve into ${relLayer} via ${specifier}`;
|
|
230
|
-
}
|
|
231
|
-
if (relLayer === "domain" && PRODUCT_BOUNDED_CONTEXTS.has(relCtx) && relCtx !== loc.context) return `${fromAbsoluteFile}: domain must not resolve into other bounded context domain via ${specifier}`;
|
|
232
|
-
if (loc.context === "core" && relLayer === "domain" && PRODUCT_BOUNDED_CONTEXTS.has(relCtx)) return `${fromAbsoluteFile}: core/domain must not resolve into bounded context domain via ${specifier}`;
|
|
233
|
-
return null;
|
|
234
|
-
}
|
|
235
|
-
function violationApplicationLayer(loc, specifier, fromAbsoluteFile) {
|
|
236
|
-
const applicationSegments = internalLibSpecifierSegmentList(specifier);
|
|
237
|
-
if (applicationSegments !== null) {
|
|
238
|
-
const segments = applicationSegments;
|
|
239
|
-
if (segments[0] === "infra") return `${fromAbsoluteFile}: application must not import ${specifier}`;
|
|
240
|
-
const sharedLayerImported = sharedSourceCodeLayerFromSpecifier(segments);
|
|
241
|
-
if (sharedLayerImported === "infra" || sharedLayerImported === "presentation" || sharedLayerImported === "application") return `${fromAbsoluteFile}: application must not import ${specifier}`;
|
|
242
|
-
if (segments.length >= 2 && (segments[1] === "infra" || segments[1] === "presentation")) return `${fromAbsoluteFile}: application must not import ${specifier}`;
|
|
243
|
-
const appSegmentZero = segments[0];
|
|
244
|
-
if (PRODUCT_BOUNDED_CONTEXTS.has(loc.context) && appSegmentZero !== void 0 && PRODUCT_BOUNDED_CONTEXTS.has(appSegmentZero) && appSegmentZero !== loc.context) return `${fromAbsoluteFile}: application (${loc.context}) must not import ${specifier}`;
|
|
245
|
-
return null;
|
|
246
|
-
}
|
|
247
|
-
const resolved = resolveRelativeSpecifier(fromAbsoluteFile, specifier);
|
|
248
|
-
if (resolved === null) return null;
|
|
249
|
-
const parts = libTailAfterResolution(resolved);
|
|
250
|
-
if (parts === null || parts.length < 2) return null;
|
|
251
|
-
const relCtx = parts[0];
|
|
252
|
-
const relLayer = parts[1];
|
|
253
|
-
if (relCtx === void 0 || relLayer === void 0) return null;
|
|
254
|
-
if (relCtx === "infra") return `${fromAbsoluteFile}: application must not resolve into infra via ${specifier}`;
|
|
255
|
-
if (relLayer === "infra" || relLayer === "presentation") return `${fromAbsoluteFile}: application must not resolve into ${relLayer} via ${specifier}`;
|
|
256
|
-
return null;
|
|
257
|
-
}
|
|
258
|
-
/**
|
|
259
|
-
* Returns human-readable violations for a single file's imports (for unit tests and tooling).
|
|
260
|
-
*/ function violationsForFileContent(absoluteFilePath, loc, sourceText) {
|
|
261
|
-
const violations = [];
|
|
262
|
-
const fileBaseName = basenamePath(absoluteFilePath);
|
|
263
|
-
for (const specifier of extractImportSpecifiers(sourceText)) {
|
|
264
|
-
const ruleA = violationRuleAPureDomainModel(absoluteFilePath, fileBaseName, specifier);
|
|
265
|
-
if (ruleA !== null) violations.push(ruleA);
|
|
266
|
-
const ruleB = violationRuleBApplicationIsolation(loc, absoluteFilePath, specifier);
|
|
267
|
-
if (ruleB !== null) violations.push(ruleB);
|
|
268
|
-
const ruleC = violationRuleCAntiCrossSlice(loc, specifier, absoluteFilePath);
|
|
269
|
-
if (ruleC !== null) violations.push(ruleC);
|
|
270
|
-
if (loc.layer === "domain") {
|
|
271
|
-
const domainViolation = violationDomainLayer(loc, specifier, absoluteFilePath);
|
|
272
|
-
if (domainViolation !== null) violations.push(domainViolation);
|
|
273
|
-
} else if (loc.layer === "application") {
|
|
274
|
-
const appViolation = violationApplicationLayer(loc, specifier, absoluteFilePath);
|
|
275
|
-
if (appViolation !== null) violations.push(appViolation);
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
return [...new Set(violations)];
|
|
279
|
-
}
|
|
280
|
-
function walkNonTestTsFiles(rootDir, fs) {
|
|
281
|
-
const results = [];
|
|
282
|
-
const scan = (dir) => {
|
|
283
|
-
for (const entryName of fs.readdirSync(dir)) {
|
|
284
|
-
const full = joinPath(dir, entryName);
|
|
285
|
-
const stat = fs.statSync(full);
|
|
286
|
-
if (stat.isDirectory()) {
|
|
287
|
-
if (entryName === "node_modules" || entryName === "dist") continue;
|
|
288
|
-
scan(full);
|
|
289
|
-
} else if (stat.isFile() && entryName.endsWith(".ts")) {
|
|
290
|
-
if (!isArchitectureExcludedSourceFile(full)) results.push(full);
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
};
|
|
294
|
-
scan(rootDir);
|
|
295
|
-
return results;
|
|
296
|
-
}
|
|
297
|
-
/**
|
|
298
|
-
* Scans `packages/cli/src/lib` for explicit-architecture boundary violations (non-test sources only).
|
|
299
|
-
*/ function scanCliPackageArchitectureViolations(cliPackageRoot, fs, pathService) {
|
|
300
|
-
const libRoot = pathService.join(cliPackageRoot, "src", "lib");
|
|
301
|
-
const violations = [];
|
|
302
|
-
for (const absoluteFilePath of walkNonTestTsFiles(libRoot, fs)) {
|
|
303
|
-
const loc = parseLibSourceLocation(absoluteFilePath);
|
|
304
|
-
if (loc === null) continue;
|
|
305
|
-
const sourceText = fs.readFileSync(absoluteFilePath, "utf8");
|
|
306
|
-
violations.push(...violationsForFileContent(absoluteFilePath, loc, sourceText));
|
|
307
|
-
}
|
|
308
|
-
return violations;
|
|
309
|
-
}
|
|
310
|
-
//#endregion
|
|
311
|
-
export { extractImportSpecifiers, isArchitectureExcludedSourceFile, parseLibSourceLocation, scanCliPackageArchitectureViolations, violationsForFileContent };
|