Files
design/scripts/check-lint.mjs
zeekay 7d993eab1b feat(lint): flex-prop floor, counted per directory; 0.5.36
flex, grow, shrink, flexBasis and flexWrap are the layout @hanzo/gui 8.4's
`tracks` replaces, so rule 13 counts them on JSX elements, as attributes or
as keys of an attribute's object, except on a direct child of a <Wrap>. A
small JSX reader (flexProps) finds the element a prop sits on, because a
Wrap's child is not a fact about a line.

It is a floor, not a rule: off until a surface names it, counted per
directory (first two path segments, as hanzo.ai's tailwind floor counts),
and turned on by `hanzo-design-lint --record`, which writes today's counts
into hanzo-design.allow.json. Over a directory's count fails; under it
reports the number to lower it to. scripts/check-lint.mjs runs the real
script against fixtures in a scratch directory.

Claude-Session: https://claude.ai/code/session_01BLLMyBFSvAfCt7ZGqYuiEz
2026-10-22 11:34:20 -07:00

111 lines
3.9 KiB
JavaScript

/**
* The flex-prop floor (lint.mjs rule 13), run the way a surface runs it: the
* real script, in a scratch directory, against files that hold every shape the
* JSX reader has to get right. A plain node script, like the other checks.
*/
import { execFileSync } from 'node:child_process'
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const lint = join(dirname(fileURLToPath(import.meta.url)), 'lint.mjs')
const root = mkdtempSync(join(tmpdir(), 'hanzo-design-lint-'))
let failed = 0
const write = (path, src) => {
mkdirSync(dirname(join(root, path)), { recursive: true })
writeFileSync(join(root, path), src)
}
const run = (...args) => {
try {
return { code: 0, out: execFileSync('node', [lint, ...args], { cwd: root, encoding: 'utf8' }) }
} catch (e) {
return { code: e.status, out: `${e.stdout}${e.stderr}` }
}
}
const check = (name, ok, detail = '') => {
console.log(`${ok ? 'ok ' : 'FAIL'} ${name}${ok ? '' : `\n ${detail}`}`)
if (!ok) failed++
}
// Two counted in app/: flex on the YStack, flexWrap in the XStack's media object.
// Not counted: grow and flexBasis on a Wrap's children (one through a fragment and
// a map), every `<` that is a comparison, a type argument or a generic arrow, a
// regex holding a quote, a string holding a tag, and an object outside JSX.
write('app/page.tsx', `
import { Wrap, XStack, YStack } from '@hanzo/gui'
const config = { flex: 1, grow: 2 }
const pick = <T,>(xs: T[]): T => xs[0]
const quote = /'/g
const html = '<View flex={1} />'
export function Page({ items }: { items: Array<string> }) {
const n = useState<number>(0)
if (n < 3 && items.length > 0) {}
return (
<YStack flex={1}>
<Wrap gap="$2">
<Chip grow={1} />
<>{items.map((i) => <Tag key={i} flexBasis={100} />)}</>
</Wrap>
<XStack $sm={{ flexWrap: 'wrap' }} onPress={() => n > 2} />
</YStack>
)
}
`)
// Two counted in components/home/: a Wrap's GRANDchild is back outside it, and
// an element after a line comment inside an expression is still an element.
write('components/home/Card.tsx', `
export const Card = ({ open }) => (
<Wrap>
<YStack>
<View shrink={1} />
{open ? (
// the comment's apostrophe and \`backticks\` are not code
<View grow={1} />
) : null}
</YStack>
</Wrap>
)
`)
let r = run('app', 'components')
check('off until recorded: passes, says nothing about flex-prop', r.code === 0 && !r.out.includes('flex-prop'), r.out)
r = run('--record', 'app', 'components')
const allow = JSON.parse(readFileSync(join(root, 'hanzo-design.allow.json'), 'utf8'))
check(
'--record writes per-directory counts',
r.code === 0 && JSON.stringify(allow['flex-prop']) === JSON.stringify({ app: 2, 'components/home': 2 }),
JSON.stringify(allow)
)
r = run('app', 'components')
check('at the floor: passes', r.code === 0, r.out)
write('app/more.tsx', `export const More = () => <XStack grow={1} />\n`)
r = run('app', 'components')
check(
'growth in one directory fails and names it',
r.code === 1 && r.out.includes('app: 3 found, 2 allowed') && r.out.includes('more.tsx:1'),
r.out
)
rmSync(join(root, 'app/more.tsx'))
write('app/page.tsx', readFileSync(join(root, 'app/page.tsx'), 'utf8').replace('<YStack flex={1}>', '<YStack>'))
r = run('app', 'components')
check('under the floor passes and says what to lower it to', r.code === 0 && r.out.includes('Lower it to 1'), r.out)
writeFileSync(join(root, 'hanzo-design.allow.json'), JSON.stringify({ 'flex-props': {} }))
r = run('app', 'components')
check('a misspelt rule is refused', r.code === 1 && r.out.includes('not rules: flex-props'), r.out)
rmSync(root, { recursive: true, force: true })
if (failed) {
console.error(`check-lint: ${failed} failed`)
process.exit(1)
}
console.log('check-lint: flex-prop floor holds')