inkbridge 0.1.0-beta.20 → 0.1.0-beta.21
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 +2 -1
- package/bin/inkbridge.mjs +64 -9
- package/code.js +11 -11
- package/package.json +8 -2
- package/scanner/adapter-utils-regression.ts +159 -0
- package/scanner/component-scanner.ts +276 -19
- package/scanner/font-family-extract-regression.ts +113 -0
- package/scanner/framework-adapter-shadcn-regression.ts +96 -1
- package/scanner/grid-cols-extraction-regression.ts +110 -0
- package/scanner/input-range-regression.ts +217 -0
- package/scanner/jsx-prop-unresolved-regression.ts +178 -0
- package/scanner/local-const-className-regression.ts +331 -0
- package/scanner/ring-utility-regression.ts +25 -4
- package/scanner/state-classification-regression.ts +38 -0
- package/scanner/stretch-to-parent-width-regression.ts +35 -1
- package/scanner/tailwind-parser.ts +38 -2
- package/src/components/component-gen.ts +11 -151
- package/src/design-system/cva-master.ts +7 -3
- package/src/design-system/design-system.ts +8 -0
- package/src/design-system/node-helpers.ts +15 -1
- package/src/design-system/preview-builder.ts +14 -45
- package/src/design-system/state-master.ts +23 -1
- package/src/design-system/story-builder.ts +55 -5
- package/src/design-system/ui-builder.ts +116 -6
- package/src/framework-adapters/index.ts +15 -2
- package/src/framework-adapters/shadcn.ts +83 -67
- package/src/layout/deferred-layout.ts +187 -1
- package/src/layout/layout-utils.ts +2 -1
- package/src/layout/ring-utils.ts +31 -82
- package/src/render-engine-version.ts +1 -1
- package/src/tailwind/adapter-utils.ts +137 -0
- package/src/tailwind/jsx-utils.ts +9 -0
- package/src/tailwind/node-ir.ts +172 -0
- package/src/tailwind/tailwind.ts +23 -16
- package/src/tokens/tokens.ts +11 -3
- package/templates/scan-components-route.ts +11 -1
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { ComponentScanner } from './component-scanner';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Regression: when a JSX attribute reads an unresolved identifier (e.g. a
|
|
7
|
+
* function parameter with no default), the scanner must OMIT the prop
|
|
8
|
+
* rather than serialise the identifier's source text as the value.
|
|
9
|
+
*
|
|
10
|
+
* History: DropdownMenuItem's body has
|
|
11
|
+
*
|
|
12
|
+
* <DropdownMenuPrimitive.Item data-inset={inset} ... />
|
|
13
|
+
*
|
|
14
|
+
* where `inset?: boolean` is destructured from props with no default. In
|
|
15
|
+
* the Default story the consumer never passes `inset`, so its runtime
|
|
16
|
+
* value is `undefined` — React would render NO `data-inset` attribute.
|
|
17
|
+
*
|
|
18
|
+
* The scanner used to fall back to `expr.getText()` when an expression
|
|
19
|
+
* couldn't be resolved, which serialised the identifier name `"inset"`
|
|
20
|
+
* as the attribute value. The plugin's variant engine then treated
|
|
21
|
+
* `props["data-inset"] === "inset"` as "data-inset is present", which
|
|
22
|
+
* activated every `data-[inset]:` utility. The visible symptom: every
|
|
23
|
+
* DropdownMenuItem got `data-[inset]:pl-8` (32px left padding) even when
|
|
24
|
+
* no item was `inset`, leaving them indented like checkbox/radio items.
|
|
25
|
+
*
|
|
26
|
+
* The fix: omit the prop when the expression doesn't resolve. Matches
|
|
27
|
+
* React's runtime behaviour for `data-x={undefined}` — no attribute is
|
|
28
|
+
* emitted, so no variant predicate matches.
|
|
29
|
+
*
|
|
30
|
+
* This file locks the contract at the boundary the variant engine reads
|
|
31
|
+
* from — the props object on the JSX node.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
interface JsxNodeLike {
|
|
35
|
+
type: 'element' | 'text';
|
|
36
|
+
tagName?: string;
|
|
37
|
+
props?: Record<string, unknown>;
|
|
38
|
+
children?: JsxNodeLike[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface TestScannerView {
|
|
42
|
+
project: import('ts-morph').Project;
|
|
43
|
+
extractComponentJsxTree: (
|
|
44
|
+
sourceFile: import('ts-morph').SourceFile,
|
|
45
|
+
componentName: string
|
|
46
|
+
) => JsxNodeLike | null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function makeScanner(): TestScannerView {
|
|
50
|
+
return new ComponentScanner({
|
|
51
|
+
componentPaths: [],
|
|
52
|
+
filePattern: '*.tsx',
|
|
53
|
+
exclude: [],
|
|
54
|
+
}) as unknown as TestScannerView;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function fixturePath(relative: string): string {
|
|
58
|
+
return path.resolve(process.cwd(), 'tools/figma-plugin/scanner/__fixtures__', relative);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function findElement(node: JsxNodeLike | null, tag: string): JsxNodeLike | null {
|
|
62
|
+
if (!node || node.type !== 'element') return null;
|
|
63
|
+
if (node.tagName === tag) return node;
|
|
64
|
+
for (const child of node.children || []) {
|
|
65
|
+
const found = findElement(child, tag);
|
|
66
|
+
if (found) return found;
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const scanner = makeScanner();
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// Case 1: unresolved boolean-prop identifier — the DropdownMenuItem pattern.
|
|
75
|
+
// `inset` is a destructured parameter with no default. The data-attribute
|
|
76
|
+
// expression `data-inset={inset}` MUST be omitted, not serialised as
|
|
77
|
+
// "inset".
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
{
|
|
80
|
+
const file = scanner.project.createSourceFile(
|
|
81
|
+
fixturePath('unresolved-data-attr.tsx'),
|
|
82
|
+
`
|
|
83
|
+
// The className would normally include "data-[inset]:pl-8" — omitted
|
|
84
|
+
// from this fixture because the regression asserts on the props map,
|
|
85
|
+
// not on class extraction, and inlining the class string trips the
|
|
86
|
+
// workspace lint (suggestCanonicalClasses) for what is intentionally
|
|
87
|
+
// a verbatim shadcn-shaped fixture.
|
|
88
|
+
export function DropdownMenuItem({ inset }: { inset?: boolean }) {
|
|
89
|
+
return (
|
|
90
|
+
<div
|
|
91
|
+
data-slot="dropdown-menu-item"
|
|
92
|
+
data-inset={inset}
|
|
93
|
+
data-variant="default"
|
|
94
|
+
/>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
`,
|
|
98
|
+
{ overwrite: true }
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
const tree = scanner.extractComponentJsxTree(file, 'DropdownMenuItem');
|
|
102
|
+
assert.ok(tree, 'DropdownMenuItem must produce a tree');
|
|
103
|
+
const root = findElement(tree, 'div');
|
|
104
|
+
assert.ok(root, 'must find the root <div>');
|
|
105
|
+
|
|
106
|
+
// Literal data-* attrs survive.
|
|
107
|
+
assert.equal(root.props?.['data-slot'], 'dropdown-menu-item', 'literal data-slot preserved');
|
|
108
|
+
assert.equal(root.props?.['data-variant'], 'default', 'literal data-variant preserved');
|
|
109
|
+
|
|
110
|
+
// The unresolved expression must NOT have been serialised as the
|
|
111
|
+
// identifier name. Either the key is absent, or the value is some
|
|
112
|
+
// explicit "absent" sentinel — never the identifier text "inset".
|
|
113
|
+
const insetValue = root.props?.['data-inset'];
|
|
114
|
+
assert.notEqual(
|
|
115
|
+
insetValue,
|
|
116
|
+
'inset',
|
|
117
|
+
"data-inset must NOT serialise to the unresolved identifier's source text"
|
|
118
|
+
);
|
|
119
|
+
// Stronger contract: the key should be absent. React renders no
|
|
120
|
+
// attribute for `data-x={undefined}`, and that's what the variant
|
|
121
|
+
// engine needs to see for the predicate to NOT match.
|
|
122
|
+
assert.ok(
|
|
123
|
+
!Object.prototype.hasOwnProperty.call(root.props || {}, 'data-inset'),
|
|
124
|
+
'data-inset key must be omitted when the value expression is unresolved'
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
// Case 2: unresolved string-prop identifier — same rule, different shape.
|
|
130
|
+
// `<input placeholder={placeholder}>` where `placeholder` is unresolved
|
|
131
|
+
// shouldn't end up with `placeholder="placeholder"` (the identifier name)
|
|
132
|
+
// in the rendered tree.
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
{
|
|
135
|
+
const file = scanner.project.createSourceFile(
|
|
136
|
+
fixturePath('unresolved-string-attr.tsx'),
|
|
137
|
+
`
|
|
138
|
+
export function Field({ placeholder }: { placeholder?: string }) {
|
|
139
|
+
return <input placeholder={placeholder} />;
|
|
140
|
+
}
|
|
141
|
+
`,
|
|
142
|
+
{ overwrite: true }
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
const tree = scanner.extractComponentJsxTree(file, 'Field');
|
|
146
|
+
assert.ok(tree, 'Field must produce a tree');
|
|
147
|
+
const input = findElement(tree, 'input');
|
|
148
|
+
assert.ok(input, 'must find <input>');
|
|
149
|
+
assert.ok(
|
|
150
|
+
!Object.prototype.hasOwnProperty.call(input.props || {}, 'placeholder'),
|
|
151
|
+
'placeholder key must be omitted when the value identifier is unresolved'
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
// Case 3: literal string attribute survives — sanity check that this fix
|
|
157
|
+
// only suppresses *unresolved* expressions, not the literal-attr path.
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
{
|
|
160
|
+
const file = scanner.project.createSourceFile(
|
|
161
|
+
fixturePath('literal-attr.tsx'),
|
|
162
|
+
`
|
|
163
|
+
export function LiteralAttr() {
|
|
164
|
+
return <div data-variant="primary" data-slot="root" />;
|
|
165
|
+
}
|
|
166
|
+
`,
|
|
167
|
+
{ overwrite: true }
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
const tree = scanner.extractComponentJsxTree(file, 'LiteralAttr');
|
|
171
|
+
assert.ok(tree, 'LiteralAttr must produce a tree');
|
|
172
|
+
const div = findElement(tree, 'div');
|
|
173
|
+
assert.ok(div, 'must find <div>');
|
|
174
|
+
assert.equal(div.props?.['data-variant'], 'primary', 'literal data-variant survives');
|
|
175
|
+
assert.equal(div.props?.['data-slot'], 'root', 'literal data-slot survives');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
console.log('jsx-prop-unresolved-regression: PASS (3 cases)');
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { ComponentScanner } from './component-scanner';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Regression: function-body-local `const` declarations whose initializer
|
|
7
|
+
* depends on a prop value must be resolved when the identifier is
|
|
8
|
+
* referenced in a child JSX `className`. Without this, the common pattern
|
|
9
|
+
*
|
|
10
|
+
* const StatusPill = ({ sourceLabel }) => {
|
|
11
|
+
* const dotClass =
|
|
12
|
+
* sourceLabel === "db" ? "bg-emerald-500"
|
|
13
|
+
* : sourceLabel === "live" ? "bg-sky-400"
|
|
14
|
+
* : "bg-muted-foreground";
|
|
15
|
+
* return <span className={cn("h-2 w-2 rounded-full", dotClass)} />;
|
|
16
|
+
* };
|
|
17
|
+
*
|
|
18
|
+
* collapses to just `"h-2 w-2 rounded-full"` in the scanned output —
|
|
19
|
+
* the conditional color silently disappears.
|
|
20
|
+
*
|
|
21
|
+
* History: the inkbridge greenhouse-app `DataSourcesCard` shipped this
|
|
22
|
+
* pattern for its source-status pill. Stories passed `overallSourceLabel:
|
|
23
|
+
* "live"`, the runtime evaluated `dotClass = "bg-sky-400"`, but Figma
|
|
24
|
+
* rendered every pill with a black dot (the `bg-muted-foreground` fallback
|
|
25
|
+
* was being picked up incorrectly because the `cn(base, dotClass)` call
|
|
26
|
+
* lost the `dotClass` argument entirely).
|
|
27
|
+
*
|
|
28
|
+
* Root cause: `resolveExpressionValue` looked up identifiers in
|
|
29
|
+
* `propsContext` (function params), then fell back to
|
|
30
|
+
* `SourceFile.getVariableDeclaration` — which only searches MODULE-LEVEL
|
|
31
|
+
* declarations. Function-body `const`s were invisible.
|
|
32
|
+
*
|
|
33
|
+
* Fix: walk ancestor scopes (Block / CaseClause / DefaultClause) from the
|
|
34
|
+
* identifier site outward and resolve the matching `const`'s initializer
|
|
35
|
+
* against the current `propsContext`. This is the universal version of the
|
|
36
|
+
* pattern — applies to any local-const-derived className, not just pill
|
|
37
|
+
* colors.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
interface JsxNodeLike {
|
|
41
|
+
type: 'element' | 'text';
|
|
42
|
+
tagName?: string;
|
|
43
|
+
content?: string;
|
|
44
|
+
props?: Record<string, unknown>;
|
|
45
|
+
children?: JsxNodeLike[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface TestScannerView {
|
|
49
|
+
project: import('ts-morph').Project;
|
|
50
|
+
extractComponentJsxTree: (
|
|
51
|
+
sourceFile: import('ts-morph').SourceFile,
|
|
52
|
+
componentName: string,
|
|
53
|
+
) => JsxNodeLike | null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function makeScanner(): TestScannerView {
|
|
57
|
+
return new ComponentScanner({
|
|
58
|
+
componentPaths: [],
|
|
59
|
+
filePattern: '*.tsx',
|
|
60
|
+
exclude: [],
|
|
61
|
+
}) as unknown as TestScannerView;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function fixturePath(relative: string): string {
|
|
65
|
+
return path.resolve(process.cwd(), 'tools/figma-plugin/scanner/__fixtures__', relative);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function findElement(node: JsxNodeLike | null, tag: string): JsxNodeLike | null {
|
|
69
|
+
if (!node || node.type !== 'element') return null;
|
|
70
|
+
if (node.tagName === tag) return node;
|
|
71
|
+
for (const child of node.children || []) {
|
|
72
|
+
const found = findElement(child, tag);
|
|
73
|
+
if (found) return found;
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function classList(node: JsxNodeLike | null): string[] {
|
|
79
|
+
const cls = node?.props?.className;
|
|
80
|
+
if (typeof cls !== 'string') return [];
|
|
81
|
+
return cls.split(/\s+/).filter(Boolean);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const scanner = makeScanner();
|
|
85
|
+
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// Case 1: the canonical StatusPill shape. A wrapper component renders a
|
|
88
|
+
// local-defined inner component with a literal prop value. The inner has a
|
|
89
|
+
// local `const dotClass = sourceLabel === "..." ? "class" : ...` whose
|
|
90
|
+
// resolved class must appear in the rendered `<span>`'s className.
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
{
|
|
93
|
+
const file = scanner.project.createSourceFile(
|
|
94
|
+
fixturePath('local-const-classname-status-pill.tsx'),
|
|
95
|
+
`
|
|
96
|
+
const cn = (...args: any[]) => args.filter(Boolean).join(' ');
|
|
97
|
+
|
|
98
|
+
const StatusPill = ({ sourceLabel }: { sourceLabel: "db" | "live" | "inactive" }) => {
|
|
99
|
+
const dotClass =
|
|
100
|
+
sourceLabel === "db"
|
|
101
|
+
? "bg-emerald-500"
|
|
102
|
+
: sourceLabel === "live"
|
|
103
|
+
? "bg-sky-400"
|
|
104
|
+
: "bg-muted-foreground";
|
|
105
|
+
return <span data-slot="dot" className={cn("h-2 w-2 rounded-full", dotClass)} />;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
export function Card() {
|
|
109
|
+
return <StatusPill sourceLabel="live" />;
|
|
110
|
+
}
|
|
111
|
+
`,
|
|
112
|
+
{ overwrite: true },
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
const tree = scanner.extractComponentJsxTree(file, 'Card');
|
|
116
|
+
assert.ok(tree, 'Card must produce a tree');
|
|
117
|
+
const dot = findElement(tree, 'span');
|
|
118
|
+
assert.ok(dot, 'must find the rendered <span>');
|
|
119
|
+
const classes = classList(dot);
|
|
120
|
+
assert.ok(
|
|
121
|
+
classes.includes('bg-sky-400'),
|
|
122
|
+
`dot must inherit bg-sky-400 from the "live" branch of the local-const ternary; got: ${classes.join(' ')}`,
|
|
123
|
+
);
|
|
124
|
+
assert.ok(classes.includes('h-2'), 'base utilities must survive cn() resolution');
|
|
125
|
+
assert.ok(classes.includes('rounded-full'), 'base utilities must survive cn() resolution');
|
|
126
|
+
// The non-chosen branches must NOT appear.
|
|
127
|
+
assert.ok(!classes.includes('bg-emerald-500'), 'losing branch "db" must not appear');
|
|
128
|
+
assert.ok(!classes.includes('bg-muted-foreground'), 'losing branch fallback must not appear');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
// Case 2: literal pass-through with a different prop name. Verifies the
|
|
133
|
+
// resolver picks the right branch when a wrapper forwards a literal value
|
|
134
|
+
// (the StatusPill shape but a different name, to catch regressions tied to
|
|
135
|
+
// hard-coded identifier matching).
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
{
|
|
138
|
+
const file = scanner.project.createSourceFile(
|
|
139
|
+
fixturePath('local-const-classname-passthrough.tsx'),
|
|
140
|
+
`
|
|
141
|
+
const cn = (...args: any[]) => args.filter(Boolean).join(' ');
|
|
142
|
+
|
|
143
|
+
const StatusPill = ({ tone }: { tone: "ok" | "warn" }) => {
|
|
144
|
+
const dotClass = tone === "warn" ? "bg-amber-500" : "bg-emerald-500";
|
|
145
|
+
return <span className={cn("dot", dotClass)} />;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
export function Card() {
|
|
149
|
+
return <StatusPill tone="warn" />;
|
|
150
|
+
}
|
|
151
|
+
`,
|
|
152
|
+
{ overwrite: true },
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
const tree = scanner.extractComponentJsxTree(file, 'Card');
|
|
156
|
+
assert.ok(tree, 'Card must produce a tree');
|
|
157
|
+
const dot = findElement(tree, 'span');
|
|
158
|
+
assert.ok(dot, 'must find the rendered <span>');
|
|
159
|
+
const classes = classList(dot);
|
|
160
|
+
assert.ok(
|
|
161
|
+
classes.includes('bg-amber-500'),
|
|
162
|
+
`the "warn" branch must be selected; got: ${classes.join(' ')}`,
|
|
163
|
+
);
|
|
164
|
+
assert.ok(!classes.includes('bg-emerald-500'), 'the losing branch must not appear');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
// Case 3: forward-reference TDZ. A const declared AFTER the JSX reference
|
|
169
|
+
// site in the same scope MUST NOT be picked up — that would resolve a
|
|
170
|
+
// real TDZ error as if it succeeded, masking bugs.
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
{
|
|
173
|
+
const file = scanner.project.createSourceFile(
|
|
174
|
+
fixturePath('local-const-classname-tdz.tsx'),
|
|
175
|
+
`
|
|
176
|
+
const cn = (...args: any[]) => args.filter(Boolean).join(' ');
|
|
177
|
+
|
|
178
|
+
export function Card() {
|
|
179
|
+
// Reference comes BEFORE the declaration — JS would throw at runtime.
|
|
180
|
+
// The scanner must not silently "fix" it.
|
|
181
|
+
// @ts-ignore — intentional TDZ for the test.
|
|
182
|
+
const out = <span className={cn("base", lateClass)} />;
|
|
183
|
+
const lateClass = "should-not-appear";
|
|
184
|
+
return out;
|
|
185
|
+
}
|
|
186
|
+
`,
|
|
187
|
+
{ overwrite: true },
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
const tree = scanner.extractComponentJsxTree(file, 'Card');
|
|
191
|
+
assert.ok(tree, 'Card must produce a tree');
|
|
192
|
+
const span = findElement(tree, 'span');
|
|
193
|
+
assert.ok(span, 'must find <span>');
|
|
194
|
+
const classes = classList(span);
|
|
195
|
+
assert.ok(
|
|
196
|
+
!classes.includes('should-not-appear'),
|
|
197
|
+
'forward-declared const must not be resolved (TDZ semantics)',
|
|
198
|
+
);
|
|
199
|
+
assert.ok(classes.includes('base'), 'literal class survives');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
// Case 4: cycle protection. `const A = B` and `const B = A` must not
|
|
204
|
+
// infinite-loop — both should resolve to undefined and the className
|
|
205
|
+
// should fall back to just the literal base.
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
{
|
|
208
|
+
const file = scanner.project.createSourceFile(
|
|
209
|
+
fixturePath('local-const-classname-cycle.tsx'),
|
|
210
|
+
`
|
|
211
|
+
const cn = (...args: any[]) => args.filter(Boolean).join(' ');
|
|
212
|
+
|
|
213
|
+
export function Card() {
|
|
214
|
+
const A = B;
|
|
215
|
+
const B = A;
|
|
216
|
+
return <span className={cn("base", A)} />;
|
|
217
|
+
}
|
|
218
|
+
`,
|
|
219
|
+
{ overwrite: true },
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
const tree = scanner.extractComponentJsxTree(file, 'Card');
|
|
223
|
+
assert.ok(tree, 'Card must produce a tree');
|
|
224
|
+
const span = findElement(tree, 'span');
|
|
225
|
+
assert.ok(span, 'must find <span>');
|
|
226
|
+
const classes = classList(span);
|
|
227
|
+
assert.ok(classes.includes('base'), 'literal class survives cycle protection');
|
|
228
|
+
// A and B are mutually recursive — neither should resolve to a literal
|
|
229
|
+
// string. The cn() call should silently drop the unresolved arg.
|
|
230
|
+
assert.equal(classes.length, 1, `only the literal "base" should remain; got: ${classes.join(' ')}`);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
// Case 5: scope-locality. Two functions in the same file both have a local
|
|
235
|
+
// `const colorClass = ...` with different values. The resolver must pick
|
|
236
|
+
// the one in the enclosing scope, not bleed across functions.
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
{
|
|
239
|
+
const file = scanner.project.createSourceFile(
|
|
240
|
+
fixturePath('local-const-classname-scope.tsx'),
|
|
241
|
+
`
|
|
242
|
+
const cn = (...args: any[]) => args.filter(Boolean).join(' ');
|
|
243
|
+
|
|
244
|
+
const Inner = () => {
|
|
245
|
+
const colorClass = "text-sky-500";
|
|
246
|
+
return <span data-which="inner" className={cn("a", colorClass)} />;
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
export function Card() {
|
|
250
|
+
const colorClass = "text-rose-500";
|
|
251
|
+
return (
|
|
252
|
+
<div>
|
|
253
|
+
<span data-which="outer" className={cn("b", colorClass)} />
|
|
254
|
+
<Inner />
|
|
255
|
+
</div>
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
`,
|
|
259
|
+
{ overwrite: true },
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
const tree = scanner.extractComponentJsxTree(file, 'Card');
|
|
263
|
+
assert.ok(tree, 'Card must produce a tree');
|
|
264
|
+
|
|
265
|
+
// Find the two <span>s by their data-which marker.
|
|
266
|
+
function findByMarker(node: JsxNodeLike | null, value: string): JsxNodeLike | null {
|
|
267
|
+
if (!node || node.type !== 'element') return null;
|
|
268
|
+
if (node.props?.['data-which'] === value) return node;
|
|
269
|
+
for (const child of node.children || []) {
|
|
270
|
+
const found = findByMarker(child, value);
|
|
271
|
+
if (found) return found;
|
|
272
|
+
}
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
const outer = findByMarker(tree, 'outer');
|
|
276
|
+
const inner = findByMarker(tree, 'inner');
|
|
277
|
+
assert.ok(outer, 'outer <span> must be present');
|
|
278
|
+
assert.ok(inner, 'inner <span> must be present');
|
|
279
|
+
|
|
280
|
+
const outerClasses = classList(outer);
|
|
281
|
+
const innerClasses = classList(inner);
|
|
282
|
+
assert.ok(outerClasses.includes('text-rose-500'), `outer scope wins for outer span; got: ${outerClasses.join(' ')}`);
|
|
283
|
+
assert.ok(!outerClasses.includes('text-sky-500'), 'inner-scope value must not bleed into outer span');
|
|
284
|
+
assert.ok(innerClasses.includes('text-sky-500'), `inner scope wins for inner span; got: ${innerClasses.join(' ')}`);
|
|
285
|
+
assert.ok(!innerClasses.includes('text-rose-500'), 'outer-scope value must not be picked when inner scope shadows');
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ---------------------------------------------------------------------------
|
|
289
|
+
// Case 6: text-children expressions. `<span>{statusText}</span>` where
|
|
290
|
+
// `statusText` is a local-const ternary must render the resolved text.
|
|
291
|
+
// History: the original DataSourcesCard fix landed for className identifiers
|
|
292
|
+
// but the main `buildJsxTree` flow had a separate, narrower lookup for JSX
|
|
293
|
+
// text-children that only checked module-level vars — so the "Live - DB"
|
|
294
|
+
// label inside the StatusPill was missing even after the dot color worked.
|
|
295
|
+
// ---------------------------------------------------------------------------
|
|
296
|
+
{
|
|
297
|
+
const file = scanner.project.createSourceFile(
|
|
298
|
+
fixturePath('local-const-text-children.tsx'),
|
|
299
|
+
`
|
|
300
|
+
const cn = (...args: any[]) => args.filter(Boolean).join(' ');
|
|
301
|
+
|
|
302
|
+
const StatusPill = ({ sourceLabel }: { sourceLabel: "db" | "live" | "inactive" }) => {
|
|
303
|
+
const statusText =
|
|
304
|
+
sourceLabel === "db"
|
|
305
|
+
? "Live - DB"
|
|
306
|
+
: sourceLabel === "live"
|
|
307
|
+
? "Live - API"
|
|
308
|
+
: "Inactive";
|
|
309
|
+
return <span data-slot="label">{statusText}</span>;
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
export function Card() {
|
|
313
|
+
return <StatusPill sourceLabel="db" />;
|
|
314
|
+
}
|
|
315
|
+
`,
|
|
316
|
+
{ overwrite: true },
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
const tree = scanner.extractComponentJsxTree(file, 'Card');
|
|
320
|
+
assert.ok(tree, 'Card must produce a tree');
|
|
321
|
+
const span = findElement(tree, 'span');
|
|
322
|
+
assert.ok(span, 'must find <span>');
|
|
323
|
+
const textChildren = (span.children || []).filter((c) => c.type === 'text');
|
|
324
|
+
const combined = textChildren.map((c) => c.content ?? '').join('');
|
|
325
|
+
assert.ok(
|
|
326
|
+
combined.includes('Live - DB'),
|
|
327
|
+
`text child must resolve to "Live - DB" via local-const lookup; got: ${JSON.stringify(textChildren)}`,
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
console.log('local-const-className-regression: PASS (6 cases)');
|
|
@@ -96,11 +96,32 @@ assert.equal(parseRingColor('ring-[3px]', colorGroup), null, 'arbitrary widths a
|
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
{
|
|
99
|
-
// Color
|
|
100
|
-
//
|
|
99
|
+
// Color WITHOUT an explicit width is a CSS no-op — Tailwind's
|
|
100
|
+
// `ring-COLOR` sets `--tw-ring-color` but the ring stays
|
|
101
|
+
// invisible until a width utility is added. shadcn's
|
|
102
|
+
// invalid-input pattern relies on this:
|
|
103
|
+
// `aria-invalid:ring-destructive/20 focus-visible:ring-[3px]`
|
|
104
|
+
// → tinted ring when invalid, only visible (3px) when focused.
|
|
105
|
+
// Previously this helper defaulted to width 3 when ANY ring color
|
|
106
|
+
// was present — so the State Matrix `error` variant rendered with
|
|
107
|
+
// a doubled red ring outside the destructive border instead of
|
|
108
|
+
// just the border. Now: color-only → null (no ring).
|
|
101
109
|
const ring = getRingInfoFromClasses(['ring-primary'], colorGroup);
|
|
102
|
-
assert.
|
|
103
|
-
|
|
110
|
+
assert.equal(ring, null, 'color without width → null (no ring)');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
{
|
|
114
|
+
// Same contract for the shadcn invalid-input pattern: tinted color,
|
|
115
|
+
// no width, no visible ring.
|
|
116
|
+
const ring = getRingInfoFromClasses(['ring-destructive', 'border-destructive'], colorGroup);
|
|
117
|
+
assert.equal(ring, null, 'color + non-ring class without width → null');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
{
|
|
121
|
+
// Color + explicit width DOES render the ring.
|
|
122
|
+
const ring = getRingInfoFromClasses(['ring-destructive', 'ring-[3px]'], colorGroup);
|
|
123
|
+
assert.ok(ring, 'color + arbitrary width → ring renders');
|
|
124
|
+
assert.equal(ring!.width, 3, '3px from `ring-[3px]`');
|
|
104
125
|
}
|
|
105
126
|
|
|
106
127
|
{
|
|
@@ -134,4 +134,42 @@ const scanner = makeScanner();
|
|
|
134
134
|
);
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
// Case 4: shadcn-style Label — only carries `peer-disabled:` and
|
|
139
|
+
// `group-data-[disabled=true]:` modifiers (passive reactions to a
|
|
140
|
+
// parent's state, NOT its own state axis). Must NOT be classified as
|
|
141
|
+
// `state` — otherwise the runtime renders it via the state-master
|
|
142
|
+
// path, which has no slot for the label's text children, and a story
|
|
143
|
+
// like `<Dialog>... <Label>Name</Label> <Input/> ...</Dialog>` ends up
|
|
144
|
+
// with the labels invisible. Lock this in so a future widening of the
|
|
145
|
+
// state-modifier list doesn't silently re-break it.
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
{
|
|
148
|
+
const file = scanner.project.createSourceFile(
|
|
149
|
+
fixturePath('passive-label.tsx'),
|
|
150
|
+
`
|
|
151
|
+
export function Label({ className, ...props }: any) {
|
|
152
|
+
return (
|
|
153
|
+
<label
|
|
154
|
+
className={
|
|
155
|
+
"flex items-center gap-2 text-sm leading-none font-medium select-none " +
|
|
156
|
+
"group-data-[disabled=true]:pointer-events-none " +
|
|
157
|
+
"group-data-[disabled=true]:opacity-50 " +
|
|
158
|
+
"peer-disabled:cursor-not-allowed peer-disabled:opacity-50"
|
|
159
|
+
}
|
|
160
|
+
{...props}
|
|
161
|
+
/>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
`,
|
|
165
|
+
{ overwrite: true }
|
|
166
|
+
);
|
|
167
|
+
const result = scanner.analyzeState(file, file.getFilePath());
|
|
168
|
+
assert.equal(
|
|
169
|
+
result,
|
|
170
|
+
null,
|
|
171
|
+
'A component whose only "state" modifiers are passive `group-*:` / `peer-*:` reactions must NOT be classified as `state` — it has no own-element state axis, and the runtime would lose its text children when rendering via the state-master path.',
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
137
175
|
console.log('state-classification-regression: ok');
|
|
@@ -110,4 +110,38 @@ assert.equal(
|
|
|
110
110
|
'w-full suppresses stretch (the post-normalisation form of size-full)',
|
|
111
111
|
);
|
|
112
112
|
|
|
113
|
-
|
|
113
|
+
// DropdownMenuItem class shape: scanner emits these wrappers as
|
|
114
|
+
// `kind: 'component'` with tagName `DropdownMenuPrimitive.Item`. The
|
|
115
|
+
// ui-builder wrapperFrame branch calls `shouldStretchToParentWidth('div',
|
|
116
|
+
// classes)` to mirror the kind:'element' decision — without it, items
|
|
117
|
+
// inside a `w-56` Content stay HUG-width. Lock the predicate so the call
|
|
118
|
+
// site keeps producing true for the canonical Item / Label class lists.
|
|
119
|
+
assert.equal(
|
|
120
|
+
shouldStretchToParentWidth('div', [
|
|
121
|
+
'focus:bg-accent', 'focus:text-accent-foreground',
|
|
122
|
+
'relative', 'flex', 'cursor-default', 'items-center', 'gap-2',
|
|
123
|
+
'rounded-sm', 'px-2', 'py-1.5', 'text-sm',
|
|
124
|
+
'data-[disabled]:opacity-50', 'data-[inset]:pl-8',
|
|
125
|
+
]),
|
|
126
|
+
true,
|
|
127
|
+
'DropdownMenuItem class list → stretches in vertical block-flow parent',
|
|
128
|
+
);
|
|
129
|
+
assert.equal(
|
|
130
|
+
shouldStretchToParentWidth('div', [
|
|
131
|
+
'px-2', 'py-1.5', 'text-sm', 'font-medium', 'data-[inset]:pl-8',
|
|
132
|
+
]),
|
|
133
|
+
true,
|
|
134
|
+
'DropdownMenuLabel class list → stretches in vertical block-flow parent',
|
|
135
|
+
);
|
|
136
|
+
// DropdownMenuContent itself has `min-w-[8rem]` which IS a width signal —
|
|
137
|
+
// so Content does NOT stretch (the consumer's `w-56` sets its width
|
|
138
|
+
// instead). Lock that boundary.
|
|
139
|
+
assert.equal(
|
|
140
|
+
shouldStretchToParentWidth('div', [
|
|
141
|
+
'bg-popover', 'min-w-[8rem]', 'rounded-md', 'border', 'p-1', 'shadow-md',
|
|
142
|
+
]),
|
|
143
|
+
false,
|
|
144
|
+
'DropdownMenuContent (min-w-* present) → does NOT stretch — consumer width wins',
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
console.log('stretch-to-parent-width-regression: PASS (17 cases)');
|