|
5 | 5 | require "stringio" |
6 | 6 | require "yaml" |
7 | 7 | require_relative "erb_parser" |
| 8 | +require_relative "report/style" |
8 | 9 |
|
9 | 10 | module Guardrails |
10 | 11 | class Audit |
@@ -69,13 +70,14 @@ class Audit |
69 | 70 | flood-color lighting-color stop-color |
70 | 71 | ].freeze |
71 | 72 |
|
72 | | - def initialize(root:, output: $stdout, suggest: false, format: :text, apply: false) |
| 73 | + def initialize(root:, output: $stdout, suggest: false, format: :text, apply: false, style: nil) |
73 | 74 | @root = Pathname(root) |
74 | 75 | @output = output |
75 | 76 | @suggest = suggest |
76 | 77 | @format = format |
77 | 78 | @apply = apply |
78 | 79 | @config = load_audit_config |
| 80 | + @style = style |
79 | 81 | end |
80 | 82 |
|
81 | 83 | def run |
@@ -445,18 +447,170 @@ def print_report(violations) |
445 | 447 |
|
446 | 448 | def print_text(violations) |
447 | 449 | if violations.empty? |
448 | | - @output.puts "Guardrails audit: no violations found." |
| 450 | + @output.puts "" |
| 451 | + @output.puts "#{style.colorize("✓", :green)} Guardrails audit: no violations found." |
449 | 452 | return |
450 | 453 | end |
451 | 454 |
|
452 | | - noun = violations.length == 1 ? "violation" : "violations" |
453 | | - @output.puts "Guardrails audit: #{violations.length} #{noun} found" |
| 455 | + # Group by type so each rule gets its own section with framing |
| 456 | + # intro + tagged findings + inline suggestion arrows. The order |
| 457 | + # mirrors what users care about: hex literals + inline styles + |
| 458 | + # arbitrary Tailwind (real violations) before helper_recommended |
| 459 | + # (a suggestion-shaped warning) and a11y (errors but grouped |
| 460 | + # separately because A11yAudit owns them — the audit rake task |
| 461 | + # threads them in via this same method). |
| 462 | + type_order = %i[inline_style raw_color tailwind_arbitrary helper_recommended |
| 463 | + image_alt button_name link_name input_label] |
| 464 | + by_type = violations.group_by(&:type) |
| 465 | + ordered = type_order + (by_type.keys - type_order) |
| 466 | + |
| 467 | + ordered.each do |type| |
| 468 | + list = by_type[type] || [] |
| 469 | + next if list.empty? |
| 470 | + |
| 471 | + print_violation_type(type, list) |
| 472 | + end |
| 473 | + end |
| 474 | + |
| 475 | + SEVERITY_FOR_TYPE = { |
| 476 | + inline_style: :warning, |
| 477 | + raw_color: :error, |
| 478 | + tailwind_arbitrary: :error, |
| 479 | + helper_recommended: :warning, |
| 480 | + image_alt: :error, |
| 481 | + button_name: :error, |
| 482 | + link_name: :error, |
| 483 | + input_label: :error |
| 484 | + }.freeze |
| 485 | + |
| 486 | + FRAMING_FOR_TYPE = { |
| 487 | + inline_style: "Inline style attributes bypass your design tokens. Extract these to a CSS class or component stylesheet that references defined tokens.", |
| 488 | + raw_color: "Hex/rgb literals in color attributes bypass your design tokens. Run APPLY=1 to auto-fix where a token matches; SUGGEST=1 writes a markdown checklist.", |
| 489 | + tailwind_arbitrary: "Arbitrary Tailwind values (bg-[#fa3] etc.) bypass your theme. Add the value to theme.colors / theme.fontSize and use the named utility, or APPLY=1 to auto-fix where a token matches.", |
| 490 | + helper_recommended: "Literal <button>/<a> wrapping ERB output hides intent from static analysis and a11y tooling. Switch to tag.button / link_to / button_to so attributes flow through one place.", |
| 491 | + image_alt: "Images need accessible alt text. Use alt=\"\" for purely decorative images.", |
| 492 | + button_name: "Buttons need an accessible name — text content, aria-label, or aria-labelledby.", |
| 493 | + link_name: "Links need an accessible name — text content, aria-label, or aria-labelledby.", |
| 494 | + input_label: "Interactive inputs need a programmatic label — aria-label, aria-labelledby, or a matching <label for=...>." |
| 495 | + }.freeze |
| 496 | + |
| 497 | + AUTO_FIXABLE_TYPES = %i[raw_color tailwind_arbitrary].freeze |
| 498 | + |
| 499 | + def print_violation_type(type, violations) |
| 500 | + severity = SEVERITY_FOR_TYPE.fetch(type, :warning) |
| 501 | + auto_fix_marker = AUTO_FIXABLE_TYPES.include?(type) ? ", auto-fix available" : "" |
| 502 | + |
| 503 | + @output.puts "" |
| 504 | + @output.puts style.section_heading( |
| 505 | + severity, |
| 506 | + "#{type} (#{violations.length} #{violations.length == 1 ? "finding" : "findings"}#{auto_fix_marker})" |
| 507 | + ) |
| 508 | + framing = FRAMING_FOR_TYPE[type] |
| 509 | + wrap_framing(framing).each { |line| @output.puts " #{line}" } if framing |
| 510 | + |
454 | 511 | violations.each do |v| |
455 | | - @output.puts " [#{v.type}] #{v.file}:#{v.line}:#{v.column}" |
456 | | - @output.puts " #{v.snippet}" |
| 512 | + @output.puts "" |
| 513 | + header = "#{type}: #{format_value(v)}" |
| 514 | + @output.puts " #{style.severity(severity, header)}" |
| 515 | + suggestion = suggestion_for_violation(v) |
| 516 | + @output.puts " #{style.suggestion(suggestion)}" if suggestion |
| 517 | + @output.puts " #{style.location("#{v.file}:#{v.line}:#{v.column}")}" |
| 518 | + @output.puts " #{v.snippet}" if v.snippet |
| 519 | + end |
| 520 | + end |
| 521 | + |
| 522 | + def format_value(violation) |
| 523 | + case violation.type |
| 524 | + when :raw_color, :tailwind_arbitrary |
| 525 | + violation.value |
| 526 | + when :inline_style |
| 527 | + violation.value || "<inline style>" |
| 528 | + else |
| 529 | + violation.snippet.to_s[0, 60] |
| 530 | + end |
| 531 | + end |
| 532 | + |
| 533 | + # Hard-wrap the framing-intro paragraph at ~72 chars so it doesn't |
| 534 | + # run off the side of an 80-col terminal. Cheap word-wrap; nothing |
| 535 | + # fancy needed for a 1-2 sentence paragraph. |
| 536 | + def wrap_framing(text, width: 72) |
| 537 | + lines = [] |
| 538 | + current = +"" |
| 539 | + text.split(/\s+/).each do |word| |
| 540 | + if current.empty? |
| 541 | + current << word |
| 542 | + elsif current.length + 1 + word.length <= width |
| 543 | + current << " " << word |
| 544 | + else |
| 545 | + lines << current |
| 546 | + current = +word |
| 547 | + end |
| 548 | + end |
| 549 | + lines << current unless current.empty? |
| 550 | + lines |
| 551 | + end |
| 552 | + |
| 553 | + # Per-violation inline suggestion. For token-aware types |
| 554 | + # (raw_color, tailwind_arbitrary), reuse load_tokens + TokenMatcher |
| 555 | + # to surface exact matches inline. Anything more elaborate |
| 556 | + # (near-match, replacement string, etc.) belongs in SUGGEST=1's |
| 557 | + # markdown checklist. |
| 558 | + def suggestion_for_violation(violation) |
| 559 | + case violation.type |
| 560 | + when :raw_color |
| 561 | + matched_token_suggestion(violation, [:css_var]) |
| 562 | + when :tailwind_arbitrary |
| 563 | + matched_token_suggestion(violation, [:tailwind]) || |
| 564 | + matched_token_suggestion(violation, [:css_var]) |
| 565 | + when :inline_style |
| 566 | + "extract to a CSS class or component stylesheet" |
| 567 | + when :helper_recommended |
| 568 | + helper_recommended_suggestion(violation) |
| 569 | + when :image_alt |
| 570 | + "add an alt attribute (or alt=\"\" if decorative)" |
| 571 | + when :button_name |
| 572 | + "add text, aria-label, or aria-labelledby" |
| 573 | + when :link_name |
| 574 | + "add link text, aria-label, or aria-labelledby" |
| 575 | + when :input_label |
| 576 | + "add aria-label, aria-labelledby, or a matching <label for=...>" |
| 577 | + end |
| 578 | + end |
| 579 | + |
| 580 | + def matched_token_suggestion(violation, allowed_syntaxes) |
| 581 | + require_relative "token_matcher" |
| 582 | + tokens = load_tokens.select { |t| allowed_syntaxes.include?(t.syntax) } |
| 583 | + return nil if tokens.empty? |
| 584 | + |
| 585 | + match = TokenMatcher.new(tokens).match(violation.value) |
| 586 | + return nil unless match && match.kind == :exact |
| 587 | + |
| 588 | + token = match.token |
| 589 | + "replace with #{token_reference(token)} (exact match from #{token.file})" |
| 590 | + end |
| 591 | + |
| 592 | + def token_reference(token) |
| 593 | + case token.syntax |
| 594 | + when :css_var then "var(--#{token.name})" |
| 595 | + when :scss_var then "$#{token.name}" |
| 596 | + when :tailwind then token.name.to_s |
| 597 | + else token.name.to_s |
| 598 | + end |
| 599 | + end |
| 600 | + |
| 601 | + def helper_recommended_suggestion(violation) |
| 602 | + tag = violation.snippet.to_s[/<(\w+)/, 1] |
| 603 | + case tag |
| 604 | + when "button" then "use tag.button(label, ...) or button_to(label, path)" |
| 605 | + when "a" then "use link_to(label, path, ...)" |
| 606 | + else "use the Rails helper for this element" |
457 | 607 | end |
458 | 608 | end |
459 | 609 |
|
| 610 | + def style |
| 611 | + @style ||= Report::Style.new(io: @output) |
| 612 | + end |
| 613 | + |
460 | 614 | def print_json(violations) |
461 | 615 | require "json" |
462 | 616 | payload = { |
|
0 commit comments