Skip to content

Commit f2a9b50

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
fix exported class map source dispatch
1 parent baa6d54 commit f2a9b50

7 files changed

Lines changed: 378 additions & 13 deletions

File tree

‎scripts/check-design-conformance-appearance.test.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -157,9 +157,10 @@ test('a component rename does not hide removal of an appearance override', () =>
157157
).toBe(false)
158158
})
159159
test('uppercase exported object styling and property edits are inspected', () => {
160-
const a = "export const PANEL_CLASSES={layout:'p-2 [--control-reserve:64px]'} as const"
160+
const a =
161+
"export const PANEL_CLASSES={layout:'p-2 [--control-reserve:64px]'} as const;const A=()=> <div className={PANEL_CLASSES.layout}/>"
161162
expect(diff(a, a.replace('64px', '52px')).flagged).toBe(true)
162-
expect(diff(a, a.replace('PANEL_CLASSES', 'ROW_CLASSES')).flagged).toBe(false)
163+
expect(diff(a, a.replaceAll('PANEL_CLASSES', 'ROW_CLASSES')).flagged).toBe(false)
163164
})
164165
test('detached shared styles have specific evidence; import aliases pass', () => {
165166
const a =

‎scripts/check-design-conformance-command.test.ts‎

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,165 @@ throw new Error('Product source must not execute')
309309
}
310310
)
311311

