Skip to content

Commit d6c93e6

Browse files
authored
Merge pull request #26 from robinlopez/v.0.2.2
V.0.2.2
2 parents 1ca36c2 + 0eeacc8 commit d6c93e6

16 files changed

Lines changed: 829 additions & 70 deletions

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,22 @@
22

33
Format : [Keep a Changelog](https://keepachangelog.com/) — versionning [SemVer](https://semver.org/).
44

5+
## [0.2.2] — 2026-06-04
6+
7+
### Fixed
8+
- **TS/JS indexer — value-based object & leaf filtering** : each exported object is now classified by the *shape of its leaf values*. An object is kept only when its values are recognisable style primitives (colours, dimensions, aliases, unitless numbers, style keywords). Objects holding arbitrary application strings — event-name enums (`{ CREATED: 'ENTITY_CREATED' }`), config maps, JSON-Schema bodies (`type: 'object'`, `minimum: 0`) — are dropped wholesale. Within a kept object, individual non-style leaves are also dropped: a status-config map (`{ PICKING: { label: 'En picking', variant: 'info', bg: '#e8ecf5', color: '#4563a0' } }`) now indexes only the colours, no longer the `label` / `variant` strings. Closes [#24](https://github.com/robinlopez/token-flow/issues/24).
9+
- **TS/JS indexer — JSON Schema files skipped** : files containing JSON Schema vocabulary keys (`$schema`, `$defs`, `$ref`) — quoted or unquoted — are classified `Mode.NONE` and bypass the parser entirely. Schema paths (`$defs.WidgetSlot.properties.flex.minimum`) and `$ref` strings (`'#/$defs/LayoutRow'`) no longer appear as pseudo-tokens.
10+
- **TS/JS indexer — Storybook / test files skipped** : files matching `*.stories.*`, `*.spec.*`, `*.test.*` or importing from `@storybook/*` are excluded before parsing.
11+
- **SCSS indexer — local variables skipped** : `$variables` declared inside any block (`@function`, `@mixin`, `@each`, selector…) are recognised as local Sass helpers and no longer indexed. Only brace-depth-0 declarations are treated as tokens.
12+
- **SCSS / CSS indexer — BEM modifiers no longer captured as variables** : the `--name` segment of a BEM selector was being captured by the custom-property regex, producing fake tokens (`--closeable: hover`, `--selected: not(...)`). The negative lookbehind now also excludes the SCSS parent-ref form `&--selected:not(.x)`, and a value containing `{` (a captured selector) is rejected outright. `--` is only treated as a token in a real property declaration or a `var(--…)` call. Closes [#25](https://github.com/robinlopez/token-flow/issues/25).
13+
- **Suggestion engine — no circular self-reference** : a token definition flagged as hardcoded no longer suggests itself as the replacement. `--color-bg-page: #e5e9eb` used to offer `var(--color-bg-page)` (which would produce `--color-bg-page: var(--color-bg-page)`); the engine now excludes the token currently being declared from its own suggestion list, across CSS (`--x`), SCSS (`$x`) and JS object-path (`colors.bg`) declarations. Closes [#23](https://github.com/robinlopez/token-flow/issues/23).
14+
15+
### Changed
16+
- **Analyser — "Active editor" scope option removed** : the Scope picker in the Analyser toolbar no longer carries an *Active editor (…)* entry, and the panel no longer follows the active editor. The analysed scope is now a purely explicit, user-driven choice (*All project* by default, plus every configured scope), which keeps the report stable as you navigate between files. The Library and Hardcoded Values panels keep their active-editor follow-along behaviour unchanged. Closes [#21](https://github.com/robinlopez/token-flow/issues/21).
17+
18+
### Internal
19+
- Unit-test suites : `StyleValueHeuristicsTest` and `JsParserFalsePositiveTest` cover the value classifier and the three false-positive families from [#24](https://github.com/robinlopez/token-flow/issues/24). `CssVarRegexTest` locks the BEM-safe behaviour from [#25](https://github.com/robinlopez/token-flow/issues/25). `SuggestionEngineSelfReferenceTest` locks the no-self-reference behaviour from [#23](https://github.com/robinlopez/token-flow/issues/23).
20+
521
## [0.2.1] — 2026-05-29
622

723
### Added

build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ plugins {
66
}
77

88
group = "fr.fsh.tokendesigner"
9-
version = "0.2.1"
9+
version = "0.2.2"
1010

1111
repositories {
1212
mavenCentral()

src/main/kotlin/fr/fsh/tokendesigner/inspection/SuggestionEngine.kt

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,43 @@ object SuggestionEngine {
4444
allTokens: List<DesignToken>,
4545
expectedCategory: TokenCategory?,
4646
expectedRole: TokenRole? = null,
47+
): List<TokenSuggestion> {
48+
val ranked = rankSuggestions(hit, valueIndex, allTokens, expectedCategory, expectedRole)
49+
// Issue #23 — never suggest the token currently being *defined* as the
50+
// replacement for its own literal: `--color-bg-page: #e5e9eb` must not
51+
// offer `var(--color-bg-page)` (a self-reference loop). The declaring
52+
// name comes verbatim from the Hit; synthetic hits (whole-file scan,
53+
// recursive fallback lookups) carry no declaration name, so this is a
54+
// no-op there.
55+
val declaring = hit.declarationName ?: return ranked
56+
return ranked.filter { !isSelfReference(it.token.name, declaring) }
57+
}
58+
59+
/**
60+
* `true` when [tokenName] denotes the same token as the one being declared
61+
* ([declarationName]). Handles the three syntaxes the declaration walker
62+
* emits: `--name` (CSS), `$name` (SCSS) and a bare leaf key (JS/JSON object
63+
* path, where the token name is the full dotted path `colors.bg` but the
64+
* declaration name is just `bg`).
65+
*/
66+
private fun isSelfReference(tokenName: String, declarationName: String): Boolean {
67+
val t = tokenName.trim()
68+
val d = declarationName.trim()
69+
if (t.equals(d, ignoreCase = true)) return true
70+
fun strip(s: String) = s.removePrefix("--").removePrefix("$")
71+
val ts = strip(t)
72+
val ds = strip(d)
73+
if (ts.equals(ds, ignoreCase = true)) return true
74+
// JS object path: declaration name is the trailing key only.
75+
return ds.isNotEmpty() && ts.endsWith(".$ds", ignoreCase = true)
76+
}
77+
78+
private fun rankSuggestions(
79+
hit: LiteralFinder.Hit,
80+
valueIndex: TokenValueIndex,
81+
allTokens: List<DesignToken>,
82+
expectedCategory: TokenCategory?,
83+
expectedRole: TokenRole? = null,
4784
): List<TokenSuggestion> {
4885
// Prefer the CSS-property-derived category over the literal's natural
4986
// one when known: `padding: 6px` looks up under SPACING, `font-size:

src/main/kotlin/fr/fsh/tokendesigner/scanner/DynamicCssVarIndex.kt

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -124,13 +124,13 @@ class DynamicCssVarIndex(private val project: Project) {
124124
/**
125125
* A bare `--name: value` custom-property declaration inside a CSS
126126
* rule body. Group 1 = name, group 2 = value (everything up to the
127-
* statement terminator). The negative lookbehind on `[A-Za-z0-9_-]`
128-
* rules out BEM identifiers like `.block__elem--mod:hover` — the
129-
* `--mod` there is preceded by an alphanumeric character so the
130-
* regex skips it.
127+
* statement terminator). The negative lookbehind on `[A-Za-z0-9_&-]`
128+
* rules out BEM identifiers in selectors — `.block__elem--mod:hover`
129+
* (preceded by an alphanumeric) *and* SCSS parent-ref modifiers like
130+
* `&--selected:not(.x)` (preceded by `&`). See issue #25.
131131
*/
132132
private val CSS_DECL = Regex(
133-
"(?<![A-Za-z0-9_-])--([A-Za-z_][A-Za-z0-9_-]*)\\s*:\\s*([^;\\n}]*)"
133+
"(?<![A-Za-z0-9_&-])--([A-Za-z_][A-Za-z0-9_-]*)\\s*:\\s*([^;\\n}]*)"
134134
)
135135

136136
fun getInstance(project: Project): DynamicCssVarIndex =

src/main/kotlin/fr/fsh/tokendesigner/scanner/JsObjectTokenParser.kt

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,17 +22,29 @@ object JsObjectTokenParser {
2222

2323
data class Leaf(val path: String, val value: String, val offset: Int)
2424

25-
fun parse(text: CharSequence): List<Leaf> {
26-
val out = mutableListOf<Leaf>()
25+
/** Flattened leaves across every top-level exported object. */
26+
fun parse(text: CharSequence): List<Leaf> = parseGroups(text).flatten()
27+
28+
/**
29+
* Like [parse] but keeps each top-level `export const|default` object's
30+
* leaves in its own list. Callers that classify a *whole object* as a
31+
* token dictionary vs. an application config object (see
32+
* [StyleValueHeuristics]) need this grouping so they can keep or drop each
33+
* exported object independently rather than mixing their leaves.
34+
*/
35+
fun parseGroups(text: CharSequence): List<List<Leaf>> {
36+
val groups = mutableListOf<List<Leaf>>()
2737
// Walk every top-level `export const|default` exported object.
2838
val regex = Regex("\\bexport\\s+(?:default|const\\s+\\w+)\\s*[:=]?\\s*")
2939
for (match in regex.findAll(text)) {
3040
var i = match.range.last + 1
3141
i = skipWs(text, i)
3242
if (i >= text.length || text[i] != '{') continue
33-
walkObject(text, i + 1, ArrayDeque(), out)
43+
val group = mutableListOf<Leaf>()
44+
walkObject(text, i + 1, ArrayDeque(), group)
45+
if (group.isNotEmpty()) groups += group
3446
}
35-
return out
47+
return groups
3648
}
3749

3850
/**
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
package fr.fsh.tokendesigner.scanner
2+
3+
/**
4+
* Value-shape heuristics that tell a *design-token tree* apart from a plain
5+
* application/config object.
6+
*
7+
* The TS/JS indexer is structural — it walks any `export const X = { … }` and
8+
* records every `path → value` leaf. That alone cannot distinguish a token
9+
* dictionary from arbitrary application data: a JSON-Schema definition, an
10+
* event-name enum (`{ CREATED: 'ENTITY_CREATED' }`) or a Storybook `args`
11+
* object are all *shaped* like token files.
12+
*
13+
* The discriminator used here is the **value**, not the shape: a design token,
14+
* by definition, holds a *style primitive* — a colour, a dimension, an alias
15+
* reference, or a bare number (unitless spacing / font-weight / line-height).
16+
* An object whose values are predominantly arbitrary strings
17+
* (`'ENTITY_CREATED'`, `'string'`, `'#/$defs/LayoutRow'`) is almost certainly
18+
* not a token dictionary and is dropped wholesale.
19+
*
20+
* Deliberately conservative: an object is kept whenever it carries a real
21+
* style signal, so false-negatives (dropping a genuine token object) are
22+
* limited to the rare pure-prose or single-font-family object.
23+
*/
24+
object StyleValueHeuristics {
25+
26+
enum class ValueClass { STYLE, NUMERIC, NON_STYLE, EMPTY }
27+
28+
/** Style-Dictionary alias literal: `{primitive.primary.500}`. */
29+
private val ALIAS_STYLE_DICT = Regex("^\\{[A-Za-z_][\\w.\\-]*}$")
30+
31+
/** Bare runtime property-access alias: `colors.PRIMARY_500` (≥ 1 dot). */
32+
private val RUNTIME_REF = Regex("^[A-Za-z_$][\\w$]*(?:\\.[A-Za-z_$0-9][\\w$]*)+$")
33+
34+
/** Pure number, optionally signed / decimal: `8`, `-2`, `1.5`, `.25`. */
35+
private val PURE_NUMBER = Regex("^-?\\d*\\.?\\d+$")
36+
37+
/** Hex colour `#abc` … `#aabbcc88`, *not* preceded/followed by more hex. */
38+
private val HEX_COLOR = Regex("(?<![0-9a-fA-F#])#[0-9a-fA-F]{3,8}(?![0-9a-fA-F])")
39+
40+
/** Colour functions: `rgb(`, `rgba(`, `hsl(`, `hsla(`. */
41+
private val COLOR_FN = Regex("(?i)\\b(?:rgba?|hsla?)\\s*\\(")
42+
43+
/** A number immediately followed by a CSS length / time / angle unit, or `%`. */
44+
private val DIMENSION = Regex(
45+
"(?i)(?<![\\w.])\\d*\\.?\\d+" +
46+
"(?:px|rem|em|vh|vw|vmin|vmax|vb|vi|pt|pc|cm|mm|in|ex|ch|fr|deg|rad|grad|turn|ms|s|dpi|dpcm|dppx)(?![\\w])" +
47+
"|(?<![\\w.])\\d*\\.?\\d+%"
48+
)
49+
50+
/** Style-producing CSS functions (shadows, gradients, calc, transforms…). */
51+
private val STYLE_FN = Regex(
52+
"(?i)\\b(?:var|calc|min|max|clamp|env|url|" +
53+
"linear-gradient|radial-gradient|conic-gradient|repeating-linear-gradient|" +
54+
"translate[xyz3d]*|rotate[xyz]?|scale[xyz]?|skew[xy]?|matrix|perspective|cubic-bezier)\\s*\\("
55+
)
56+
57+
/**
58+
* Single-word values that are legitimate style keywords (font weights,
59+
* border styles, text transforms, common globals). Kept intentionally
60+
* narrow — layout words like `center`/`row`/`block` are *excluded* because
61+
* they show up just as often in application config and would let
62+
* non-token objects slip through. Their presence lets a numeric typography
63+
* object (`{ fontSize: 16, fontWeight: 'bold' }`) survive.
64+
*/
65+
private val STYLE_KEYWORDS = setOf(
66+
"bold", "bolder", "lighter", "normal", "italic", "oblique",
67+
"thin", "light", "regular", "medium", "semibold", "semi-bold", "heavy", "black",
68+
"solid", "dashed", "dotted", "double", "groove", "ridge", "inset", "outset",
69+
"none", "auto", "transparent", "currentcolor",
70+
"inherit", "initial", "unset",
71+
"uppercase", "lowercase", "capitalize",
72+
"ease", "ease-in", "ease-out", "ease-in-out", "linear",
73+
)
74+
75+
/** Classifies a single leaf value (quotes already stripped by the parser). */
76+
fun classify(raw: String): ValueClass {
77+
val v = raw.trim()
78+
if (v.isEmpty()) return ValueClass.EMPTY
79+
if (ALIAS_STYLE_DICT.matches(v) || RUNTIME_REF.matches(v)) return ValueClass.STYLE
80+
if (PURE_NUMBER.matches(v)) return ValueClass.NUMERIC
81+
if (HEX_COLOR.containsMatchIn(v) ||
82+
COLOR_FN.containsMatchIn(v) ||
83+
DIMENSION.containsMatchIn(v) ||
84+
STYLE_FN.containsMatchIn(v)
85+
) {
86+
return ValueClass.STYLE
87+
}
88+
if (v.lowercase() in STYLE_KEYWORDS) return ValueClass.STYLE
89+
return ValueClass.NON_STYLE
90+
}
91+
92+
/**
93+
* Leaf-level gate applied *after* [looksLikeTokenObject] has accepted the
94+
* surrounding object. A token dictionary can still hold non-style metadata
95+
* next to its real values — e.g. a status-config map keyed by enum value
96+
* (`{ PICKING: { label: 'En picking', color: '#4563a0' } }`) carries both
97+
* a colour (a token) and a human label (not a token). We keep only the
98+
* leaves whose value is itself a style primitive or a number, dropping the
99+
* arbitrary-string siblings (`label`, `variant`, …). See issue #24.
100+
*/
101+
fun isIndexableLeafValue(raw: String): Boolean =
102+
when (classify(raw)) {
103+
ValueClass.STYLE, ValueClass.NUMERIC -> true
104+
ValueClass.NON_STYLE, ValueClass.EMPTY -> false
105+
}
106+
107+
/**
108+
* Decides whether the leaf [values] of one exported object look like a
109+
* design-token dictionary.
110+
*
111+
* - Has at least one genuine style value → keep, *provided* style values
112+
* are not heavily outnumbered by arbitrary strings (`style >= nonStyle`).
113+
* This keeps colour/dimension/alias objects (with the odd stray label)
114+
* while rejecting a config object that merely contains one stray colour.
115+
* - No style value, but purely numeric (no arbitrary strings) → keep:
116+
* unitless spacing / weight / line-height scales are legitimate.
117+
* - Otherwise (arbitrary-string-dominated, e.g. an event-name enum or a
118+
* JSON-Schema body) → drop.
119+
*/
120+
fun looksLikeTokenObject(values: List<String>): Boolean {
121+
var style = 0
122+
var numeric = 0
123+
var nonStyle = 0
124+
for (raw in values) {
125+
when (classify(raw)) {
126+
ValueClass.STYLE -> style++
127+
ValueClass.NUMERIC -> numeric++
128+
ValueClass.NON_STYLE -> nonStyle++
129+
ValueClass.EMPTY -> {}
130+
}
131+
}
132+
if (style + numeric + nonStyle == 0) return false
133+
return if (style > 0) style >= nonStyle else (nonStyle == 0 && numeric > 0)
134+
}
135+
}

src/main/kotlin/fr/fsh/tokendesigner/scanner/TokenScanner.kt

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,23 @@ class TokenScanner(private val project: Project) {
128128
sink: MutableList<RawToken>,
129129
) {
130130
if (isScss) {
131+
// Only top-level `$var:` declarations are design tokens. Variables
132+
// declared inside a `{ … }` block (`@function`, `@mixin`, `@each`,
133+
// a selector, …) are locally-scoped Sass helpers — e.g. `$map`,
134+
// `$value` reused across a loop — and must not pollute the index
135+
// (issue #24). We gate on brace depth, advancing a running counter
136+
// over the gaps between successive matches (matches are ordered).
137+
var pos = 0
138+
var depth = 0
131139
for (m in SCSS_VAR_REGEX.findAll(text)) {
140+
while (pos < m.range.first) {
141+
when (text[pos]) {
142+
'{' -> depth++
143+
'}' -> if (depth > 0) depth--
144+
}
145+
pos++
146+
}
147+
if (depth > 0) continue
132148
val raw = m.groupValues[2].trim().trimEnd(';').trim()
133149
val cleanedRaw = raw.replace(Regex("(?i)!(default|global|important)\\s*$"), "").trim()
134150
sink += RawToken(
@@ -150,6 +166,10 @@ class TokenScanner(private val project: Project) {
150166
}
151167
}
152168
for (m in CSS_VAR_REGEX.findAll(text)) {
169+
// A real custom-property value never contains a `{` — its presence
170+
// means we captured a selector that slipped past the lookbehind
171+
// (e.g. a BEM modifier spanning to the rule's opening brace). Skip.
172+
if ('{' in m.groupValues[2]) continue
153173
val raw = m.groupValues[2].trim().trimEnd(';').trim()
154174
val cleanedRaw = raw.replace(Regex("(?i)!important\\s*$"), "").trim()
155175
sink += RawToken(
@@ -163,8 +183,14 @@ class TokenScanner(private val project: Project) {
163183
}
164184

165185
private fun extractJsLike(text: CharSequence, path: String, sink: MutableList<RawToken>) {
186+
val fileName = path.substringAfterLast('/')
187+
// Storybook stories, test files, and config files never contain design tokens.
188+
if (".stories." in fileName || ".spec." in fileName || ".test." in fileName) return
189+
166190
val parsed = JsTokenFileParserRegistry.parseFull(text)
167-
val kind = JsTokenFileParserRegistry.parserFor(parsed.mode).kind
191+
if (parsed.mode == JsTokenFileParserRegistry.Mode.NONE) return
192+
193+
val kind = JsTokenFileParserRegistry.parserFor(parsed.mode)!!.kind
168194
for (leaf in parsed.leaves) {
169195
sink += RawToken(
170196
name = leaf.path,
@@ -378,8 +404,14 @@ class TokenScanner(private val project: Project) {
378404
private val SCSS_VAR_REGEX = Regex(
379405
"(?m)^\\s*\\$([A-Za-z_][A-Za-z0-9_-]*)\\s*:\\s*([^;\\n]+)\\s*;?"
380406
)
407+
// CSS custom-property declaration: `--name: value`. The negative
408+
// lookbehind on `[A-Za-z0-9_&-]` rules out BEM modifier syntax inside
409+
// selectors — both `.block__slot--closeable:hover` (preceded by an
410+
// alphanumeric) and the SCSS parent-ref form `&--selected:not(.x)`
411+
// (preceded by `&`) would otherwise be captured as fake CSS variables.
412+
// See issue #25. The same guard protects `DynamicCssVarIndex.CSS_DECL`.
381413
private val CSS_VAR_REGEX = Regex(
382-
"--([A-Za-z_][A-Za-z0-9_-]*)\\s*:\\s*([^;}\\n]+)\\s*;?"
414+
"(?<![A-Za-z0-9_&-])--([A-Za-z_][A-Za-z0-9_-]*)\\s*:\\s*([^;}\\n]+)\\s*;?"
383415
)
384416
// SCSS map keys: `"<token-name>": <value>,` where token names are
385417
// lowercase-hyphenated. The trailing comma keeps the pattern specific

0 commit comments

Comments
 (0)