@r0hitsharma/uikit-cli 0.12.0-rohit-fork-ci.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.
@@ -0,0 +1,518 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ /**
4
+ * Design-system recipe class-name stems. A design-system recipe emits classes
5
+ * of the form `${className}--variant_x` (recipe) or `${className}__slot` (slot
6
+ * recipe) — and *only* when the `designSystemStaticCssRecipes` spread is wired
7
+ * into the consumer's Panda `staticCss`, because components apply these as
8
+ * runtime strings that Panda's extractor cannot see. So the presence of any one
9
+ * of these stems is the signal that `staticCss` is wired.
10
+ *
11
+ * The gate is deliberately "**at least one** present", not "all": a consumer
12
+ * only ships CSS for the recipes it actually uses, and a legitimately narrowed
13
+ * `staticCss` map is valid (and encouraged, for bundle size). We flag only the
14
+ * total-omission case — the headline failure where nothing runtime-selected
15
+ * emits at all. This list therefore does not need to be exhaustive or perfectly
16
+ * in sync with the library; it just needs a few stems any real consumer emits.
17
+ */
18
+ export const DESIGN_SYSTEM_RECIPE_CLASSNAMES = [
19
+ 'badge',
20
+ 'button',
21
+ 'drawer',
22
+ 'dataTable',
23
+ 'panel',
24
+ 'statTile',
25
+ 'toggleSwitch',
26
+ 'segmentedControl',
27
+ 'surfaceMessage',
28
+ 'sidebarLayout',
29
+ ];
30
+ /** True if any design-system recipe class is present (i.e. staticCss is wired). */
31
+ function hasAnyRecipeClass(css) {
32
+ return DESIGN_SYSTEM_RECIPE_CLASSNAMES.some((name) => css.includes(`.${name}--`) || css.includes(`.${name}__`));
33
+ }
34
+ /**
35
+ * Color-ish CSS properties whose value, if written as a bare `token.path`,
36
+ * means an unresolved semantic token: Panda emits the path verbatim (e.g.
37
+ * `color: text.subtle;`) which the browser silently drops. `var(...)`, hex,
38
+ * numeric and keyword values never contain a dotted lowercase identifier, so a
39
+ * dotted value on one of these properties is the tell.
40
+ */
41
+ const UNRESOLVED_TOKEN_DECL = /(color|background|background-color|border-color|fill|stroke|outline-color)\s*:\s*([a-z][\w-]*(?:\.[a-z0-9][\w-]*)+)\s*[;}]/gi;
42
+ /**
43
+ * ── Roleless `colorPalette` detection ──
44
+ *
45
+ * Panda compiles `colorPalette: 'violet'` into a ruleset that *remaps* the
46
+ * generic palette custom properties onto that hue's tokens:
47
+ *
48
+ * .alert--colorPalette_violet {
49
+ * --colors-color-palette-50: var(--colors-violet-50); … 100…950
50
+ * }
51
+ *
52
+ * It emits one line per token that actually exists under the hue. So a hue with
53
+ * only the 50–950 scale (any Panda default hue the preset never gave role
54
+ * sub-tokens) maps the scale and nothing else, while a role-complete palette
55
+ * (`neutral`/`gray`/`green`/`red`/`amber`/`blue` in this design system) also maps
56
+ * `--colors-color-palette-solid-bg`, `-subtle-fg`, `-outline-border`, and the rest.
57
+ *
58
+ * Meanwhile a recipe that styles with a role emits the *reference* side:
59
+ *
60
+ * .alert--emphasis_solid { background: var(--colors-color-palette-solid-bg); }
61
+ *
62
+ * Pair a roleless palette with a role reference and the custom property is
63
+ * undefined in that scope: well-formed CSS, valid `var()` syntax, and the browser
64
+ * silently drops the declaration. Nothing else in doctor catches it — the token
65
+ * path resolved, and `staticCss` is wired.
66
+ *
67
+ * The stylesheet is therefore its own palette→roles map: the assignment rulesets
68
+ * ARE the preset's role tokens in generated form, which is why this check reads
69
+ * them out of the CSS rather than importing a table from the design system. A
70
+ * palette added to the preset, or one a consumer defines in their own preset
71
+ * extension, is covered the moment it appears in the CSS — with no list here to
72
+ * keep in sync, and no dependency from this CLI on the design-system package.
73
+ *
74
+ * DETECTION BOUNDARY. Resolving which palette is in scope for a declaration is
75
+ * the CSS cascade, and doctor does not simulate it. It resolves exactly one
76
+ * scope — a **recipe**, keyed by the class-name stem Panda derives from
77
+ * `className` — and reports a role reference only when a palette assigned
78
+ * anywhere in that same recipe's classes fails to define the role. Slots count
79
+ * as the same scope (`.chip__root--colorPalette_x` sets the properties; the
80
+ * `.chip__dismiss` slot inherits them), and a reference is attributed to the
81
+ * *rightmost* compound selector, the element the rule actually styles. Out of
82
+ * scope, deliberately, and reported as clean:
83
+ *
84
+ * - Atomic utilities (`.color-palette_violet` + `.bg_colorPalette\.solid\.bg`).
85
+ * Two unrelated classes; whether they land on the same element, and whether an
86
+ * ancestor already supplied the role, is not knowable from the stylesheet.
87
+ * - Cross-recipe cascade: a palette set on an outer recipe, a role consumed by an
88
+ * inner one.
89
+ * - `var(--colors-color-palette-…, fallback)`. A fallback means the declaration
90
+ * survives, so it is not a silent drop — the regex below requires a bare
91
+ * reference and skips these by construction.
92
+ *
93
+ * Those are false negatives by design. The check must never fail a healthy
94
+ * stylesheet, so every case where scope is ambiguous resolves to "no issue".
95
+ */
96
+ /**
97
+ * Leaf rulesets — `selector { declarations }` with no nested braces. Declarations
98
+ * only ever live in leaves, so this is enough to read a stylesheet without a real
99
+ * CSS parser, and it sees through `@layer`/`@media` wrappers for free.
100
+ */
101
+ const LEAF_RULESET = /([^{}]+)\{([^{}]*)\}/g;
102
+ /**
103
+ * A design-system recipe class: `.stem--variant_value`, `.stem__slot`, or
104
+ * `.stem__slot--variant_value`. Requires the `__`/`--` shape so Panda's atomic
105
+ * utilities (`.bg_surface`, `.c_text\.muted`) can never be mistaken for a recipe
106
+ * scope named after their property.
107
+ */
108
+ const RECIPE_CLASS = /\.([A-Za-z][A-Za-z0-9]*)(?:__[A-Za-z0-9-]+(?:--[A-Za-z0-9_-]+)?|--[A-Za-z0-9_-]+)/g;
109
+ /** A class with no variant/slot suffix — a recipe's base rule, e.g. `.button`. */
110
+ const BARE_CLASS = /\.([A-Za-z][A-Za-z0-9]*)(?![\w-])/g;
111
+ /** `--colors-color-palette-<role>: var(--colors-<palette>-<role>)`. */
112
+ const PALETTE_ROLE_ASSIGNMENT = /--colors-color-palette-([a-z0-9-]+)\s*:\s*var\(\s*--colors-([a-z0-9-]+)\s*\)/gi;
113
+ /** A bare `var(--colors-color-palette-<role>)` — no fallback (see boundary above). */
114
+ const PALETTE_ROLE_REFERENCE = /var\(\s*--colors-color-palette-([a-z0-9-]+)\s*\)/gi;
115
+ function splitLeafRulesets(css) {
116
+ const leaves = [];
117
+ for (const match of css.matchAll(LEAF_RULESET)) {
118
+ // `match[1]` runs from just after the previous `}`, so it can carry leading
119
+ // blank lines. Anchor on the `{` instead — a body offset added to the match
120
+ // start would report a line too early by however many newlines precede the
121
+ // selector.
122
+ const [whole, selector, body] = match;
123
+ // Both groups are non-optional in LEAF_RULESET, so a match always carries
124
+ // them; the guard is what tells the compiler that.
125
+ if (selector === undefined || body === undefined)
126
+ continue;
127
+ leaves.push({
128
+ selector: selector.trim(),
129
+ body,
130
+ bodyIndex: (match.index ?? 0) + whole.indexOf('{') + 1,
131
+ });
132
+ }
133
+ return leaves;
134
+ }
135
+ /** Every recipe stem the stylesheet mentions with a slot or variant suffix. */
136
+ function collectRecipeStems(leaves) {
137
+ const stems = new Set();
138
+ for (const leaf of leaves) {
139
+ for (const match of leaf.selector.matchAll(RECIPE_CLASS)) {
140
+ const stem = match[1];
141
+ if (stem !== undefined)
142
+ stems.add(stem);
143
+ }
144
+ }
145
+ return stems;
146
+ }
147
+ /**
148
+ * Split a selector list on top-level commas only. A comma inside a functional
149
+ * pseudo-class (`:is(.a--x, .b--y)`, `:not(…)`, `:where(…)`) separates that
150
+ * pseudo-class's arguments, not selectors — splitting there yields fragments
151
+ * that are not selectors at all (`.badge--variant_solid:is(.a--x`), whose
152
+ * rightmost recipe class is an argument rather than the styled element, so the
153
+ * reference is attributed to a scope that does not exist. A backslash escapes
154
+ * the next character, since Panda escapes parens inside class names
155
+ * (`.w_calc\(100\%\)`).
156
+ */
157
+ function splitSelectorList(selectorList) {
158
+ const selectors = [];
159
+ let depth = 0;
160
+ let start = 0;
161
+ for (let i = 0; i < selectorList.length; i++) {
162
+ const char = selectorList[i];
163
+ if (char === '\\')
164
+ i++;
165
+ else if (char === '(')
166
+ depth++;
167
+ else if (char === ')')
168
+ depth = Math.max(0, depth - 1);
169
+ else if (char === ',' && depth === 0) {
170
+ selectors.push(selectorList.slice(start, i));
171
+ start = i + 1;
172
+ }
173
+ }
174
+ selectors.push(selectorList.slice(start));
175
+ return selectors;
176
+ }
177
+ /**
178
+ * Blank out functional-pseudo argument lists before the subject is read. Their
179
+ * contents narrow *when* the rule matches; the class naming the element it
180
+ * styles is always written outside the parens, so an argument must never win the
181
+ * scope. They also carry commas and whitespace that would otherwise read as
182
+ * selector-list and descendant separators.
183
+ */
184
+ function stripPseudoArguments(selector) {
185
+ let stripped = selector;
186
+ let previous;
187
+ do {
188
+ previous = stripped;
189
+ stripped = stripped.replace(/(?<!\\)\([^()]*\)/g, '');
190
+ } while (stripped !== previous);
191
+ return stripped;
192
+ }
193
+ /**
194
+ * The recipe stem of the element a selector actually styles: its rightmost
195
+ * compound. `.dark .toggleSwitch__thumb` scopes to `toggleSwitch`, not to
196
+ * whatever the ancestors are — attributing a reference to an ancestor's recipe
197
+ * would invent a scope the declaration never had.
198
+ */
199
+ function subjectRecipeStem(selector, stems) {
200
+ const compounds = stripPseudoArguments(selector)
201
+ .split(/[\s>+~]+/)
202
+ .filter(Boolean);
203
+ for (let i = compounds.length - 1; i >= 0; i--) {
204
+ const compound = compounds[i];
205
+ if (compound === undefined)
206
+ continue;
207
+ const recipeClasses = [...compound.matchAll(RECIPE_CLASS)];
208
+ const last = recipeClasses.at(-1)?.[1];
209
+ if (last !== undefined)
210
+ return last;
211
+ // A recipe's base rule (`.button { … }`) carries no suffix, so accept a bare
212
+ // class only when the stylesheet proves elsewhere that it is a recipe.
213
+ for (const match of [...compound.matchAll(BARE_CLASS)].reverse()) {
214
+ const bare = match[1];
215
+ if (bare !== undefined && stems.has(bare))
216
+ return bare;
217
+ }
218
+ }
219
+ return null;
220
+ }
221
+ /**
222
+ * The palette a ruleset assigns, plus the roles it maps. Panda writes the palette
223
+ * name into the *value* (`var(--colors-violet-solid-bg)`), which is read here
224
+ * rather than parsed out of the selector so an atomic `.color-palette_violet` and
225
+ * a recipe variant are handled by the same code.
226
+ */
227
+ function readPaletteAssignment(body) {
228
+ let palette = null;
229
+ const roles = new Set();
230
+ for (const match of body.matchAll(PALETTE_ROLE_ASSIGNMENT)) {
231
+ const [, role, target] = match;
232
+ if (role === undefined || target === undefined)
233
+ continue;
234
+ if (!target.endsWith(`-${role}`))
235
+ continue;
236
+ const name = target.slice(0, -(role.length + 1));
237
+ palette ??= name;
238
+ if (name === palette)
239
+ roles.add(role);
240
+ }
241
+ return palette ? { palette, roles } : null;
242
+ }
243
+ function scopeFor(scopes, stem) {
244
+ let scope = scopes.get(stem);
245
+ if (!scope) {
246
+ scope = { palettes: new Map(), references: new Map() };
247
+ scopes.set(stem, scope);
248
+ }
249
+ return scope;
250
+ }
251
+ function collectPaletteScopes(leaves) {
252
+ const stems = collectRecipeStems(leaves);
253
+ const scopes = new Map();
254
+ for (const leaf of leaves) {
255
+ const assignment = readPaletteAssignment(leaf.body);
256
+ const references = [...leaf.body.matchAll(PALETTE_ROLE_REFERENCE)];
257
+ if (!assignment && references.length === 0)
258
+ continue;
259
+ for (const rawSelector of splitSelectorList(leaf.selector)) {
260
+ const selector = rawSelector.trim();
261
+ const stem = subjectRecipeStem(selector, stems);
262
+ if (!stem)
263
+ continue; // unknown scope — see DETECTION BOUNDARY
264
+ const scope = scopeFor(scopes, stem);
265
+ if (assignment) {
266
+ const known = scope.palettes.get(assignment.palette) ?? new Set();
267
+ for (const role of assignment.roles)
268
+ known.add(role);
269
+ scope.palettes.set(assignment.palette, known);
270
+ }
271
+ for (const match of references) {
272
+ const role = match[1];
273
+ if (role === undefined)
274
+ continue;
275
+ if (scope.references.has(role))
276
+ continue;
277
+ scope.references.set(role, {
278
+ selector,
279
+ index: leaf.bodyIndex + (match.index ?? 0),
280
+ });
281
+ }
282
+ }
283
+ }
284
+ return scopes;
285
+ }
286
+ function lineOf(css, index) {
287
+ return css.slice(0, index).split('\n').length;
288
+ }
289
+ /**
290
+ * Role references whose scope can assign a palette that does not define them.
291
+ *
292
+ * SEVERITY. A miss is an `error` only when it is definite — when *every* palette
293
+ * the scope can be set to lacks the role, so no combination of that scope's
294
+ * variants avoids the dropped declaration. The one-assignable-palette case is
295
+ * that condition at its smallest: the palette is the only one there is.
296
+ *
297
+ * When some assignable palette does define the role, the pairing is merely
298
+ * possible, and the stylesheet cannot say whether an app ever makes it: a recipe
299
+ * exposing `colorPalette: violet` alongside a `solid` emphasis variant may never
300
+ * combine the two. Reported as a `warn` — worth printing, since a real
301
+ * combination is silently dropped CSS, but not worth failing a build over an
302
+ * unproven one.
303
+ */
304
+ function findRolelessPalettes(css, leaves) {
305
+ const issues = [];
306
+ for (const [scopeName, scope] of collectPaletteScopes(leaves)) {
307
+ for (const [role, site] of scope.references) {
308
+ const missing = [...scope.palettes]
309
+ .filter(([, roles]) => !roles.has(role))
310
+ .map(([palette]) => palette);
311
+ const definite = missing.length === scope.palettes.size;
312
+ for (const palette of missing) {
313
+ issues.push({
314
+ kind: 'roleless-color-palette',
315
+ severity: definite ? 'error' : 'warn',
316
+ line: lineOf(css, site.index),
317
+ scope: scopeName,
318
+ selector: site.selector,
319
+ palette,
320
+ role,
321
+ });
322
+ }
323
+ }
324
+ }
325
+ return issues;
326
+ }
327
+ /**
328
+ * Scan a generated Panda stylesheet for the silently-dropped-CSS failure class:
329
+ * recipe variants that were never emitted (missing `staticCss`), semantic tokens
330
+ * that resolved to a bare path (invalid declaration), and roleless `colorPalette`
331
+ * values (valid `var()`, undefined in scope). Pure so it can be unit-tested
332
+ * without a filesystem.
333
+ */
334
+ export function scanGeneratedCss(css) {
335
+ const issues = [];
336
+ if (!hasAnyRecipeClass(css)) {
337
+ issues.push({ kind: 'missing-static-css', severity: 'error' });
338
+ }
339
+ const seen = new Set();
340
+ for (const match of css.matchAll(UNRESOLVED_TOKEN_DECL)) {
341
+ const index = match.index ?? 0;
342
+ const line = css.slice(0, index).split('\n').length;
343
+ const declaration = `${match[1]}: ${match[2]}`;
344
+ const key = `${line}:${declaration}`;
345
+ if (seen.has(key))
346
+ continue;
347
+ seen.add(key);
348
+ issues.push({
349
+ kind: 'unresolved-token',
350
+ severity: 'error',
351
+ line,
352
+ declaration,
353
+ });
354
+ }
355
+ issues.push(...findRolelessPalettes(css, splitLeafRulesets(css)));
356
+ return {
357
+ ok: issues.every((issue) => issue.severity !== 'error'),
358
+ issues,
359
+ };
360
+ }
361
+ function missingStaticCssMessage(relative) {
362
+ return (`No design-system recipe classes found in ${relative}.\n` +
363
+ " The design system's recipes are applied by class name, so Panda's\n" +
364
+ ' static extractor cannot see them — without `staticCss` they emit\n' +
365
+ ' nothing, and runtime-selected variants (status tones, dense tables,\n' +
366
+ ' drawer sizes) render unstyled. Spread the exported map into your\n' +
367
+ ' Panda config `staticCss`:\n' +
368
+ " import { designSystemStaticCssRecipes } from '@r0hitsharma/design-system/recipes';\n" +
369
+ ' staticCss: { recipes: { ...designSystemStaticCssRecipes } }\n' +
370
+ ' then re-run `panda codegen`. (A narrowed subset is fine — this only\n' +
371
+ ' flags the case where nothing is wired at all.)');
372
+ }
373
+ function unresolvedTokenMessage(issue, relative) {
374
+ return (`${relative}:${issue.line} unresolved token — \`${issue.declaration};\` ` +
375
+ 'is an invalid declaration the browser drops. The token path does not ' +
376
+ 'resolve to a `var(--…)`; check the token exists in the preset (or you ' +
377
+ 'passed a token path where a finished value was expected).');
378
+ }
379
+ function rolelessColorPaletteMessage(issue, relative) {
380
+ return (`${relative}:${issue.line} roleless colorPalette — \`${issue.selector}\` ` +
381
+ `reads \`var(--colors-color-palette-${issue.role})\`, but the ` +
382
+ `\`${issue.scope}\` scope can be set to \`colorPalette: ${issue.palette}\`, ` +
383
+ `which defines no \`${issue.role}\` role. The custom property is then ` +
384
+ 'undefined on that element: valid CSS the browser silently drops, so the ' +
385
+ 'declaration just does not apply. ' +
386
+ (issue.severity === 'error'
387
+ ? 'No palette this scope can take defines that role, so nothing avoids it. '
388
+ : `Warning only: \`${issue.scope}\` can also take a role-complete palette, ` +
389
+ 'so this breaks only if the two are actually combined. ') +
390
+ 'Either style with a role-complete palette (one whose ruleset maps ' +
391
+ `\`--colors-color-palette-${issue.role}\`), give \`${issue.palette}\` that ` +
392
+ "role in your preset's colorPalette tokens, or reference a role the " +
393
+ 'palette does define.');
394
+ }
395
+ /**
396
+ * The console message for one finding. Exhaustive over `DoctorIssue` with no
397
+ * default branch, so adding a `kind` without a message fails to compile.
398
+ */
399
+ function doctorIssueMessage(issue, relative) {
400
+ switch (issue.kind) {
401
+ case 'missing-static-css':
402
+ return missingStaticCssMessage(relative);
403
+ case 'unresolved-token':
404
+ return unresolvedTokenMessage(issue, relative);
405
+ case 'roleless-color-palette':
406
+ return rolelessColorPaletteMessage(issue, relative);
407
+ }
408
+ }
409
+ /**
410
+ * Locations a Panda project commonly writes `styles.css`, relative to the
411
+ * consumer root. First existing wins; an explicit path always takes precedence.
412
+ */
413
+ const CSS_CANDIDATES = [
414
+ 'styled-system/styles.css',
415
+ 'src/styled-system/styles.css',
416
+ 'app/styled-system/styles.css',
417
+ ];
418
+ /**
419
+ * `uikit-cli doctor [path-to-styles.css] [--codegen]` — fails loudly on the
420
+ * silent CSS failures the library's authoring model otherwise hides.
421
+ *
422
+ * Point it at a generated stylesheet, or pass `--codegen` and it runs
423
+ * `panda cssgen --outfile` itself into a temp file — the mode PostCSS-plugin
424
+ * consumers need, since they never write a frozen `styled-system/styles.css`.
425
+ */
426
+ export class DoctorCommand {
427
+ fs;
428
+ logger;
429
+ executor;
430
+ constructor(fs, logger, executor) {
431
+ this.fs = fs;
432
+ this.logger = logger;
433
+ this.executor = executor;
434
+ }
435
+ /**
436
+ * Generate a full stylesheet from the consumer's Panda config into a temp
437
+ * file (for PostCSS-plugin consumers with no frozen styles.css). Returns the
438
+ * temp path, or null if codegen failed.
439
+ */
440
+ runCodegen(cwd) {
441
+ const outfile = path.join(os.tmpdir(), `uikit-doctor-${process.pid}.css`);
442
+ const command = `npx panda cssgen --outfile "${outfile}"`;
443
+ const result = this.executor.exec(command, { cwd, silent: true });
444
+ if (!result.success || !this.fs.exists(outfile)) {
445
+ this.logger.error(`Could not generate CSS with \`${command}\`.\n` +
446
+ ' Ensure @pandacss/dev is installed and a panda config is present in\n' +
447
+ ` ${cwd}. Underlying error:\n` +
448
+ ` ${(result.stderr || 'panda produced no output file').trim()}`);
449
+ return null;
450
+ }
451
+ return outfile;
452
+ }
453
+ resolveCssPath(args, cwd) {
454
+ const explicit = args.find((arg) => !arg.startsWith('-'));
455
+ if (explicit) {
456
+ const resolved = path.resolve(cwd, explicit);
457
+ return this.fs.exists(resolved) ? resolved : null;
458
+ }
459
+ for (const candidate of CSS_CANDIDATES) {
460
+ const resolved = path.resolve(cwd, candidate);
461
+ if (this.fs.exists(resolved))
462
+ return resolved;
463
+ }
464
+ return null;
465
+ }
466
+ /** Returns true when the stylesheet is healthy. */
467
+ execute(args, cwd = process.cwd()) {
468
+ const useCodegen = args.includes('--codegen');
469
+ let cssPath;
470
+ let temp = false;
471
+ if (useCodegen) {
472
+ cssPath = this.runCodegen(cwd);
473
+ if (!cssPath)
474
+ return false; // runCodegen already logged
475
+ temp = true;
476
+ }
477
+ else {
478
+ cssPath = this.resolveCssPath(args, cwd);
479
+ if (!cssPath) {
480
+ this.logger.error('Could not find a generated Panda stylesheet.\n' +
481
+ `Looked for: ${CSS_CANDIDATES.join(', ')} (relative to ${cwd}).\n` +
482
+ 'Options:\n' +
483
+ ' - run your `panda codegen` first, then re-run doctor;\n' +
484
+ ' - pass the path explicitly: uikit-cli doctor path/to/styles.css;\n' +
485
+ ' - if you use the Panda PostCSS plugin (no frozen styles.css),\n' +
486
+ ' pass --codegen and doctor will run `panda cssgen` itself.');
487
+ return false;
488
+ }
489
+ }
490
+ const css = this.fs.readFile(cssPath);
491
+ if (temp)
492
+ this.fs.removeDir(cssPath, { force: true });
493
+ const report = scanGeneratedCss(css);
494
+ const relative = temp
495
+ ? '(panda cssgen)'
496
+ : path.relative(cwd, cssPath) || cssPath;
497
+ if (report.issues.length === 0) {
498
+ this.logger.info(`✓ ${relative}: no silently-dropped CSS detected.`);
499
+ return true;
500
+ }
501
+ for (const issue of report.issues) {
502
+ const message = doctorIssueMessage(issue, relative);
503
+ if (issue.severity === 'error')
504
+ this.logger.error(message);
505
+ else
506
+ this.logger.warn(message);
507
+ }
508
+ if (report.ok) {
509
+ // Only warnings were printed: say so rather than claiming a clean sheet.
510
+ const count = report.issues.length;
511
+ const summary = count === 1
512
+ ? 'The warning above is an unproven combination, so it does not fail'
513
+ : `The ${count} warnings above are unproven combinations, so they do not fail`;
514
+ this.logger.info(`✓ ${relative}: nothing definitely dropped. ${summary} the check.`);
515
+ }
516
+ return report.ok;
517
+ }
518
+ }
@@ -0,0 +1,34 @@
1
+ import path from 'node:path';
2
+ import { shellEscape } from '../shell-utils.js';
3
+ import { resolveCliBinary } from '../tool-binaries.js';
4
+ /**
5
+ * Format command - forwards to oxfmt with config detection
6
+ */
7
+ export class FormatCommand {
8
+ executor;
9
+ fs;
10
+ constructor(executor, fs) {
11
+ this.executor = executor;
12
+ this.fs = fs;
13
+ }
14
+ execute(args) {
15
+ const modifiedArgs = [...args];
16
+ const defaultConfig = './.oxfmtrc.ts';
17
+ // Add default config if not specified and file exists
18
+ if (!this.hasConfigFlag(modifiedArgs)) {
19
+ const configPath = path.join(process.cwd(), '.oxfmtrc.ts');
20
+ if (this.fs.exists(configPath)) {
21
+ modifiedArgs.unshift(defaultConfig);
22
+ modifiedArgs.unshift('-c');
23
+ }
24
+ }
25
+ const escapedArgs = modifiedArgs.map((arg) => shellEscape(arg)).join(' ');
26
+ const oxfmtBinary = shellEscape(resolveCliBinary('oxfmt', 'bin/oxfmt'));
27
+ return this.executor.exec(`${oxfmtBinary} ${escapedArgs}`.trim(), {
28
+ cwd: process.cwd(),
29
+ }).success;
30
+ }
31
+ hasConfigFlag(args) {
32
+ return args.some((arg) => arg === '-c' || arg === '--config' || arg.startsWith('--config='));
33
+ }
34
+ }