Class: Guardrails::Tokens

Inherits:
Object
  • Object
show all
Defined in:
lib/guardrails/tokens.rb,
lib/guardrails/tokens/tailwind_config_parser.rb

Defined Under Namespace

Classes: Drift, TailwindConfigParser, Token

Constant Summary collapse

CSS_VAR_PATTERN =
/--([a-z][\w-]*):\s*([^;]+);/i
SCSS_VAR_PATTERN =
/\$([a-z][\w-]*):\s*([^;]+);/i
HEX_LITERAL_PATTERN =
/#[0-9a-fA-F]{3,8}\b/
BLOCK_COMMENT_PATTERN =
/\/\*[\s\S]*?\*\//
LINE_COMMENT_PATTERN =
/\/\/[^\n]*/
STYLESHEET_PATTERNS =
[
  "app/assets/stylesheets/**/*.{css,scss}",
  "app/assets/tailwind/**/*.css"
].freeze
IMPLICIT_IGNORE_SEGMENTS =

Same path-component skip-list as Audit / StackDetector — vendor stylesheets nested under app/assets/stylesheets/ shouldn’t surface as drift since they’re typically third-party.

%w[vendor node_modules tmp public log].freeze

Instance Method Summary collapse

Constructor Details

#initialize(root:, output: $stdout) ⇒ Tokens

Returns a new instance of Tokens.



28
29
30
31
32
# File 'lib/guardrails/tokens.rb', line 28

def initialize(root:, output: $stdout)
  @root = Pathname(root)
  @output = output
  @config = load_config
end

Instance Method Details

#detect_drift(tokens) ⇒ Object



91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
# File 'lib/guardrails/tokens.rb', line 91

def detect_drift(tokens)
  lookup = tokens.to_h { |t| [HexNormalizer.normalize(t.value), t] }
  drift = []
  definition_files = [colors_file, type_scale_file].compact

  stylesheets.each do |file|
    next if definition_files.include?(file)
    next if file == @root.join("tailwind.config.js")

    raw_content = File.read(file, encoding: Encoding::UTF_8)
    content = strip_comments(raw_content)
    content.each_line.with_index do |line, idx|
      next if variable_definition_line?(line)

      line.scan(HEX_LITERAL_PATTERN) do
        value = Regexp.last_match[0]
        column = Regexp.last_match.begin(0) + 1
        drift << Drift.new(
          file: file.relative_path_from(@root).to_s,
          line: idx + 1,
          column: column,
          value: value,
          matched_token: lookup[HexNormalizer.normalize(value)]
        )
      end
    end
  end
  drift
end

#parse_tailwind_configObject



55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
# File 'lib/guardrails/tokens.rb', line 55

def parse_tailwind_config
  # Reset on every call — a Tokens instance can outlive a single run
  # (callers may reuse it), and tailwind.config.js can change between
  # runs. Without this, the hint can leak into later summaries after
  # the config no longer matches the preset pattern.
  @tailwind_preset_hint = nil

  file = @root.join("tailwind.config.js")
  return [] unless file.exist?

  content = File.read(file, encoding: Encoding::UTF_8)
  entries = TailwindConfigParser.parse(content)

  # If the literal config has zero parseable entries but uses the
  # preset import pattern, the actual tokens live in a JS file we
  # can't evaluate. Surface a one-line hint so users don't think
  # the parser is broken — found in the Avo dogfood where
  # tailwind.config.js does `module.exports = { presets: [preset] }`.
  if entries.empty? && tailwind_uses_presets?(content)
    @tailwind_preset_hint =
      "tailwind.config.js uses a `presets:` import; only the literal config file " \
        "is parsed (we don't evaluate JS). Define non-color tokens in v4 `@theme` " \
        "blocks for cross-tool token visibility."
  end

  entries.map do |entry|
    Token.new(
      name: entry.name,
      value: entry.value,
      syntax: :tailwind,
      file: file.relative_path_from(@root).to_s,
      line: 0
    )
  end
end

#parse_tokensObject



42
43
44
45
46
47
48
49
50
51
52
53
# File 'lib/guardrails/tokens.rb', line 42

def parse_tokens
  tokens = []
  [colors_file, type_scale_file].compact.each do |file|
    next unless file.exist?

    content = File.read(file, encoding: Encoding::UTF_8)
    tokens.concat(scan(content, file, CSS_VAR_PATTERN, :css_var))
    tokens.concat(scan(content, file, SCSS_VAR_PATTERN, :scss_var))
  end
  tokens.concat(parse_tailwind_config)
  tokens
end

#runObject



34
35
36
37
38
39
40
# File 'lib/guardrails/tokens.rb', line 34

def run
  tokens = parse_tokens
  drift = detect_drift(tokens)
  print_summary(tokens)
  print_drift(drift)
  { tokens: tokens, drift: drift }
end