qss 0.1.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.
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module QSS
6
+ # The consuming project's QSS settings, read at runtime. QSS core ships only neutral defaults (generated from
7
+ # grammar.yml into each auditor's AUDITOR_CONFIG); a consumer declares its own root and config file once:
8
+ #
9
+ # QSS.configure(root: "/path/to/project", config: "config/myapp/qss_config.yml")
10
+ #
11
+ # Relative paths (the config file, and paths inside it such as auditor.registry_path) resolve against that
12
+ # root, never the process working directory. Without QSS.configure, the root is Rails.root when a Rails app
13
+ # is loaded; with neither, relative paths fall back to the working directory (a last resort, not a contract).
14
+ class Configuration
15
+ attr_reader :root, :config_path
16
+
17
+ def initialize(root: nil, config: nil)
18
+ @root = root && File.expand_path(root.to_s)
19
+ @config_path = config && resolve(config.to_s)
20
+ @cache = {}
21
+ end
22
+
23
+ def root_or_default
24
+ return @root if @root
25
+ return File.expand_path(Rails.root.to_s) if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
26
+
27
+ nil
28
+ end
29
+
30
+ # A relative path is resolved against the project root (File.expand_path keeps an absolute one as it is).
31
+ def resolve(path)
32
+ path = path.to_s
33
+ return "" if path.empty?
34
+ base = root_or_default
35
+ base ? File.expand_path(path, base) : File.expand_path(path)
36
+ end
37
+
38
+ # The consumer's config file as a Hash ({} when none is configured or the file is missing). Re-read when the
39
+ # file changes.
40
+ def consumer
41
+ return {} unless @config_path && File.exist?(@config_path)
42
+
43
+ mtime = File.mtime(@config_path)
44
+ @cache = { mtime: mtime, data: (YAML.load_file(@config_path) || {}) } unless @cache[:mtime] == mtime
45
+ @cache[:data]
46
+ end
47
+
48
+ # Auditor settings: the core defaults with the consumer's `auditor:` block over them. The overlay portal root
49
+ # comes from the consumer's `config.portal_root` (shared with the SCSS side); registry_path is resolved.
50
+ def auditor_settings(defaults)
51
+ data = consumer
52
+ settings = deep_merge(defaults, data["auditor"] || {})
53
+ portal = data.dig("config", "portal_root")
54
+ settings["portal_root"] = portal.to_s unless portal.nil?
55
+ settings["registry_path"] = resolve(settings["registry_path"])
56
+ settings.freeze
57
+ end
58
+
59
+ # The consumer's additions to the adjective categories (grammar_extensions: { category => [names] }).
60
+ def grammar_extensions
61
+ consumer["grammar_extensions"] || {}
62
+ end
63
+
64
+ private
65
+
66
+ def deep_merge(base, over)
67
+ return over unless base.is_a?(Hash) && over.is_a?(Hash)
68
+
69
+ base.merge(over) { |_key, a, b| deep_merge(a, b) }
70
+ end
71
+ end
72
+
73
+ class << self
74
+ def configuration
75
+ @configuration ||= Configuration.new
76
+ end
77
+
78
+ def configure(root:, config: nil)
79
+ @configuration = Configuration.new(root: root, config: config)
80
+ end
81
+ end
82
+ end
data/lib/qss/engine.rb ADDED
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QSS
4
+ # The Rails integration: a plain engine (no routes, controllers or models) so a Rails app finds QSS's assets
5
+ # (app/assets/stylesheets/qss/*, app/assets/javascripts/qss/*) through its asset pipeline. lib/qss.rb loads this
6
+ # file only when Rails is present; the auditors and configuration work without Rails.
7
+ class Engine < ::Rails::Engine
8
+ end
9
+ end
@@ -0,0 +1,412 @@
1
+ require 'nokogiri'
2
+ require 'json'
3
+
4
+ module QSS
5
+ class QssAuditor
6
+ # rubocop:disable all -- generated by exe/qss-sync; its layout is the generator's, not the house style
7
+ # QSS-GENERATED-BEGIN (exe/qss-sync rewrites everything below, up to the closing marker)
8
+ # GENERATED FROM grammar.yml - DO NOT EDIT
9
+ DSL_WHITELIST = [
10
+ 'absolute',
11
+ 'anchored',
12
+ 'around',
13
+ 'bg',
14
+ 'bg-alpha',
15
+ 'blend',
16
+ 'blur',
17
+ 'border',
18
+ 'border-alpha',
19
+ 'border-b',
20
+ 'border-l',
21
+ 'border-r',
22
+ 'border-t',
23
+ 'bright',
24
+ 'busy',
25
+ 'centered',
26
+ 'clickable',
27
+ 'contain',
28
+ 'contrast',
29
+ 'cover',
30
+ 'cursor-col-resize',
31
+ 'cursor-row-resize',
32
+ 'detached',
33
+ 'dev-border',
34
+ 'evenly',
35
+ 'flex',
36
+ 'flex-1',
37
+ 'flex-auto',
38
+ 'flex-col',
39
+ 'flex-none',
40
+ 'flex-nowrap',
41
+ 'flex-row',
42
+ 'flex-wrap',
43
+ 'floating',
44
+ 'font-black',
45
+ 'font-bold',
46
+ 'font-medium',
47
+ 'font-mono',
48
+ 'font-sans',
49
+ 'foreign',
50
+ 'ghost',
51
+ 'grayscale',
52
+ 'grid',
53
+ 'group',
54
+ 'h-end',
55
+ 'h-f',
56
+ 'h-full',
57
+ 'h-mid',
58
+ 'h-start',
59
+ 'horizontal',
60
+ 'hue',
61
+ 'icon-size',
62
+ 'invert',
63
+ 'invisible',
64
+ 'italic',
65
+ 'items-center',
66
+ 'justify-between',
67
+ 'justify-center',
68
+ 'leading',
69
+ 'lowercase',
70
+ 'nowrap',
71
+ 'opacity',
72
+ 'outline-none',
73
+ 'pos',
74
+ 'pos-b',
75
+ 'pos-r',
76
+ 'pos-x',
77
+ 'pos-y',
78
+ 'qss',
79
+ 'qss-content-driven',
80
+ 'qss-dampen-scaling',
81
+ 'qss-ergonomic-override',
82
+ 'qss-legibility-override',
83
+ 'qss-payload-override',
84
+ 'relative',
85
+ 'resize',
86
+ 'ring',
87
+ 'rounded',
88
+ 'rounded-full',
89
+ 'saturate',
90
+ 'scroll',
91
+ 'scroll-smooth',
92
+ 'sepia',
93
+ 'shadow',
94
+ 'smooth',
95
+ 'spaced',
96
+ 'stuck',
97
+ 'table-fixed',
98
+ 'text-alpha',
99
+ 'text-size',
100
+ 'tracking',
101
+ 'tracking-tighter',
102
+ 'tracking-wide',
103
+ 'tracking-wider',
104
+ 'tracking-widest',
105
+ 'transition-all',
106
+ 'transition-colors',
107
+ 'transition-none',
108
+ 'truncate',
109
+ 'underline',
110
+ 'unselectable',
111
+ 'uppercase',
112
+ 'v-end',
113
+ 'v-mid',
114
+ 'v-start',
115
+ 'vertical',
116
+ 'visible',
117
+ 'w-f',
118
+ 'w-full'
119
+ ].freeze
120
+
121
+ PAYLOAD_TAGS = ["p", "h1", "h2", "h3", "h4", "h5", "h6", "span", "label", "blockquote", "li"].freeze
122
+
123
+ ADJECTIVE_CATEGORIES = {"positional"=>["detached", "floating", "stuck", "absolute", "pos-x", "pos-y", "pos-r", "pos-b", "pos"], "flow_relative"=>["anchored", "relative"], "layout"=>["vertical", "horizontal", "centered", "v-mid", "h-mid", "v-start", "v-end", "h-start", "h-end", "spaced", "around", "evenly", "flex", "flex-col", "flex-row", "flex-wrap", "flex-nowrap", "flex-1", "flex-auto", "flex-none", "items-center", "justify-center", "justify-between", "grid", "table-fixed", "w-full", "h-full", "w-f", "h-f"], "typographic"=>["italic", "uppercase", "lowercase", "underline", "nowrap", "truncate", "font-mono", "font-sans", "font-medium", "font-bold", "font-black", "text-size", "leading", "tracking", "tracking-tighter", "tracking-wide", "tracking-wider", "tracking-widest", "icon-size"], "visual"=>["bg", "border", "border-t", "border-b", "border-l", "border-r", "rounded", "rounded-full", "shadow", "opacity", "blur", "bright", "contrast", "grayscale", "hue", "invert", "saturate", "sepia", "blend", "contain", "cover", "visible", "invisible", "bg-alpha", "border-alpha", "text-alpha"], "interactive"=>["clickable", "smooth", "busy", "ghost", "unselectable", "scroll-smooth", "scroll", "cursor-col-resize", "cursor-row-resize", "transition-all", "transition-colors", "transition-none", "ring", "resize", "outline-none"], "internal"=>["qss", "dev-border", "group", "foreign", "qss-legibility-override", "qss-ergonomic-override", "qss-payload-override", "qss-dampen-scaling", "qss-content-driven"]}.freeze
124
+
125
+ ILLEGAL_COMBOS = [{"tag_in"=>"payload_tags", "forbid_category"=>"positional", "severity"=>"red", "message"=>"Protocol Violation: Payload tags (<tag>) cannot carry flow-breaking positional adjectives. Use a wrapping <div> Frame.", "escape_hatch"=>"qss-payload-override"}].freeze
126
+
127
+ BLUEPRINT_NAMES = [].freeze
128
+
129
+ BLUEPRINT_SLOTS = {}.freeze
130
+
131
+ BRAND_COLORS = {"black"=>"#000000", "white"=>"#ffffff", "transparent"=>"transparent", "none"=>"none", "slate-50"=>"#f8fafc", "slate-100"=>"#f1f5f9", "slate-200"=>"#e2e8f0", "slate-300"=>"#cbd5e1", "slate-400"=>"#94a3b8", "slate-500"=>"#64748b", "slate-600"=>"#475569", "slate-700"=>"#334155", "slate-800"=>"#1e293b", "slate-900"=>"#0f172a", "slate-950"=>"#020617", "rose-500"=>"#f43f5e"}.freeze
132
+
133
+ DEPTHS = {"canvas"=>0, "content"=>100, "ui"=>500, "overlay"=>900, "modal"=>1000}.freeze
134
+
135
+ AUDITOR_CONFIG = {"id_prefix"=>"", "structural_words"=>["header", "footer", "sidebar", "workspace", "main", "panel", "frame", "wrapper", "nav", "aside"], "narrow_words"=>["header", "footer", "sidebar", "workspace", "main", "panel", "frame", "wrapper", "nav", "aside"], "icon_prefixes"=>[], "registry_path"=>"", "portal_root"=>""}.freeze
136
+ # QSS-GENERATED-END
137
+ # rubocop:enable all
138
+
139
+
140
+
141
+
142
+
143
+
144
+
145
+
146
+
147
+
148
+
149
+
150
+
151
+
152
+
153
+
154
+
155
+
156
+
157
+
158
+
159
+
160
+
161
+
162
+
163
+
164
+
165
+
166
+
167
+
168
+
169
+
170
+
171
+
172
+
173
+
174
+
175
+
176
+
177
+
178
+
179
+
180
+
181
+
182
+
183
+
184
+
185
+
186
+
187
+
188
+
189
+
190
+
191
+
192
+
193
+
194
+
195
+
196
+
197
+
198
+
199
+
200
+
201
+
202
+
203
+
204
+
205
+
206
+
207
+
208
+
209
+
210
+
211
+
212
+
213
+ # Thresholds based on QSS Spec
214
+ LEGIBILITY_FLOOR = 11
215
+ ERGONOMIC_MIN = 44
216
+
217
+ # Core's own grammar, found relative to this file (not the working directory).
218
+ GRAMMAR_PATH = File.expand_path('../../config/grammar.yml', __dir__)
219
+
220
+ # The effective settings: the core defaults above (AUDITOR_CONFIG, generated from grammar.yml alone) with the
221
+ # consuming project's config over them, read at runtime (QSS.configure, lib/qss/configuration.rb).
222
+ def self.settings
223
+ QSS.configuration.auditor_settings(AUDITOR_CONFIG)
224
+ end
225
+
226
+ # Everything derived from the settings, built once per audited file.
227
+ Rules = Struct.new(:id_prefix, :structural_id_pattern, :narrow_id_pattern, :icon_prefix_pattern, :portal_label, :whitelist)
228
+
229
+ def self.rules(settings = self.settings)
230
+ prefix = settings['id_prefix'].to_s
231
+ words = ->(key) { settings[key].map { |w| Regexp.escape(w) }.join('|') }
232
+ extensions = QSS.configuration.grammar_extensions.values.flatten
233
+ Rules.new(
234
+ prefix,
235
+ /^#{Regexp.escape(prefix)}.*(#{words.('structural_words')})/i,
236
+ /^#{Regexp.escape(prefix)}.*(#{words.('narrow_words')})/i,
237
+ settings['icon_prefixes'].empty? ? nil : /^(#{words.('icon_prefixes')})/,
238
+ settings['portal_root'].to_s.empty? ? 'the overlay portal root' : settings['portal_root'],
239
+ extensions.empty? ? DSL_WHITELIST : (DSL_WHITELIST + extensions).uniq
240
+ )
241
+ end
242
+
243
+ # An empty id_prefix means "no prefix convention": nothing counts as prefixed/first-party by prefix.
244
+ def self.prefixed_id?(id, prefix = rules.id_prefix)
245
+ !prefix.empty? && id.start_with?(prefix)
246
+ end
247
+
248
+ def self.first_party?(id, classes, prefix = rules.id_prefix)
249
+ (!prefix.empty? && (id.start_with?(prefix) || classes.any? { |c| c.start_with?(prefix) })) ||
250
+ classes.any? { |c| c.start_with?('qss-') }
251
+ end
252
+
253
+ # Whitelists
254
+
255
+
256
+
257
+ def self.audit_file(file_path)
258
+ file_path = file_path.to_s # callers pass Pathname (specs) or String
259
+ return { status: 'grey', issues: [] } unless File.exist?(file_path)
260
+ raw_content = File.read(file_path).force_encoding('UTF-8').scrub rescue ""
261
+ return { status: 'grey', issues: [] } if raw_content.strip.empty?
262
+
263
+ # 0. Blueprint Integrity & Blast Radius Check (Global)
264
+ issues = []
265
+ r = rules
266
+ is_shared_definition = file_path.match?(/qss-objects\.json|grammar\.yml/i)
267
+
268
+ if is_shared_definition
269
+ issues << { type: 'blueprint', message: "High Blast Radius: You are editing a shared blueprint definition. Changes here propagate site-wide to all instances.", severity: 'orange' }
270
+ end
271
+
272
+ if file_path.include?('qss-engine.js')
273
+ grammar_path = GRAMMAR_PATH
274
+ if File.exist?(grammar_path)
275
+ require 'yaml'
276
+ grammar = YAML.load_file(grammar_path) rescue nil
277
+ if grammar
278
+ # Detect manual additions to objectRegistry in JS
279
+ # blue_names = grammar['objects']&.keys || [] # Placeholder if objects moved to grammar.yml
280
+ # For now, we enforce that direct JS edits are forbidden.
281
+ unless raw_content.include?('// GENERATED FROM grammar.yml - DO NOT EDIT')
282
+ issues << { type: 'protocol', message: "Blueprint Integrity Violation: Manual edits detected in generated engine. Changes must be made in grammar.yml.", severity: 'red' }
283
+ end
284
+ end
285
+ end
286
+ end
287
+
288
+ # Strip ERB tags to avoid parser confusion, but keep placeholders for audit
289
+ doc_content = raw_content.gsub(/<%.*?%>/m, '<!--ERB-->')
290
+ doc = Nokogiri::HTML.fragment(doc_content)
291
+
292
+ # 1. Global Checks
293
+ if raw_content.include?('!important')
294
+ issues << { type: 'protocol', message: "Cheat detected: '!important' is forbidden in QSS", severity: 'red' }
295
+ end
296
+
297
+ # 2. Structural Scoping (Thin Wrapper Protocol)
298
+ is_thin_protocol_file = raw_content.include?('contain: layout') || raw_content.include?('var(--')
299
+
300
+ # 2.0 Shared Definition Overwrite Check (Monkey-patching Prevention)
301
+ if raw_content.match?(/objectRegistry\s*[\[=]|let\s+objectRegistry|const\s+objectRegistry/i) && !file_path.include?('qss-engine.js')
302
+ issues << { type: 'protocol', message: "Monkey-patching Violation: Manual override of 'objectRegistry' detected. Object definitions must be registered through registerObject() or config/qss/qss-objects.json.", severity: 'red' }
303
+ end
304
+
305
+ doc.traverse do |node|
306
+ next unless node.element?
307
+
308
+ classes = node['class']&.split(/\s+/) || []
309
+ styles = node['style'] || ""
310
+ id = node['id'] || ""
311
+ is_first_party = first_party?(id, classes, r.id_prefix)
312
+ is_structural = id.match?(r.structural_id_pattern)
313
+ active_thin_protocol = is_structural || is_thin_protocol_file
314
+
315
+ # A zone is genuinely foreign only if it was established by a non-first-party element
316
+ is_genuinely_foreign = node.ancestors.any? do |a|
317
+ a_classes = a['class']&.split(/\s+/) || []
318
+ a_id = a['id'] || ""
319
+ a_is_first_party = first_party?(a_id, a_classes, r.id_prefix)
320
+ (a_classes.include?('foreign') && !a_is_first_party) || a_classes.include?('qss-payload-override')
321
+ end || (classes.include?('foreign') && !is_first_party) || classes.include?('qss-payload-override')
322
+
323
+ # 1.5 Suspicious Foreign Check
324
+ if is_first_party && classes.include?('foreign')
325
+ issues << { type: 'protocol', message: "Suspicious Foreign: First-party element <#{node.name}#{id.empty? ? '' : '#' + id}> is using the 'foreign' escape hatch. Verify this is intended.", severity: 'orange' }
326
+ end
327
+
328
+ # 2.1 Inline Styles Check
329
+ if !styles.empty?
330
+ # structural check
331
+ unless is_structural || is_genuinely_foreign
332
+ issues << { type: 'protocol', message: "Protocol Violation: Inline styles forbidden on non-structural element <#{node.name}#{id.empty? ? '' : '#' + id}>. Use QSS Adjectives.", severity: 'red' }
333
+ end
334
+
335
+ # 2.2 Hardcoded Relational Positioning
336
+ if active_thin_protocol && !is_genuinely_foreign
337
+ styles.scan(/\b(?<!-)(left|top|right|bottom|width|height)\s*:\s*(\d+px)/i).each do |prop, val|
338
+ issues << { type: 'protocol', message: "Thin Wrapper Violation: Hardcoded '#{prop}: #{val}' on ##{id}. Use var() for relational anchoring.", severity: 'red' }
339
+ end
340
+ end
341
+ end
342
+
343
+ # 2.3 Containment Enforcement
344
+ if active_thin_protocol && id.match?(r.narrow_id_pattern)
345
+ unless styles.include?('contain: layout') || id.include?('portal') || is_genuinely_foreign
346
+ issues << { type: 'protocol', message: "Thin Wrapper Violation: Structural element ##{id} must carry 'contain: layout'", severity: 'red' }
347
+ end
348
+ end
349
+
350
+ # 2.4 Stacking Context Warning
351
+ if active_thin_protocol && prefixed_id?(id, r.id_prefix) && styles.include?('z-index') && !id.include?('portal')
352
+ z_val = styles.match(/z-index\s*:\s*(\d+)/i)&.captures&.first
353
+ issues << { type: 'protocol', message: "Stacking Context Warning: z-index:#{z_val} on ##{id} is inside a contained frame. Use #{r.portal_label}.", severity: 'orange' }
354
+ end
355
+
356
+ # 3. Grammar & Payload Checks
357
+ unless is_genuinely_foreign
358
+ # 3.1 Payload Positional Violation
359
+ if PAYLOAD_TAGS.include?(node.name)
360
+ # Only flag flow-breaking properties. anchored and relative are flow-relative.
361
+ has_flow_breaking = classes.any? { |c| c.match?(/^(detached|floating|stuck|absolute|pos-x|pos-y|pos-r|pos-b|pos)/) || c.start_with?('^') }
362
+ if has_flow_breaking && !classes.include?('qss-payload-override')
363
+ issues << { type: 'protocol', message: "Protocol Violation: Payload tag <#{node.name}> cannot carry flow-breaking adjectives.", severity: 'red' }
364
+ end
365
+ end
366
+
367
+ # 3.2 DSL Whitelist & Prefix Check
368
+ classes.each do |cls|
369
+ # Strip common prefixes for check
370
+ base_cls = cls.sub(/^(hover|group-hover|focus|active):/, '')
371
+
372
+ # Allow recognition of known icon/project-standard prefixes
373
+ # The consumer's id prefix is NOT allowed as a class prefix (only as an ID prefix)
374
+ next if r.icon_prefix_pattern && base_cls.match?(r.icon_prefix_pattern)
375
+
376
+ # Tailwind-like content allowance (Strictly anchored)
377
+ next if base_cls.match?(/^(text|bg|border|font|rounded|shadow|opacity|bright)-/)
378
+ next if base_cls.match?(/\A(m[trblxy]?|p[trblxy]?|w|h|space-[xy]|flex|grid|items|justify|gap|scroll|depth|pos-[xyrb]|text-size|rounded|border)-(\d+|full|auto|hidden|px|f|[\d.]+|ui|overlay|modal|canvas|content|center)\z/)
379
+ next if base_cls == 'qss'
380
+
381
+ unless r.whitelist.include?(base_cls)
382
+ issues << { type: 'grammar', message: "Foreign Syntax: '#{cls}' is not a valid QSS Adjective", severity: 'red' }
383
+ end
384
+ end
385
+
386
+ # 3.3 Explicit Dimension Rule
387
+ if active_thin_protocol && id.match?(r.narrow_id_pattern) && !id.include?('portal')
388
+ has_width = styles.match(/\bwidth\s*:\s*[^;]+/i) || classes.any? { |c| c.match?(/^(w\[|w-full|w-f|w-\d+)/) || c.match?(/^[A-Z][a-zA-Z0-9]+$/) } || (styles.include?('left:') && styles.include?('right:'))
389
+ has_height = styles.match(/\bheight\s*:\s*[^;]+/i) || classes.any? { |c| c.match?(/^(h\[|h-full|h-f|h-\d+)/) || c.match?(/^[A-Z][a-zA-Z0-9]+$/) } || (styles.include?('top:') && styles.include?('bottom:'))
390
+
391
+ unless (has_width && has_height) || classes.include?('qss-content-driven')
392
+ issues << { type: 'protocol', message: "Implicit Dimension Warning: Structural element ##{id} lacks explicit dimensions.", severity: 'orange' }
393
+ end
394
+ end
395
+ end
396
+
397
+ # 4. Legibility & Ergonomics
398
+ unless is_genuinely_foreign || classes.include?('qss-legibility-override')
399
+ classes.each do |cls|
400
+ if cls.match?(/text-size\[(\d+)\]/)
401
+ size = cls.match(/text-size\[(\d+)\]/)[1].to_i
402
+ issues << { type: 'legibility', message: "Font size #{size} below floor (#{LEGIBILITY_FLOOR})", severity: 'orange' } if size < LEGIBILITY_FLOOR
403
+ end
404
+ end
405
+ end
406
+ end
407
+
408
+ status = issues.any? { |i| i[:severity] == 'red' } ? 'red' : (issues.any? { |i| i[:severity] == 'orange' } ? 'orange' : 'green')
409
+ { status: status, issues: issues }
410
+ end
411
+ end
412
+ end