@sorb/seed 0.2.0 → 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.
- package/package.json +7 -3
- package/src/adapt/__fixtures__/Button.legacy.jsx +38 -0
- package/src/adapt/__fixtures__/Button.tokenized.tsx +28 -0
- package/src/adapt/__fixtures__/corpus/button-inline.case.json +10 -0
- package/src/adapt/__fixtures__/corpus/card-styled.case.json +9 -0
- package/src/adapt/__fixtures__/corpus/misc-unmapped.case.json +7 -0
- package/src/adapt/adaptCli.js +110 -0
- package/src/adapt/adaptCli.test.js +109 -0
- package/src/adapt/benchmark.js +113 -0
- package/src/adapt/benchmark.test.js +79 -0
- package/src/adapt/codemod.js +160 -0
- package/src/adapt/codemod.test.js +120 -0
- package/src/adapt/detectHardcoded.js +165 -0
- package/src/adapt/detectHardcoded.test.js +62 -0
- package/src/adapt/glob.js +87 -0
- package/src/adapt/mapToToken.js +94 -0
- package/src/adapt/mapToToken.test.js +103 -0
- package/src/adapt/report.js +53 -0
- package/src/adapt/report.test.js +66 -0
- package/src/adapt/runBenchmark.js +16 -0
- package/src/adapt/types.js +48 -0
- package/src/captureCli.js +38 -139
- package/src/cli.js +16 -1
- package/src/emit/sorbFormat.js +313 -0
- package/src/emit/sorbFormat.test.js +133 -0
- package/src/index.js +27 -0
- package/src/sources/figmaPlugin.js +83 -0
- package/src/sources/figmaPlugin.test.js +65 -0
- package/src/sources/storybookDom.js +188 -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
|
+
}
|