@sorb/seed 0.1.1 → 0.3.0

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.
Files changed (45) hide show
  1. package/README.md +15 -5
  2. package/package.json +10 -3
  3. package/src/adapt/__fixtures__/Button.legacy.jsx +38 -0
  4. package/src/adapt/__fixtures__/Button.tokenized.tsx +28 -0
  5. package/src/adapt/__fixtures__/corpus/button-inline.case.json +10 -0
  6. package/src/adapt/__fixtures__/corpus/card-styled.case.json +9 -0
  7. package/src/adapt/__fixtures__/corpus/misc-unmapped.case.json +7 -0
  8. package/src/adapt/adaptCli.js +110 -0
  9. package/src/adapt/adaptCli.test.js +109 -0
  10. package/src/adapt/benchmark.js +113 -0
  11. package/src/adapt/benchmark.test.js +79 -0
  12. package/src/adapt/codemod.js +160 -0
  13. package/src/adapt/codemod.test.js +120 -0
  14. package/src/adapt/detectHardcoded.js +165 -0
  15. package/src/adapt/detectHardcoded.test.js +62 -0
  16. package/src/adapt/glob.js +87 -0
  17. package/src/adapt/mapToToken.js +94 -0
  18. package/src/adapt/mapToToken.test.js +103 -0
  19. package/src/adapt/report.js +53 -0
  20. package/src/adapt/report.test.js +66 -0
  21. package/src/adapt/runBenchmark.js +16 -0
  22. package/src/adapt/types.js +48 -0
  23. package/src/captureCli.js +38 -118
  24. package/src/cli.js +200 -3
  25. package/src/cli.test.js +51 -0
  26. package/src/emit/sorbFormat.js +313 -0
  27. package/src/emit/sorbFormat.test.js +133 -0
  28. package/src/index.js +27 -0
  29. package/src/render/cli.js +82 -0
  30. package/src/render/cli.test.js +47 -0
  31. package/src/render/diffCache.js +152 -0
  32. package/src/render/diffCache.test.js +110 -0
  33. package/src/render/pagePool.js +82 -0
  34. package/src/render/pagePool.test.js +23 -0
  35. package/src/render/tokenInject.js +53 -0
  36. package/src/render/tokenInject.test.js +59 -0
  37. package/src/render/walkerBundle.js +24 -0
  38. package/src/render/walkerBundle.test.js +12 -0
  39. package/src/render/worker.js +193 -0
  40. package/src/render/worker.test.js +233 -0
  41. package/src/sources/figmaPlugin.js +83 -0
  42. package/src/sources/figmaPlugin.test.js +65 -0
  43. package/src/sources/storybookDom.js +188 -0
  44. package/src/variants.js +279 -0
  45. package/src/variants.test.js +129 -0
