Skip to content

Commit 58f7453

Browse files
authored
Merge pull request #22 from robinlopez/v.0.2.1
V.0.2.1
2 parents 26c8250 + 26e07f0 commit 58f7453

7 files changed

Lines changed: 336 additions & 59 deletions

File tree

CHANGELOG.md

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

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

5+
## [0.2.1] — 2026-05-29
6+
7+
### Added
8+
- **Analyser — sticky section headers** : while scrolling through a long report, the header of the section currently in view pins itself at the top of the panel. The sticky strip mirrors the section title and count — you always know which section you are reading without scrolling back to the heading.
9+
10+
### Changed
11+
- **Analyser — section order** : *Broken references* is now the first section — these are genuine bugs (broken `var(--…)` / `$var` references) and should be triaged before the lower-urgency hardcoded sections. *Hardcoded clusters* and *Hardcoded values* follow immediately after.
12+
- **Analyser — occurrence rows show `filename:line · property` instead of parent folder** : the per-file list inside an expanded hardcoded row now shows the CSS/JS property the literal is bound to (`Button.scss:42 · padding`) instead of echoing the parent directory name. The fixed 28 px height cap that clipped long labels has been removed.
13+
- **Analyser — no more 50-row ceiling on hardcoded results** : the hard cap of 50 at the analyser layer has been removed. Truncation is now handled exclusively by the `+ N more…` expander in the UI, so the count badge always matches the real number of detected items.
14+
15+
### Fixed
16+
- **Analyser — token definitions no longer flagged as hardcoded** : literal values that are part of a token declaration (SCSS `$var: value`, CSS `--var: value`, JSON / JS object entries such as `"sm": "8px"`) are now correctly recognised as definitions and excluded from both *Hardcoded clusters* and *Hardcoded values*. Declaration detection has been extended to quoted JSON / JS object keys (`"borderRadius": "8px"`, `"sm": 8`).
17+
- **Analyser — pure token-catalog files fully excluded from hardcoded scan** : `.ts/.tsx/.js/.jsx/.json` files registered as token sources are now skipped entirely in the hardcoded scan, eliminating false positives from design-token catalog files.
18+
- **Analyser — TS / JS files only receive format-compatible token suggestions** : CSS custom-property suggestions (`var(--…)`) are no longer offered when the occurrence lives in a `.ts`/`.tsx`/`.js`/`.jsx` source. Only JS-reachable token kinds (object-path, runtime property access, helper call) are surfaced.
19+
520
## [0.2.0] — 2026-05-22
621

722
### 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.0"
9+
version = "0.2.1"
1010

