Skip to content

feat(linter): detect token name collisions for flat vs grouped keys#150

Open
Arshgill01 wants to merge 1 commit into
google-labs-code:mainfrom
Arshgill01:feat/collision-detection
Open

feat(linter): detect token name collisions for flat vs grouped keys#150
Arshgill01 wants to merge 1 commit into
google-labs-code:mainfrom
Arshgill01:feat/collision-detection

Conversation

@Arshgill01

Copy link
Copy Markdown

Closes #149

Requested by @davideast in #68:

One thing your PR has that #103 does not: collision detection for cases where a flat key and a grouped key produce the same name. That's a good idea and worth tracking.

What this does

forEachLeaf (merged in #103) enables arbitrary nesting depth for colors, rounded, and spacing tokens. As a side-effect it is now possible to write a DESIGN.md where two differently-structured keys resolve to the same symbol table entry with no diagnostic emitted. Two collision shapes exist:

Case 1 — Duplicate dot path

A flat string key whose literal value matches an existing nested token's dot-separated path:

colors:
  utility-info:
    50: "#EEF7FC"
  utility-info.50: "#FFFFFF"   # → error: Duplicate token path 'colors.utility-info.50' detected.

Case 2 — Flat/grouped name collision

A nested token whose path, when dots are replaced by hyphens, matches a flat key that is already registered:

colors:
  utility-info-50: "#EEF7FC"
  utility-info:
    50: "#FFFFFF"              # → error: Grouped color token flattens to 'utility-info-50', which is already defined.

In both cases the offending token is skipped (not written to the symbol table) and an error-severity finding is emitted so the user sees it at lint time, before the Tailwind v4 emitter processes the output. Behaviour is consistent across all three token categories.

Changes

File Change
packages/cli/src/linter/model/handler.ts Adds seenKeys (Set<string>) and seenNormalized (Map<string, string>) tracking inside the colors, rounded, and spacing Phase 1 forEachLeaf callbacks
packages/cli/src/linter/model/handler.test.ts 6 new test cases covering both collision types for all three token categories

Verification

bun test      → 288 pass, 0 fail
bun run lint  → tsc clean
bun run build → Bundled successfully

@Emp1500 Emp1500 Jun 29, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Phase 2 can undo the collision guard. This one's a bit sneaky because it's not in your diff, but the new guard in Phase 1 interacts with the existing Phase 2 reference-resolution loop in a way that can silently corrupt the symbol table.
Phase 2 does a bare forEachLeaf walk over input.colors again with no awareness of seenKeys or seenNormalized. So in a case like this:

colors:
utility-info-50: "{colors.brand.primary}" # token reference
utility-info:50: "#FFFFFF"

Phase 1 does the right thing detects the collision, emits the error, skips utility-info.50. But then Phase 2 comes along, re-walks input.colors, and when it resolves the reference for utility-info-50 it calls symbolTable.set() again no guard, no check. Whatever Phase 1 decided gets quietly overwritten. Same issue exists in the rounded and spacing Phase 2 loops.
The straightforward fix is to not re-walk input.colors in Phase 2 at all — just iterate over symbolTable directly and resolve any entries that are still raw reference strings. That way Phase 2 only touches what Phase 1 actually registered.

@Emp1500 Emp1500 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Few changes and this is ready to go:
1)Extract the collision guard into a shared helper (DRY)
2)Phase 2 re-traverses without collision guards (data corruption risk)

return;
}
seenKeys.add(name);
seenNormalized.set(normalized, name);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extract the collision guard into a shared helper (DRY). The seenKeys / seenNormalized block is identical across colors, rounded, and spacing only the category string differs. Might be worth extracting before this merges, purely because typography is one refactor away from needing a fourth copy, and that's when these things tend to drift. Something like a small buildCollisionGuard(category, findings) factory that returns a (name) => boolean predicate would collapse all three blocks down to a one-liner each, with no changes to the existing messages or tests.

Adds duplicate and collision detection to the model builder for nested and flat token keys, preventing silent overwrites in the symbol table.
@Arshgill01 Arshgill01 force-pushed the feat/collision-detection branch from 6c6ad33 to f9dbecc Compare June 29, 2026 06:00
@Arshgill01

Copy link
Copy Markdown
Author

Both points addressed — just force-pushed.

1. DRY (buildCollisionGuard factory)
Extracted the seenKeys/seenNormalized block into a module-level buildCollisionGuard(category, findings) factory that returns a (name: string) => boolean predicate. Each call site in colors, rounded, and spacing is now a single const isCollision = buildCollisionGuard(...) + if (isCollision(name)) return; — no duplicated logic, and adding typography later is a one-liner.

2. Phase 2 symbol-table iteration (data-corruption fix)
Phase 2 now iterates symbolTable entries directly instead of re-walking input.* via forEachLeaf. This means Phase 2 only ever touches keys that Phase 1 actually registered, so tokens that were skipped due to a collision can never be silently re-inserted. The logic is also simpler — one loop instead of three.

@Arshgill01 Arshgill01 requested a review from Emp1500 June 29, 2026 06:02

@Emp1500 Emp1500 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does the colors test still pass after the refactor?
Noticed the old colors block said "Grouped color token flattens to..." (singular), but rounded/spacing just used the category name as-is. Now everything goes through buildCollisionGuard with the same ${category} interpolation, so wouldn't calling it with 'colors' give you "Grouped colors token..." instead?

@Arshgill01

Copy link
Copy Markdown
Author

Yes, caught that during the refactor and updated the test to match the new message.

The old code had three separate inline blocks so the colors one happened to use the singular noun "color" while rounded/spacing used their category names as-is. buildCollisionGuard made the pattern uniform — it uses the category argument verbatim in both the path and the message string, so calling it with 'colors' now gives:

Grouped colors token flattens to 'utility-info-50', which is already defined.

The test on line 161 was updated from "Grouped color token..." to "Grouped colors token..." to match. All 288 tests pass on the current push (f9dbecc).

@Arshgill01 Arshgill01 requested a review from Emp1500 June 30, 2026 09:37

@Emp1500 Emp1500 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — both issues I flagged (Phase 2 corruption risk and the DRY refactor) are addressed, and the colors test fix matches the new uniform messaging. 288/288 tests passing. Nice work tightening this up.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(linter): token name collision detection for flat vs grouped keys

2 participants