@@ -0,0 +1,160 @@
1
+ // P3 — codemod: rewrite each `auto` site to `var(--<cssVar>, <original raw>)`,
2
+ // fallback preserved (so the rewritten component renders byte-identical until a
3
+ // token is actually applied). Babel parse → mutate → generate.
4
+ //
5
+ // Safety (spec §6-P3 / AGENTS §5):
6
+ // - refuses to run on `main` (or whatever the default branch is),
7
+ // - requires an explicit `--write` flag — without it, dry-run (diff only),
8
+ // - writes a `.bak` of each changed file + a unified-ish diff under `.sorb/`,
9
+ // - never mutates source without `--write`.
10
+ import { readFileSync, writeFileSync, mkdirSync } from 'fs'
11
+ import { join, resolve, dirname } from 'path'
12
+ import { execSync } from 'child_process'
13
+ import { parse } from '@babel/parser'
14
+ import _traverse from '@babel/traverse'
15
+ import _generate from '@babel/generator'
16
+
17
+ const traverse = /** @type {any} */ (_traverse).default || _traverse
18
+ const generate = /** @type {any} */ (_generate).default || _generate
19
+
20
+ /** Build the `var(--x, fallback)` replacement string for a raw value. */
21
+ export const varExpr = (cssVar, raw) => `var(${cssVar}, ${raw})`
22
+
23
+ /** Current git branch for `cwd`, or null if not a repo / git unavailable. */
24
+ export function currentBranch(cwd) {
25
+ try {
26
+ return execSync('git rev-parse --abbrev-ref HEAD', { cwd, stdio: ['ignore', 'pipe', 'ignore'] })
27
+ .toString()
28
+ .trim()
29
+ } catch (e) {
30
+ return null
31
+ }
32
+ }
33
+
34
+ const DEFAULT_BRANCHES = new Set(['main', 'master'])
35
+
36
+ /**
37
+ * Rewrite the `auto` sites in a single source string. Returns the new source
38
+ * (or the original if nothing changed) + the count of edits applied.
39
+ * Matches sites by {line, column} against AST node locs.
40
+ * @param {string} source
41
+ * @param {import('./types.js').AdaptRow[]} fileRows auto rows for THIS file
42
+ * @returns {{code: string, edits: number}}
43
+ */
44
+ export function rewriteSource(source, fileRows) {
45
+ if (!fileRows.length) return { code: source, edits: 0 }
46
+ const wanted = new Map() // "line:col" → row
47
+ for (const r of fileRows) wanted.set(`${r.loc.line}:${r.loc.column}`, r)
48
+
49
+ const ast = parse(source, { sourceType: 'unambiguous', plugins: ['jsx', 'typescript'], tokens: false })
50
+ let edits = 0
51
+ const keyOf = (node) => node.loc ? `${node.loc.start.line}:${node.loc.start.column}` : null
52
+ const already = (node) => node._sorbRewritten
53
+
54
+ const replaceWith = (node, row) => {
55
+ if (already(node)) return
56
+ // Replace the literal node with a string literal carrying the var() expr.
57
+ // For inline-style JSX/object values this yields backgroundColor: 'var(--x, #0F65EF)'.
58
+ const raw = node.type === 'NumericLiteral' ? String(node.value) : node.value
59
+ node.type = 'StringLiteral'
60
+ node.value = varExpr(row.cssVar, raw)
61
+ delete node.extra // drop the original raw quoting so generator re-quotes value
62
+ node._sorbRewritten = true
63
+ edits++
64
+ }
65
+
66
+ traverse(ast, {
67
+ StringLiteral(path) {
68
+ const k = keyOf(path.node)
69
+ if (k && wanted.has(k)) replaceWith(path.node, wanted.get(k))
70
+ },
71
+ NumericLiteral(path) {
72
+ const k = keyOf(path.node)
73
+ if (k && wanted.has(k)) replaceWith(path.node, wanted.get(k))
74
+ },
75
+ // Styled-components template quasis: rewrite the literal substring in-place.
76
+ TemplateElement(path) {
77
+ const k = keyOf(path.node)
78
+ if (!k) return
79
+ // A quasi can host several sites (multiple declarations); match all rows
80
+ // whose loc points at this quasi.
81
+ const rows = fileRows.filter((r) => `${r.loc.line}:${r.loc.column}` === k)
82
+ if (!rows.length) return
83
+ let cooked = path.node.value.cooked != null ? path.node.value.cooked : path.node.value.raw
84
+ let raw = path.node.value.raw
85
+ for (const row of rows) {
86
+ const replacement = varExpr(row.cssVar, row.raw)
87
+ // Replace the first verbatim occurrence of the raw value.
88
+ if (cooked.includes(row.raw)) cooked = cooked.replace(row.raw, replacement)
89
+ if (raw.includes(row.raw)) raw = raw.replace(row.raw, replacement)
90
+ edits++
91
+ }
92
+ path.node.value = { cooked, raw }
93
+ },
94
+ })
95
+
96
+ if (!edits) return { code: source, edits: 0 }
97
+ const out = generate(ast, { retainLines: true, jsescOption: { minimal: true } }, source)
98
+ return { code: out.code, edits }
99
+ }
100
+
101
+ /**
102
+ * Run the codemod over the auto rows. Groups rows by file. Dry-run by default;
103
+ * writes only with `write: true`. Refuses on default branch.
104
+ * @param {import('./types.js').AdaptRow[]} rows full report rows
105
+ * @param {{cwd: string, write?: boolean, allowBranch?: boolean}} o
106
+ */
107
+ export async function runCodemod(rows, o) {
108
+ const { cwd, write = false } = o
109
+ const branch = currentBranch(cwd)
110
+ if (!o.allowBranch && branch && DEFAULT_BRANCHES.has(branch)) {
111
+ return { refused: true, reason: `refusing to codemod on default branch '${branch}' — branch first`, branch }
112
+ }
113
+
114
+ const auto = rows.filter((r) => r.status === 'auto')
115
+ const byFile = new Map()
116
+ for (const r of auto) {
117
+ if (!byFile.has(r.file)) byFile.set(r.file, [])
118
+ byFile.get(r.file).push(r)
119
+ }
120
+
121
+ const sorbDir = join(cwd, '.sorb')
122
+ const diffs = []
123
+ let changedFiles = 0
124
+ for (const [file, fileRows] of byFile) {
125
+ const abs = resolve(cwd, file)
126
+ const source = readFileSync(abs, 'utf-8')
127
+ const { code, edits } = rewriteSource(source, fileRows)
128
+ if (!edits || code === source) continue
129
+ changedFiles++
130
+ diffs.push(makeDiff(file, source, code))
131
+ if (write) {
132
+ // .bak under .sorb/ mirroring the file path, then write the rewrite.
133
+ const bak = join(sorbDir, 'bak', file + '.bak')
134
+ mkdirSync(dirname(bak), { recursive: true })
135
+ writeFileSync(bak, source, 'utf-8')
136
+ writeFileSync(abs, code, 'utf-8')
137
+ }
138
+ }
139
+
140
+ const diff = diffs.join('\n')
141
+ if (write && diff) {
142
+ mkdirSync(sorbDir, { recursive: true })
143
+ writeFileSync(join(sorbDir, 'adapt-codemod.diff'), diff + '\n', 'utf-8')
144
+ }
145
+ return { refused: false, written: write, changedFiles, diff, branch }
146
+ }
147
+
148
+ /** A minimal line-level unified-ish diff (no external dep). */
149
+ export function makeDiff(file, before, after) {
150
+ const a = before.split('\n')
151
+ const b = after.split('\n')
152
+ const out = [`--- a/${file}`, `+++ b/${file}`]
153
+ const max = Math.max(a.length, b.length)
154
+ for (let i = 0; i < max; i++) {
155
+ if (a[i] === b[i]) continue
156
+ if (a[i] !== undefined) out.push(`-${a[i]}`)
157
+ if (b[i] !== undefined) out.push(`+${b[i]}`)
158
+ }
159
+ return out.join('\n')
160
+ }
@@ -0,0 +1,120 @@
1
+ // P3 acceptance: codemod dry-run produces a diff; --write on a fixture COPY
2
+ // rewrites to var(--x, original) preserving the fallback; re-detecting the
3
+ // rewritten file reports those sites as resolved (var), not hardcoded; and the
4
+ // default-branch refusal is enforced. Operates on temp copies — never tracked
5
+ // source. Run: node --test src/
6
+ import { test } from 'node:test'
7
+ import assert from 'node:assert/strict'
8
+ import { readFileSync, writeFileSync, mkdtempSync, mkdirSync, existsSync, rmSync, cpSync } from 'fs'
9
+ import { tmpdir } from 'os'
10
+ import { fileURLToPath } from 'url'
11
+ import { dirname, join } from 'path'
12
+ import { buildTokenIndex } from '../annotateTokens.js'
13
+ import { detectHardcoded } from './detectHardcoded.js'
14
+ import { buildReport } from './report.js'
15
+ import { rewriteSource, runCodemod, varExpr, makeDiff } from './codemod.js'
16
+
17
+ const here = dirname(fileURLToPath(import.meta.url))
18
+ const RESOLVED = JSON.parse(
19
+ readFileSync(join(here, '..', '..', '..', 'sorb-demo', '.sorb', 'resolved.json'), 'utf-8'),
20
+ )
21
+ const index = buildTokenIndex(RESOLVED)
22
+ const fixtureSrc = readFileSync(join(here, '__fixtures__', 'Button.legacy.jsx'), 'utf-8')
23
+
24
+ const autoRows = (file) =>
25
+ buildReport(detectHardcoded(fixtureSrc, file), index, RESOLVED).filter((r) => r.status === 'auto')
26
+
27
+ test('varExpr builds var(--x, fallback) with the original literal preserved', () => {
28
+ assert.equal(varExpr('--button-radius', '4px'), 'var(--button-radius, 4px)')
29
+ })
30
+
31
+ test('rewriteSource rewrites auto sites to var() and leaves others untouched', () => {
32
+ const rows = autoRows('Button.legacy.jsx')
33
+ const { code, edits } = rewriteSource(fixtureSrc, rows)
34
+ assert.ok(edits >= 2) // at least bg + radius
35
+ assert.ok(code.includes('var(--button-primary-bg-default'))
36
+ assert.ok(code.includes('var(--button-radius'))
37
+ // fallback preserved (byte-identical render until a token applies)
38
+ assert.ok(code.includes('#0F65EF'))
39
+ })
40
+
41
+ test('re-detecting the rewritten source reports the auto sites as resolved (not hardcoded)', () => {
42
+ const rows = autoRows('Button.legacy.jsx')
43
+ const { code } = rewriteSource(fixtureSrc, rows)
44
+ const after = detectHardcoded(code, 'rewritten.jsx')
45
+ // The bg #0F65EF and radius 4/4px sites must no longer be flagged.
46
+ assert.ok(!after.some((s) => s.role === 'bg' && s.raw === '#0F65EF'))
47
+ assert.ok(!after.some((s) => s.role === 'radius'))
48
+ })
49
+
50
+ test('makeDiff produces a reviewable +/- diff', () => {
51
+ const d = makeDiff('f.jsx', 'a\nb\nc', 'a\nB\nc')
52
+ assert.ok(d.includes('-b'))
53
+ assert.ok(d.includes('+B'))
54
+ })
55
+
56
+ test('runCodemod dry-run (no --write) emits a diff and changes nothing on disk', async () => {
57
+ const cwd = mkdtempSync(join(tmpdir(), 'sorb-codemod-'))
58
+ try {
59
+ const file = 'Button.legacy.jsx'
60
+ const abs = join(cwd, file)
61
+ writeFileSync(abs, fixtureSrc, 'utf-8')
62
+ const rows = autoRows(file)
63
+ const res = await runCodemod(rows, { cwd, write: false, allowBranch: true })
64
+ assert.equal(res.refused, false)
65
+ assert.equal(res.written, false)
66
+ assert.ok(res.changedFiles >= 1)
67
+ assert.ok(res.diff.length > 0)
68
+ // disk untouched
69
+ assert.equal(readFileSync(abs, 'utf-8'), fixtureSrc)
70
+ } finally {
71
+ rmSync(cwd, { recursive: true, force: true })
72
+ }
73
+ })
74
+
75
+ test('runCodemod --write rewrites the COPY + writes a .bak; rewritten re-detects clean', async () => {
76
+ const cwd = mkdtempSync(join(tmpdir(), 'sorb-codemod-'))
77
+ try {
78
+ const file = 'Button.legacy.jsx'
79
+ const abs = join(cwd, file)
80
+ writeFileSync(abs, fixtureSrc, 'utf-8')
81
+ const rows = autoRows(file)
82
+ const res = await runCodemod(rows, { cwd, write: true, allowBranch: true })
83
+ assert.equal(res.written, true)
84
+ const rewritten = readFileSync(abs, 'utf-8')
85
+ assert.notEqual(rewritten, fixtureSrc)
86
+ assert.ok(rewritten.includes('var(--button-primary-bg-default'))
87
+ assert.ok(rewritten.includes('#0F65EF')) // fallback preserved
88
+ // .bak preserves the original
89
+ const bak = join(cwd, '.sorb', 'bak', file + '.bak')
90
+ assert.ok(existsSync(bak))
91
+ assert.equal(readFileSync(bak, 'utf-8'), fixtureSrc)
92
+ // re-detect: the auto sites now read as var()
93
+ const after = detectHardcoded(rewritten, file)
94
+ assert.ok(!after.some((s) => s.role === 'bg' && s.raw === '#0F65EF'))
95
+ assert.ok(!after.some((s) => s.role === 'radius'))
96
+ } finally {
97
+ rmSync(cwd, { recursive: true, force: true })
98
+ }
99
+ })
100
+
101
+ test('runCodemod refuses to run on a default branch (main)', async () => {
102
+ // A temp git repo whose current branch is `main`.
103
+ const cwd = mkdtempSync(join(tmpdir(), 'sorb-codemod-main-'))
104
+ try {
105
+ const { execSync } = await import('child_process')
106
+ const git = (c) => execSync(c, { cwd, stdio: 'ignore' })
107
+ git('git init -q')
108
+ git('git symbolic-ref HEAD refs/heads/main')
109
+ // An initial commit so `rev-parse --abbrev-ref HEAD` resolves to 'main'
110
+ // (an unborn branch reports 'HEAD', not the branch name).
111
+ git('git -c user.email=t@t -c user.name=t commit -q --allow-empty -m init')
112
+ writeFileSync(join(cwd, 'Button.legacy.jsx'), fixtureSrc, 'utf-8')
113
+ const rows = autoRows('Button.legacy.jsx')
114
+ const res = await runCodemod(rows, { cwd, write: true }) // allowBranch NOT set
115
+ assert.equal(res.refused, true)
116
+ assert.match(res.reason, /default branch/)
117
+ } finally {
118
+ rmSync(cwd, { recursive: true, force: true })
119
+ }
120
+ })
@@ -0,0 +1,165 @@
1
+ // P0 — detectHardcoded: find hardcoded color/dimension style literals in
2
+ // consumer React source via the Babel AST.
3
+ //
4
+ // Reuses the SAME normalizers as the capture binder (annotateTokens.js) so a
5
+ // value the matcher would bind is exactly a value we flag — no drift.
6
+ //
7
+ // Detects three shapes:
8
+ // (a) JSX inline style={{ backgroundColor: '#0F65EF', borderRadius: 4 }}
9
+ // (b) styled-components / template-literal CSS styled.button`background:#0F65EF; border-radius:4px;`
10
+ // (c) CSS-Module-style string literals const s = { background: '#0F65EF' } (plain object props)
11
+ //
12
+ // A value already written as `var(--…)` is NOT hardcoded → skipped.
13
+
14
+ import { parse } from '@babel/parser'
15
+ import _traverse from '@babel/traverse'
16
+ import { normalizeColor, normalizeDimension } from '../annotateTokens.js'
17
+
18
+ // @babel/traverse ships as CJS with a `.default` interop under ESM.
19
+ const traverse = /** @type {any} */ (_traverse).default || _traverse
20
+
21
+ /**
22
+ * Map a CSS/JSX property name → matcher role. Accepts both kebab-case (CSS,
23
+ * styled-components) and camelCase (JSX inline style). Non-roled props → null
24
+ * (still detected, matched tier-only).
25
+ * @param {string} prop
26
+ * @returns {import('./types.js').AdaptRole}
27
+ */
28
+ export const propToRole = (prop) => {
29
+ const p = String(prop).trim().toLowerCase()
30
+ switch (p) {
31
+ case 'background':
32
+ case 'background-color':
33
+ case 'backgroundcolor':
34
+ return 'bg'
35
+ case 'color':
36
+ return 'text'
37
+ case 'border-color':
38
+ case 'bordercolor':
39
+ return 'border'
40
+ case 'border-radius':
41
+ case 'borderradius':
42
+ return 'radius'
43
+ default:
44
+ return null
45
+ }
46
+ }
47
+
48
+ // Properties whose values we even bother to inspect. Keep this generous: any
49
+ // prop that can carry a color or a dimension. We still only FLAG values that
50
+ // normalize to a color or dimension, so a non-style prop with a stray string
51
+ // won't false-positive (it won't normalize).
52
+ const STYLE_PROP_RE = /(color|background|border|radius|width|height|margin|padding|gap|top|left|right|bottom|fill|stroke|shadow|outline|size|spacing|inset)/i
53
+
54
+ const isVarRef = (raw) => /^var\(\s*--/i.test(String(raw).trim())
55
+
56
+ /**
57
+ * Is this raw literal a hardcoded color or dimension we should flag?
58
+ * Returns false for var(--…), non-color/non-dimension strings, etc.
59
+ * @param {string} raw
60
+ */
61
+ const isHardcodedValue = (raw) => {
62
+ if (raw == null) return false
63
+ const s = String(raw).trim()
64
+ if (s === '') return false
65
+ if (isVarRef(s)) return false
66
+ return normalizeColor(s) != null || normalizeDimension(s) != null
67
+ }
68
+
69
+ /**
70
+ * Parse source into a Babel AST. `jsx` + `typescript` plugins so .jsx AND .tsx
71
+ * both parse (we parse a consumer's TS source; we never emit TS).
72
+ * @param {string} source
73
+ */
74
+ export const parseSource = (source) =>
75
+ parse(source, {
76
+ sourceType: 'unambiguous',
77
+ plugins: ['jsx', 'typescript'],
78
+ errorRecovery: true,
79
+ })
80
+
81
+ /**
82
+ * Pull `{line, column}` from a Babel node, 1-based line / 0-based column.
83
+ * @param {any} node
84
+ */
85
+ const locOf = (node) =>
86
+ node && node.loc
87
+ ? { line: node.loc.start.line, column: node.loc.start.column }
88
+ : { line: 0, column: 0 }
89
+
90
+ /**
91
+ * Detect hardcoded color/dimension style sites in `source`.
92
+ * @param {string} source The file's source text.
93
+ * @param {string} filename The file path (recorded on each site).
94
+ * @returns {import('./types.js').AdaptSite[]}
95
+ */
96
+ export function detectHardcoded(source, filename) {
97
+ /** @type {import('./types.js').AdaptSite[]} */
98
+ const sites = []
99
+ const push = (prop, raw, node) => {
100
+ if (!isHardcodedValue(raw)) return
101
+ sites.push({
102
+ file: filename,
103
+ loc: locOf(node),
104
+ prop,
105
+ raw: String(raw),
106
+ role: propToRole(prop),
107
+ })
108
+ }
109
+
110
+ let ast
111
+ try {
112
+ ast = parseSource(source)
113
+ } catch (e) {
114
+ // A file we can't parse yields no sites rather than throwing — the adapter
115
+ // is a best-effort detector over a whole codebase. (catch (e), never {}.)
116
+ return sites
117
+ }
118
+
119
+ traverse(ast, {
120
+ // (a) JSX inline style={{ ... }} — ObjectProperty inside a JSXAttribute
121
+ // named "style". We detect object props anywhere named like a style prop,
122
+ // which also covers (c) plain CSS-Module-style style objects.
123
+ ObjectProperty(path) {
124
+ const keyNode = path.node.key
125
+ const prop =
126
+ keyNode.type === 'Identifier'
127
+ ? keyNode.name
128
+ : keyNode.type === 'StringLiteral'
129
+ ? keyNode.value
130
+ : null
131
+ if (!prop) return
132
+ if (!STYLE_PROP_RE.test(prop)) return
133
+ const v = path.node.value
134
+ if (v.type === 'StringLiteral') push(prop, v.value, v)
135
+ else if (v.type === 'NumericLiteral') push(prop, String(v.value), v)
136
+ },
137
+
138
+ // (b) styled-components / any tagged or untagged CSS template literal.
139
+ // Scan the static (quasi) chunks for `prop: value;` declarations and flag
140
+ // hardcoded color/dimension values. Interpolations (${...}) are skipped —
141
+ // they're already dynamic.
142
+ TemplateLiteral(path) {
143
+ for (const quasi of path.node.quasis) {
144
+ const text = quasi.value.cooked != null ? quasi.value.cooked : quasi.value.raw
145
+ if (!text) continue
146
+ // Match `prop: value` declarations (value up to ; or end of chunk).
147
+ const declRe = /([-a-zA-Z]+)\s*:\s*([^;{}]+)/g
148
+ let m
149
+ while ((m = declRe.exec(text)) !== null) {
150
+ const prop = m[1].trim()
151
+ if (!STYLE_PROP_RE.test(prop)) continue
152
+ // A declaration value can be multi-token (e.g. `1px solid #0F65EF`);
153
+ // inspect each whitespace-separated token for a color/dimension.
154
+ const value = m[2].trim()
155
+ const tokens = value.split(/\s+/)
156
+ for (const tok of tokens) {
157
+ if (isHardcodedValue(tok)) push(prop, tok, quasi)
158
+ }
159
+ }
160
+ }
161
+ },
162
+ })
163
+
164
+ return sites
165
+ }
@@ -0,0 +1,62 @@
1
+ // P0 acceptance: detectHardcoded finds all N known hardcoded sites in a legacy
2
+ // fixture (zero false negatives) and 0 sites in a fully-var() file (zero false
3
+ // positives). Run: node --test src/
4
+ import { test } from 'node:test'
5
+ import assert from 'node:assert/strict'
6
+ import { readFileSync } from 'fs'
7
+ import { fileURLToPath } from 'url'
8
+ import { dirname, join } from 'path'
9
+ import { detectHardcoded, propToRole } from './detectHardcoded.js'
10
+
11
+ const here = dirname(fileURLToPath(import.meta.url))
12
+ const fixture = (name) => readFileSync(join(here, '__fixtures__', name), 'utf-8')
13
+
14
+ test('propToRole maps CSS + JSX prop names to matcher roles', () => {
15
+ assert.equal(propToRole('background'), 'bg')
16
+ assert.equal(propToRole('background-color'), 'bg')
17
+ assert.equal(propToRole('backgroundColor'), 'bg')
18
+ assert.equal(propToRole('color'), 'text')
19
+ assert.equal(propToRole('border-color'), 'border')
20
+ assert.equal(propToRole('borderColor'), 'border')
21
+ assert.equal(propToRole('border-radius'), 'radius')
22
+ assert.equal(propToRole('borderRadius'), 'radius')
23
+ assert.equal(propToRole('padding'), null) // non-roled → tier-only
24
+ })
25
+
26
+ test('detectHardcoded finds all 8 known hardcoded sites in the legacy fixture', () => {
27
+ const src = fixture('Button.legacy.jsx')
28
+ const sites = detectHardcoded(src, 'Button.legacy.jsx')
29
+ assert.equal(sites.length, 8, `expected 8 sites, got ${sites.length}: ` +
30
+ JSON.stringify(sites.map((s) => `${s.prop}=${s.raw}`)))
31
+
32
+ // The specific bindings we depend on downstream.
33
+ const byRaw = (raw) => sites.filter((s) => s.raw === raw)
34
+ assert.ok(byRaw('#0F65EF').length >= 1) // inline bg + styled background
35
+ assert.ok(sites.some((s) => s.prop === 'backgroundColor' && s.role === 'bg'))
36
+ assert.ok(sites.some((s) => s.prop === 'borderColor' && s.role === 'border'))
37
+ assert.ok(sites.some((s) => s.prop === 'borderRadius' && s.raw === '4' && s.role === 'radius'))
38
+ assert.ok(sites.some((s) => s.prop === 'border-radius' && s.raw === '4px' && s.role === 'radius'))
39
+ assert.ok(sites.some((s) => s.role === 'text')) // white text
40
+ assert.ok(sites.some((s) => s.prop === 'padding' && s.role === null)) // non-roled
41
+ })
42
+
43
+ test('detectHardcoded records 1-based line numbers and the file path', () => {
44
+ const sites = detectHardcoded(fixture('Button.legacy.jsx'), 'X.jsx')
45
+ for (const s of sites) {
46
+ assert.equal(s.file, 'X.jsx')
47
+ assert.ok(s.loc.line > 0)
48
+ assert.ok(Number.isInteger(s.loc.column))
49
+ }
50
+ })
51
+
52
+ test('detectHardcoded finds 0 sites in a fully-var() (.tsx) file — no false positives', () => {
53
+ const src = fixture('Button.tokenized.tsx')
54
+ const sites = detectHardcoded(src, 'Button.tokenized.tsx')
55
+ assert.equal(sites.length, 0, `expected 0, got: ` +
56
+ JSON.stringify(sites.map((s) => `${s.prop}=${s.raw}`)))
57
+ })
58
+
59
+ test('detectHardcoded is resilient: unparseable source yields [] (no throw)', () => {
60
+ const sites = detectHardcoded('const x = (((;;;', 'broken.js')
61
+ assert.ok(Array.isArray(sites))
62
+ })
@@ -0,0 +1,87 @@
1
+ // Minimal dependency-free glob for the adapter's --src. Supports `**` (any
2
+ // depth), `*` (one segment), and brace alts `{a,b}`. Enough to resolve patterns
3
+ // like `src/**/*.{jsx,tsx,js,ts}` without pulling in a glob dependency.
4
+ import { readdirSync, statSync, existsSync } from 'fs'
5
+ import { join, sep } from 'path'
6
+
7
+ const IGNORE = new Set(['node_modules', '.git', '.sorb', 'dist', 'build', '.next'])
8
+
9
+ /** Expand `{a,b,c}` brace alternatives into multiple patterns. */
10
+ const expandBraces = (pattern) => {
11
+ const m = pattern.match(/\{([^{}]+)\}/)
12
+ if (!m) return [pattern]
13
+ const alts = m[1].split(',')
14
+ const out = []
15
+ for (const alt of alts) {
16
+ out.push(...expandBraces(pattern.replace(m[0], alt)))
17
+ }
18
+ return out
19
+ }
20
+
21
+ /** Compile a single glob (no braces) into a RegExp anchored to the full path. */
22
+ const toRegExp = (pattern) => {
23
+ let re = ''
24
+ for (let i = 0; i < pattern.length; i++) {
25
+ const c = pattern[i]
26
+ if (c === '*') {
27
+ if (pattern[i + 1] === '*') {
28
+ // `**` → any chars incl. path separators
29
+ re += '.*'
30
+ i++
31
+ if (pattern[i + 1] === '/') i++ // swallow the slash after **
32
+ } else {
33
+ re += '[^/]*'
34
+ }
35
+ } else if ('.+^${}()|[]\\'.includes(c)) {
36
+ re += '\\' + c
37
+ } else if (c === '/') {
38
+ re += '/'
39
+ } else {
40
+ re += c
41
+ }
42
+ }
43
+ return new RegExp('^' + re + '$')
44
+ }
45
+
46
+ /** Recursively list all files under a root (skipping IGNORE dirs). */
47
+ const walk = (root) => {
48
+ const out = []
49
+ const rec = (dir) => {
50
+ let entries
51
+ try {
52
+ entries = readdirSync(dir, { withFileTypes: true })
53
+ } catch (e) {
54
+ return
55
+ }
56
+ for (const ent of entries) {
57
+ if (IGNORE.has(ent.name)) continue
58
+ const full = join(dir, ent.name)
59
+ if (ent.isDirectory()) rec(full)
60
+ else if (ent.isFile()) out.push(full)
61
+ }
62
+ }
63
+ rec(root)
64
+ return out
65
+ }
66
+
67
+ /**
68
+ * Resolve a glob pattern (relative to cwd) to a list of matching file paths.
69
+ * Normalizes path separators to `/` for matching. If the pattern names a single
70
+ * existing file, returns just that file.
71
+ * @param {string} pattern
72
+ * @param {string} cwd
73
+ * @returns {string[]}
74
+ */
75
+ export function globFiles(pattern, cwd) {
76
+ // Direct file?
77
+ const direct = join(cwd, pattern)
78
+ if (existsSync(direct) && statSync(direct).isFile()) return [direct]
79
+
80
+ const patterns = expandBraces(pattern).map((p) => toRegExp(p))
81
+ const all = walk(cwd)
82
+ const norm = (p) => p.slice(cwd.length).replace(/^[/\\]/, '').split(sep).join('/')
83
+ return all.filter((f) => {
84
+ const rel = norm(f)
85
+ return patterns.some((re) => re.test(rel))
86
+ })
87
+ }