312+
test.each([
313+
[
314+
'Boolean class map',
315+
"{'bg-red-500':true}",
316+
"{'bg-blue-500':true}",
317+
'cn(paintStyles)',
318+
'paintStyles',
319+
true,
320+
false,
321+
],
322+
[
323+
'truthy string class map',
324+
"{'bg-red-500':'enabled'}",
325+
"{'bg-blue-500':'enabled'}",
326+
'cn(paintStyles)',
327+
'paintStyles',
328+
true,
329+
false,
330+
],
331+
[
332+
'nested class map',
333+
"{tone:{'bg-red-500':true}}",
334+
"{tone:{'bg-blue-500':true}}",
335+
'cn(paintStyles.tone)',
336+
'paintStyles',
337+
true,
338+
false,
339+
],
340+
[
341+
'class map without styling suffix',
342+
"{'bg-red-500':true}",
343+
"{'bg-blue-500':true}",
344+
'cn(palette)',
345+
'palette',
346+
true,
347+
false,
348+
],
349+
[
350+
'falsy class map',
351+
"{'bg-red-500':false,'rounded-lg':0,'p-2':null,'text-red-500':''}",
352+
"{'bg-blue-500':false,'rounded-xl':0,'p-4':null,'text-blue-500':''}",
353+
'cn(paintStyles)',
354+
'paintStyles',
355+
false,
356+
false,
357+
],
358+
[
359+
'variant string value',
360+
"{primary:'bg-red-500'}",
361+
"{primary:'bg-blue-500'}",
362+
'cn(paintStyles.primary)',
363+
'paintStyles',
364+
true,
365+
false,
366+
],
367+
[
368+
'nested variant value',
369+
"{tone:{primary:'bg-red-500'}}",
370+
"{tone:{primary:'bg-blue-500'}}",
371+
'cn(paintStyles.tone.primary)',
372+
'paintStyles',
373+
true,
374+
false,
375+
],
376+
[
377+
'CVA variant table',
378+
"{tone:{primary:'bg-red-500'}}",
379+
"{tone:{primary:'bg-blue-500'}}",
380+
"cva('',{variants:paintStyles})()",
381+
'paintStyles',
382+
true,
383+
false,
384+
],
385+
[
386+
'exported string',
387+
"'bg-red-500'",
388+
"'bg-blue-500'",
389+
'cn(paintStyles)',
390+
'paintStyles',
391+
true,
392+
false,
393+
],
394+
[
395+
'data-only object',
396+
"{message:'bg-red-500'}",
397+
"{message:'bg-blue-500'}",
398+
'JSON.stringify(paintStyles)',
399+
'paintStyles',
400+
false,
401+
true,
402+
],
403+
[
404+
'shadowed data object',
405+
"{message:'bg-red-500'}",
406+
"{message:'bg-blue-500'}",
407+
'<shadowed>',
408+
'paintStyles',
409+
false,
410+
true,
411+
],
412+
[
413+
'unknown object consumer',
414+
"{primary:'bg-red-500'}",
415+
"{primary:'bg-blue-500'}",
416+
'unknownClasses(paintStyles)',
417+
'paintStyles',
418+
false,
419+
true,
420+
],
421+
] as const)(
422+
'source-only exported styling changes follow unchanged class consumers through the real CLI: %s',
423+
(_name, before, after, use, exported, notify, unchecked) => {
424+
const { repo } = fixture()
425+
const sourceFile = 'packages/emcn/src/lib/styles.ts'
426+
const consumerFile = 'packages/emcn/src/components/consumer.tsx'
427+
const write = (file: string, source: string) => {
428+
mkdirSync(path.dirname(path.join(repo, file)), { recursive: true })
429+
writeFileSync(path.join(repo, file), source)
430+
}
431+
write('packages/emcn/src/index.ts', `export {${exported}} from './lib/styles'`)
432+
const shadow =
433+
use === '<shadowed>'
434+
? ";declare function cva(value:unknown):unknown;function Local(){const paintStyles={'p-2':true};return cva(paintStyles)}"
435+
: ''
436+
write(sourceFile, `export const ${exported}=${before} as const${shadow}`)
437+
write(
438+
consumerFile,
439+
`import {${exported}} from '../lib/styles';declare function cn(...v:unknown[]):string;declare function unknownClasses(v:unknown):string;declare function cva(base:string,config:unknown):()=>string;${use === '<shadowed>' ? "const Consumer=()=> {const paintStyles={'p-2':true};return <div className={cn(paintStyles)}/>}" : `const Consumer=()=> <div className={${use}}/>`}`
440+
)
441+
write(
442+
ui,
443+
`import {${exported},cn} from '@sim/emcn';const A=()=> <div className={cn(${exported})}/>`
444+
)
445+
const base = commit(repo)
446+
write(sourceFile, `export const ${exported}=${after} as const${shadow}`)
447+
const child = run(['--repo', repo, '--base', base, '--working-tree', '--format', 'json'])
448+
expect([0, 1], child.stderr).toContain(child.status)
449+
const report = JSON.parse(child.stdout) as Report
450+
expect(report.status, child.stdout).toBe('completed')
451+
expect(git(repo, ['diff', '--name-only', base]).toString().trim()).toBe(sourceFile)
452+
expect(
453+
report.findings.some(
454+
(finding) => finding.file === sourceFile && finding.property === 'background-color'
455+
),
456+
child.stdout
457+
).toBe(notify)
458+
expect(
459+
report.findings.filter((finding) => finding.file === sourceFile),
460+
child.stdout
461+
).toHaveLength(notify ? 1 : 0)
462+
expect(
463+
report.unchecked.some(
464+
(note) => note.file === sourceFile && /object.*class.*unchecked/i.test(note.reason)
465+
),
466+
child.stdout
467+
).toBe(unchecked)
468+
}
469+
)
470+
312471
test.each([
313472
['grid line integer', 'gridColumnStart', 'grid-column-start', '1', 1, ''],
314473
['grid row integer', 'gridRowEnd', 'grid-row-end', '3', 3, ''],

‎scripts/design-conformance/conformance.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -577,6 +577,7 @@ export class ConformanceLinter {
577577
const facts = extract(source, file, true, {
578578
conformance: true,
579579
resolve: system.resolve,
580+
classUses: system.classUses,
580581
contract: (target) => componentContract(target, system.metadata),
581582
})
582583
const bytes = Buffer.byteLength(canonical(facts))

‎scripts/design-conformance/design-system.ts‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import {
2727
utility,
2828
variablesIn,
2929
} from '#design-conformance/normalize'
30-
import { type SourceSummary, summarize } from '#design-conformance/source-summary'
30+
import { classUseIndex, type SourceSummary, summarize } from '#design-conformance/source-summary'
3131
import type { SystemInput } from '#design-conformance/system-snapshot'
3232

3333
export interface Reference {
@@ -50,6 +50,7 @@ interface Module {
5050
export interface DesignSystem {
5151
metadata: GeneratedContracts
5252
recipes: Record<string, ReturnType<typeof extractCentralRecipes>>
53+
classUses: ReturnType<typeof classUseIndex>
5354
summaries: { file: string; summary: SourceSummary }[]
5455
resolutionHash: string
5556
definitions: Record<string, Atom[]>
@@ -586,13 +587,15 @@ export async function designSystem(input: SystemInput): Promise<DesignSystem> {
586587
unchecked.push(
587588
...metadata.diagnostics.map((d) => ({ file: d.file, line: d.line, reason: d.reason }))
588589
)
590+
const summaries = input.snapshot.entries.flatMap((e) => {
591+
const summary = info.get(moduleName(e.path))?.summary
592+
return summary ? [{ file: e.path, summary }] : []
593+
})
589594
return {
590595
metadata,
591596
recipes,
592-
summaries: input.snapshot.entries.flatMap((e) => {
593-
const summary = info.get(moduleName(e.path))?.summary
594-
return summary ? [{ file: e.path, summary }] : []
595-
}),
597+
classUses: classUseIndex(summaries),
598+
summaries,
596599
resolutionHash: hash(
597600
canonical([
598601
metadata.sourceHash,

‎scripts/design-conformance/extract.ts‎

Lines changed: 58 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,13 @@ import {
3333
renderedHtml,
3434
visibleHtmlText,
3535
} from '#design-conformance/rendered-html'
36-
import { modulePath, type Route, summarize } from '#design-conformance/source-summary'
36+
import {
37+
type ClassUse,
38+
classUseIndex,
39+
modulePath,
40+
type Route,
41+
summarize,
42+
} from '#design-conformance/source-summary'
3743

3844
const classHelpers = new Set(['cn', 'clsx', 'classNames', 'twMerge'])
3945
const media = new Set([
@@ -55,6 +61,7 @@ export function extract(
5561
file: string,
5662
appearance = true,
5763
options?: {
64+
classUses?: (ref: string) => ClassUse[]
5865
conformance: boolean
5966
resolve: (ref: string) => Reference | undefined
6067
contract?: (target: string) => ComponentContract | undefined
@@ -401,7 +408,9 @@ export function extract(
401408
errorRecovery: false,
402409
})
403410
parsed = true
404-
if (options?.conformance) facts.syntax = summarize(ast, file)
411+
const summary = summarize(ast, file)
412+
const localClassUses = classUseIndex([{ file, summary }])
413+
if (options?.conformance) facts.syntax = summary
405414
const emitted = new Set<string>()
406415
const mediaNames = new Set(media)
407416
for (const statement of ast.program.body)
@@ -1365,9 +1374,12 @@ export function extract(
13651374
for (const decl of d.get('declarations'))
13661375
if (
13671376
decl.get('id').isIdentifier() &&
1368-
/(?:Class|Classes|Tokens|Styles|_CLASS|_CLASSES|_TOKENS|_STYLES)$/.test(
1377+
(/(?:Class|Classes|Tokens|Styles|_CLASS|_CLASSES|_TOKENS|_STYLES)$/.test(
13691378
name(decl.get('id'))
1370-
)
1379+
) ||
1380+
localClassUses(`${modulePath(file)}#${name(decl.get('id'))}`).length > 0 ||
1381+
(options?.classUses?.(`${modulePath(file)}#${name(decl.get('id'))}`).length ?? 0) >
1382+
0)
13711383
)
13721384
within(
13731385
surface(
@@ -1380,8 +1392,48 @@ export function extract(
13801392
let init = decl.get('init') as NodePath
13811393
if (init.isTSAsExpression() || init.isTSSatisfiesExpression())
13821394
init = init.get('expression') as NodePath
1383-
if (init.isObjectExpression()) variantValues(init)
1384-
else classes(init)
1395+
if (init.isObjectExpression()) {
1396+
const ref = `${modulePath(file)}#${name(decl.get('id'))}`
1397+
const uses = [...localClassUses(ref), ...(options?.classUses?.(ref) ?? [])]
1398+
if (!uses.length) {
1399+
note(
1400+
init,
1401+
'Exported object class-input classification is unchecked; supplied same-file/central consumers do not prove class-map keys or variant values'
1402+
)
1403+
return
1404+
}
1405+
for (const use of uses) {
1406+
let selected: NodePath | undefined = init
1407+
for (const key of use.path) {
1408+
while (
1409+
selected?.isTSAsExpression() ||
1410+
selected?.isTSSatisfiesExpression() ||
1411+
selected?.isTSNonNullExpression()
1412+
)
1413+
selected = selected.get('expression') as NodePath
1414+
if (!selected?.isObjectExpression()) {
1415+
selected = undefined
1416+
break
1417+
}
1418+
const member = selected
1419+
.get('properties')
1420+
.filter(
1421+
(p) =>
1422+
p.isObjectProperty() && !p.node.computed && name(p.get('key')) === key
1423+
)
1424+
.at(-1)
1425+
selected = member?.isObjectProperty() ? member.get('value') : undefined
1426+
}
1427+
if (!selected) {
1428+
note(init, 'Exported object class-input member path is unchecked')
1429+
continue
1430+
}
1431+
if (use.mode === 'variants')
1432+
variantValues(selected, use.path.map((part) => `/${part}`).join(''))
1433+
else
1434+
classes(selected, new Set(), 0, use.path.map((part) => `/${part}`).join(''))
1435+
}
1436+
} else classes(init)
13851437
}
13861438
)
13871439
},

0 commit comments

Comments
 (0)