1111
repositories {
1212
mavenCentral()

src/main/kotlin/fr/fsh/tokendesigner/analyze/AnalysisReport.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,13 @@ data class HardcodedOccurrence(
104104
val filePath: String,
105105
val offset: Int,
106106
val line: Int,
107+
/**
108+
* CSS / JS property name the literal is assigned to, when detectable
109+
* (e.g. `padding`, `font-size`, `borderRadius`). Surfaced in the
110+
* dashboard's per-occurrence rows so the user sees WHY the literal was
111+
* picked up instead of a redundant parent-folder echo.
112+
*/
113+
val propertyName: String? = null,
107114
)
108115

109116
data class Coverage(

src/main/kotlin/fr/fsh/tokendesigner/analyze/DesignSystemAnalyzer.kt

Lines changed: 75 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -539,18 +539,42 @@ class DesignSystemAnalyzer(private val project: Project) {
539539
val offset: Int,
540540
val kind: LiteralFinder.Kind,
541541
val expectedRole: fr.fsh.tokendesigner.model.TokenRole?,
542+
val propertyName: String?,
542543
)
543544
val byBucket = LinkedHashMap<BucketKey, MutableList<Hit>>()
544545
val tokenNames = tokens.map { it.name }.toSet()
546+
// Pure-declaration files: paths that *only* declare tokens (JSON/TS/JS
547+
// catalogs). Their entire content is a token definition, so flagging
548+
// any literal inside as "hardcoded" is pure noise. SCSS / CSS / Vue
549+
// are left in the scan even when they declare tokens, because they
550+
// commonly mix declarations with consumption rules.
551+
val pureDeclarationExtensions = setOf("ts", "tsx", "js", "jsx", "json")
552+
val pureDeclarationSources = tokens.asSequence()
553+
.filter { it.filePath.substringAfterLast('.').lowercase() in pureDeclarationExtensions }
554+
.map { it.filePath }
555+
.toSet()
545556

546557
for (vf in scannedFiles) {
558+
if (vf.path in pureDeclarationSources) continue
547559
val text = try {
548560
readAction { VfsUtilCore.loadText(vf) }
549561
} catch (_: Exception) { continue }
550562
for (h in LiteralFinder.findIn(text)) {
551563
if (h.insidePartialString) continue
552564
if (h.kind == LiteralFinder.Kind.REFERENCE) continue
553-
if (h.isDeclaration && (!inspectVariableDeclarations || (h.declarationName != null && h.declarationName in tokenNames))) continue
565+
// Skip token-map values (SCSS `$map: (...)` entries) and any
566+
// detected variable / object-property declaration. The latter
567+
// bypasses `inspectVariableDeclarations` when the declaration
568+
// sits in a token-source file — those declarations are *never*
569+
// consumption, so the setting shouldn't unhide them.
570+
if (h.insideTokenMap) continue
571+
val isCatalogFile = vf.path in pureDeclarationSources
572+
if (h.isDeclaration && (
573+
isCatalogFile ||
574+
!inspectVariableDeclarations ||
575+
(h.declarationName != null && h.declarationName in tokenNames)
576+
)
577+
) continue
554578

555579
val literal = h.text.lowercase()
556580
if (literal in ignoredNames) continue
@@ -560,11 +584,12 @@ class DesignSystemAnalyzer(private val project: Project) {
560584
// still get a coarse bucket.
561585
val ctxCategory = PropertyContext.detectAt(text, h.startOffset)
562586
val role = PropertyContext.detectRoleAt(text, h.startOffset)
587+
val propertyName = PropertyContext.detectPropertyNameAt(text, h.startOffset)
563588
val category = ctxCategory ?: kindFallbackCategory(h.kind)
564589
val key = BucketKey(literal, category)
565590

566591
byBucket.getOrPut(key) { mutableListOf() }
567-
.add(Hit(vf, lineFor(text, h.startOffset), h.startOffset, h.kind, role))
592+
.add(Hit(vf, lineFor(text, h.startOffset), h.startOffset, h.kind, role, propertyName))
568593
}
569594
}
570595

@@ -591,9 +616,12 @@ class DesignSystemAnalyzer(private val project: Project) {
591616
// the bucket has no detected category (JS object, no CSS context)
592617
// accept any exact match.
593618
val exact = suggestions.firstOrNull { it.exact &&
594-
(category == null || it.token.category == category) }?.token
619+
(category == null || it.token.category == category) &&
620+
isTokenUsableInAll(it.token, occurrences.map { it.file }) }?.token
595621

596-
val occList = occurrences.map { HardcodedOccurrence(it.file.path, it.offset, it.line) }
622+
val occList = occurrences.map {
623+
HardcodedOccurrence(it.file.path, it.offset, it.line, it.propertyName)
624+
}
597625

598626
if (exact != null) {
599627
// A token already covers this literal → actionable debt.
@@ -613,12 +641,47 @@ class DesignSystemAnalyzer(private val project: Project) {
613641
}
614642
}
615643

644+
// No `.take(…)` cap here: the UI uses a `+more` expander to truncate
645+
// the visible list, so the full set must reach the report — otherwise
646+
// a hard 50-row ceiling at the analyser layer artificially capped the
647+
// counter even when many more hits existed.
616648
return HardcodedScan(
617-
clusters = clusters.sortedByDescending { it.occurrences.size }.take(50),
618-
values = values.sortedByDescending { it.occurrences.size }.take(50),
649+
clusters = clusters.sortedByDescending { it.occurrences.size },
650+
values = values.sortedByDescending { it.occurrences.size },
619651
)
620652
}
621653

654+
/**
655+
* True when [token] can be referenced from every file in [files] given
656+
* the file extension's binding format. CSS custom properties / SCSS
657+
* variables aren't reachable from a `.ts` / `.tsx` / `.js` / `.jsx`
658+
* source — suggesting a `var(--foo)` token in a React Native style
659+
* object is wrong because that file can't resolve CSS variables. Only
660+
* JS-flavoured token kinds (object paths, runtime property accesses,
661+
* runtime helpers) are valid there.
662+
*
663+
* Returns true when [files] is empty so an exact match always survives
664+
* if no occurrence files were captured (shouldn't happen in practice).
665+
*/
666+
private fun isTokenUsableInAll(
667+
token: DesignToken,
668+
files: List<VirtualFile>,
669+
): Boolean {
670+
if (files.isEmpty()) return true
671+
return files.all { isTokenUsableIn(token, it.extension?.lowercase()) }
672+
}
673+
674+
private fun isTokenUsableIn(token: DesignToken, ext: String?): Boolean = when (ext) {
675+
"ts", "tsx", "js", "jsx" -> token.kind in JS_BINDABLE_KINDS
676+
"json" -> token.kind in JS_BINDABLE_KINDS
677+
"css" -> token.kind == fr.fsh.tokendesigner.model.TokenKind.CSS_CUSTOM_PROPERTY
678+
// SCSS / Sass / Vue: anything goes — these files routinely consume
679+
// both `$var` and `var(--…)`, and `<script>` blocks in Vue accept
680+
// JS-style tokens too.
681+
"scss", "sass", "vue", "html", null -> true
682+
else -> true
683+
}
684+
622685
private fun kindFallbackCategory(kind: LiteralFinder.Kind): TokenCategory? = when (kind) {
623686
LiteralFinder.Kind.COLOR -> TokenCategory.COLOR
624687
LiteralFinder.Kind.LENGTH -> TokenCategory.SPACING
@@ -746,6 +809,12 @@ class DesignSystemAnalyzer(private val project: Project) {
746809
companion object {
747810
private const val MIN_HARDCODED_CLUSTER = 2
748811
private val COVERAGE_EXTS = listOf("scss", "sass", "css", "vue", "ts", "tsx", "js", "jsx")
812+
/** Token kinds reachable from a `.ts` / `.tsx` / `.js` / `.jsx` / `.json` source. */
813+
private val JS_BINDABLE_KINDS = setOf(
814+
fr.fsh.tokendesigner.model.TokenKind.JS_OBJECT_PATH,
815+
fr.fsh.tokendesigner.model.TokenKind.JS_RUNTIME_PROPERTY,
816+
fr.fsh.tokendesigner.model.TokenKind.JS_RUNTIME_FUNCTION,
817+
)
749818
// Each pattern's group 1 captures the bare token name so we can fold
750819
// the references into a single `Set<String>` for unused-token detection.
751820
private val CSS_REF = Regex("var\\(\\s*--([A-Za-z_][A-Za-z0-9_-]*)(?:\\s*,.*)?\\)")

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

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -154,9 +154,10 @@ object LiteralFinder {
154154
if (isWhitelisted(value)) continue
155155
if (isIgnored(start)) continue
156156
if (out.any { it.startOffset == start }) continue
157-
// If the key starts with $ or is --, it's a variable declaration.
158-
val key = m.value.substringBefore(':').trim()
159-
val declName = if (key.startsWith("$") || key.startsWith("--")) key else null
157+
// Reuse the same declaration detector as the unit-bearing literals
158+
// so RN/JS catalog files (`sm: 8`, `"sm": 8`) are folded into the
159+
// declaration bucket exactly like `8px` and skipped consistently.
160+
val declName = variableDeclarationName(text, start)
160161
out += Hit(
161162
value, start, end, Kind.NUMBER,
162163
isDeclaration = declName != null,
@@ -444,16 +445,55 @@ object LiteralFinder {
444445
* Checks if the literal at [offset] is part of a variable declaration
445446
* like `$name: ...` or `--name: ...`, and returns the variable name.
446447
*/
448+
/**
449+
* Walks back from a literal hit to decide whether it sits in a variable /
450+
* property *declaration* (token definition) rather than a *consumption*
451+
* site. Recognised shapes:
452+
*
453+
* • SCSS: `$name: <literal>` → returns `$name`
454+
* • CSS: `--name: <literal>` → returns `--name`
455+
* • JS/TS object literal: → returns the bare key
456+
* - `name: "<literal>"` or `name: '<literal>'`
457+
* - `"name": "<literal>"` / `'name': '<literal>'`
458+
* - `"name": <literal>` (numeric value)
459+
*
460+
* For a *bare* key with a *bare* numeric value (e.g. `padding: 12px` in a
461+
* CSS block) we deliberately return `null`. That shape is ambiguous —
462+
* either a CSS rule (definite consumption, should be flagged) or a
463+
* RN-style object property (could be either). We err on flagging so real
464+
* CSS hardcoded values still surface; the token-source file whole-skip
465+
* (see `DesignSystemAnalyzer.collectHardcodedScan`) handles the few false
466+
* positives that remain in TS catalog files.
467+
*/
447468
private fun variableDeclarationName(text: CharSequence, offset: Int): String? {
448469
var i = offset - 1
449470
while (i >= 0 && text[i].isWhitespace()) i--
471+
472+
// Optional opening string quote — the literal lives inside `"…"` /
473+
// `'…'` / `` `…` ``. This is the strong JS/JSON declaration signal.
474+
val openQuote = if (i >= 0 && (text[i] == '"' || text[i] == '\'' || text[i] == '`')) {
475+
text[i].also { i-- }
476+
} else null
477+
while (i >= 0 && text[i].isWhitespace()) i--
450478
if (i < 0 || text[i] != ':') return null
451479

452480
i--
453481
while (i >= 0 && text[i].isWhitespace()) i--
454482
if (i < 0) return null
455483

456-
// Match variable name backwards
484+
// Quoted key — only legal in JS / TS / JSON object literals, never in
485+
// CSS. Always treated as a declaration regardless of value shape.
486+
if (text[i] == '"' || text[i] == '\'') {
487+
val keyEnd = i
488+
val quote = text[i]
489+
i--
490+
while (i >= 0 && text[i] != quote) i--
491+
if (i < 0) return null
492+
val name = text.subSequence(i + 1, keyEnd).toString()
493+
return name.ifBlank { null }
494+
}
495+
496+
// Bare key — walk back to capture the identifier.
457497
var j = i
458498
while (j >= 0 && (text[j].isLetterOrDigit() || text[j] == '-' || text[j] == '_')) j--
459499
if (j == i) return null
@@ -462,10 +502,15 @@ object LiteralFinder {
462502

463503
// SCSS: $name
464504
if (j >= 0 && text[j] == '$') return "$$name"
465-
466-
// CSS: --name
505+
// CSS custom property: --name
467506
if (j > 0 && text[j] == '-' && text[j - 1] == '-') return "--$name"
468507

508+
// Bare key with **quoted** value → JS/TS declaration (`size: "8px"`).
509+
if (openQuote != null) return name
510+
511+
// Bare key, bare value → ambiguous CSS-rule vs. RN-style assignment.
512+
// Return null so legitimate hardcoded CSS values still surface; the
513+
// token-source whole-file skip handles the RN-catalog edge case.
469514
return null
470515
}
471516

0 commit comments

Comments
 (0)