Class: MilkTea::Linter

Inherits:
Object
  • Object
show all
Includes:
LinterDocTags, LinterFlowRules, LinterImportsPlatform, LinterReleaseRules, LinterReservedNames, LinterRules, LinterSourceHelpers, LinterTrailingComma, LinterVisitors
Defined in:
lib/milk_tea/tooling/linter.rb,
lib/milk_tea/tooling/linter/rules.rb,
lib/milk_tea/tooling/linter/doc_tags.rb,
lib/milk_tea/tooling/linter/visitors.rb,
lib/milk_tea/tooling/linter/fix_engine.rb,
lib/milk_tea/tooling/linter/flow_rules.rb,
lib/milk_tea/tooling/linter/release_rules.rb,
lib/milk_tea/tooling/linter/reserved_names.rb,
lib/milk_tea/tooling/linter/source_helpers.rb,
lib/milk_tea/tooling/linter/trailing_comma.rb,
lib/milk_tea/tooling/linter/imports_platform.rb

Defined Under Namespace

Modules: FixEngine, LinterDocTags, LinterFlowRules, LinterImportsPlatform, LinterReleaseRules, LinterReservedNames, LinterRules, LinterSourceHelpers, LinterTrailingComma, LinterVisitors Classes: Binding, DeadAssignmentAnalysis, FixEdit, Profile, ReservedPrimitiveNameFix, ReservedPrimitiveNameSite, StatementFlowAnalysis, Warning

Constant Summary collapse

UNSET =
Object.new.freeze
DEFAULT_CONFIG_FILE_NAME =
".mt-lint.yml".freeze
KNOWN_RULE_CODES =
%w[
  borrow-and-mutate
  constant-condition
  dead-assignment
  duplicate-if-condition
  directional-ffi-arg
  doc-tag
  event-capacity
  line-too-long
  loop-single-iteration
  missing-return
  noop-compound-assignment
  platform-api-drift
  owning-release-leak
  owning-release-double
  prefer-conditional-expression
  prefer-inline-if
  prefer-is-variant
  prefer-let
  prefer-let-else
  prefer-or-pattern
  prefer-own-ptr
  prefer-struct-with
  prefer-try
  prefer-var-else
  redundant-bool-compare
  redundant-cast
  redundant-else
  redundant-ignored-match-binding
  redundant-null-check
  redundant-return
  redundant-type-annotation
  reserved-primitive-name
  self-assignment
  self-comparison
  shadow
  trailing-list-comma
  unreachable-code
  unused-import
  unused-local
  unused-param
  useless-expression
].freeze
RESERVED_VALUE_TYPE_NAMES =
Types::RESERVED_VALUE_TYPE_NAMES.to_set.freeze
RESERVED_IMPORT_ALIAS_NAMES =
Types::RESERVED_IMPORT_ALIAS_NAMES.to_set.freeze
RESERVED_TYPE_BINDING_NAMES =
Types::RESERVED_TYPE_BINDING_NAMES.to_set.freeze
AUTO_FIXABLE_RULE_CODES =
%w[
  prefer-let
  redundant-ignored-match-binding
  prefer-let-else
  prefer-var-else
  redundant-bool-compare
  redundant-cast
  redundant-else
  redundant-return
  redundant-type-annotation
  reserved-primitive-name
  trailing-list-comma
].freeze
LINT_TIERS =

NOTE: unused-import is intentionally NOT auto-fixable. Removing an import has non-local effects the linter cannot see under per-file validation: it may drop extension methods / canonical hooks (hash[T], equal[T]), a bare-name type provided via the prelude bootstrap (e.g. std.option), or break downstream consumers of a library module. It remains a reported hint so authors can remove genuinely-unused imports deliberately.

%i[fast full].freeze
STATIC_QUICK_FIX_TITLES =
{
  "line-too-long" => "Wrap long line",
  "prefer-let" => "Replace 'var' with 'let'",
  "redundant-ignored-match-binding" => "Remove redundant as _",
  "redundant-bool-compare" => "Simplify boolean comparison",
  "redundant-cast" => "Remove redundant cast",
  "redundant-else" => "Remove redundant else",
  "redundant-return" => "Remove redundant return",
  "redundant-type-annotation" => "Remove redundant type annotation",
  "prefer-let-else" => "Rewrite as let-else",
  "prefer-var-else" => "Rewrite as var-else",
  "trailing-list-comma" => "Remove trailing list comma",
}.freeze
EVENT_STACK_SNAPSHOT_WARNING_THRESHOLD =
128
FIX_ALL_TITLE =
"Apply all auto-fixes".freeze

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(path: nil, sema_facts: nil, source: nil, unresolved_import_paths: nil, imported_modules: nil, source_ast: nil, profile: nil, lint_tier: :full, max_line_length: nil) ⇒ Linter

Returns a new instance of Linter.



727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
# File 'lib/milk_tea/tooling/linter.rb', line 727

def initialize(path: nil, sema_facts: nil, source: nil, unresolved_import_paths: nil, imported_modules: nil, source_ast: nil, profile: nil, lint_tier: :full, max_line_length: nil)
  @path = path
  @sema_facts = sema_facts
  @unresolved_import_paths = (unresolved_import_paths || Set.new).to_set
  @imported_modules = (imported_modules || {}).dup
  @source = source.to_s
  @source_lines = source ? source.lines.map { |line| line.delete_suffix("\n") } : []
  @tokens = begin
    Lexer.lex(@source, path: @path)
  rescue StandardError
    []
  end
  @token_index_by_location = @tokens.each_with_index.each_with_object({}) do |(token, index), locations|
    locations[[token.line, token.column]] ||= index
  end
  @tokens_by_line = @tokens.each_with_object(Hash.new { |hash, line| hash[line] = [] }) do |token, |
    next if %i[newline indent dedent eof].include?(token.type)

    [token.line] << token
  end
  @source_ast = source_ast
  @profile = profile
  @warnings = []
  @scopes = []
  @module_bindings = {}
  @declared_callable_names = Set.new
  @declared_directional_functions = {}
  @generic_function_depth = 0
  @current_function_stack = []
  @unsafe_depth = 0
  @binding_ptr_unsafe_uses = Hash.new { |h, k| h[k] = { safe: 0, unsafe: 0 } }
  @ptr_candidates = Set.new
  @reserved_primitive_name_fixes = []
  @lint_tier = self.class.normalize_lint_tier(lint_tier)
  @max_line_length = max_line_length&.to_i&.positive? ? max_line_length.to_i : Formatter::DEFAULT_MAX_LINE_LENGTH
  @cfg_binding_resolution = nil
  @cfg_binding_resolution_computed = false
  @statement_flow_analysis_cache = {}
  @dead_assignment_analysis_cache = {}
end

Class Method Details

.apply_reserved_primitive_name_fixes(source, fixes) ⇒ Object



517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
# File 'lib/milk_tea/tooling/linter.rb', line 517

def self.apply_reserved_primitive_name_fixes(source, fixes)
  return source if fixes.empty?

  line_offsets = line_start_offsets(source)
  edits = fixes.flat_map do |fix|
    fix.sites.uniq { |site| [site.line, site.column] }.filter_map do |site|
      next unless site.line && site.column
      next unless site.line >= 1 && site.line <= line_offsets.length

      {
        start_offset: line_offsets[site.line - 1] + site.column - 1,
        length: site.length,
        replacement: fix.replacement_name,
      }
    end
  end
  return source if edits.empty?

  updated_source = source.dup
  edits.sort_by { |edit| [edit[:start_offset], edit[:length]] }.reverse_each do |edit|
    updated_source[edit[:start_offset], edit[:length]] = edit[:replacement]
  end
  updated_source
end

.apply_suppressions(warnings, suppressions) ⇒ Object



708
709
710
711
712
713
714
715
716
717
718
719
# File 'lib/milk_tea/tooling/linter.rb', line 708

def self.apply_suppressions(warnings, suppressions)
  return warnings if suppressions.empty?

  warnings.reject do |w|
    next false unless w.line
    # Suppression on same line (trailing) or preceding line (leading comment)
    [w.line, w.line - 1].any? do |check_line|
      entry = suppressions[check_line]
      entry == :all || (entry.is_a?(Set) && entry.include?(w.code))
    end
  end
end

.best_effort_lint_context(source, path: nil, profile: nil, label: "best_effort_lint_context") ⇒ Object



576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
# File 'lib/milk_tea/tooling/linter.rb', line 576

def self.best_effort_lint_context(source, path: nil, profile: nil, label: "best_effort_lint_context")
  ast = profile_phase(profile, "#{label}.parse") { Parser.parse(source, path:) }
  imported_modules = {}
  unresolved_import_paths = Set.new

  resolved_path = resolve_lint_path(path)
  if resolved_path && File.file?(resolved_path)
    effective_platform = ModuleLoader.effective_platform_for_path(resolved_path)
    loader = ModuleLoader.new(
      module_roots: MilkTea::ModuleRoots.roots_for_path(resolved_path),
      package_graph: load_lint_package_graph(resolved_path),
      source_overrides: { resolved_path => source },
      platform: effective_platform,
    )
    ast = profile_phase(profile, "#{label}.load_root") { loader.load_file(resolved_path) }
    resolution = profile_phase(profile, "#{label}.imports") do
      loader.imported_modules_for_ast_collecting_errors(ast, importer_path: resolved_path)
    end
    imported_modules = resolution.modules
    unresolved_import_paths.merge(resolution.errors.filter_map { |entry| entry.import&.path&.to_s })
  end

  sema_snapshot = profile_phase(profile, "#{label}.sema") do
    SemanticAnalyzer.tooling_snapshot(ast, imported_modules: imported_modules, path: resolved_path || path)
  end

  {
    ast: ast,
    facts: sema_snapshot.facts,
    sema_snapshot: sema_snapshot,
    errors: sema_snapshot.diagnostics,
    imported_modules: imported_modules,
    unresolved_import_paths: unresolved_import_paths,
  }
rescue StandardError
  unresolved = ast ? ast.imports.map { |i| i.path.to_s }.to_set : Set.new
  { ast: nil, facts: nil, sema_snapshot: nil, errors: nil, imported_modules: {}, unresolved_import_paths: unresolved }
end

.best_effort_sema_facts(source, path: nil) ⇒ Object



615
616
617
# File 'lib/milk_tea/tooling/linter.rb', line 615

def self.best_effort_sema_facts(source, path: nil)
  best_effort_lint_context(source, path:).fetch(:facts)
end

.bool_literal_value(text) ⇒ Object



671
672
673
674
675
676
677
# File 'lib/milk_tea/tooling/linter.rb', line 671

def self.bool_literal_value(text)
  case text
  when "true" then true
  when "false" then false
  else nil
  end
end

.build_prefer_let_else_fix(lines, if_idx, symbol_name: nil) ⇒ Object



552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
# File 'lib/milk_tea/tooling/linter.rb', line 552

def self.build_prefer_let_else_fix(lines, if_idx, symbol_name: nil)
  return nil if if_idx.nil? || if_idx <= 0 || if_idx >= lines.length

  declaration = lines[if_idx - 1].delete_suffix("\n")
  guard = lines[if_idx].delete_suffix("\n")
  name = symbol_name ||
    guard[/\A\s*if\s+([A-Za-z_][A-Za-z0-9_]*)\s*==\s*null\s*:/, 1] ||
    guard[/\A\s*if\s+null\s*==\s*([A-Za-z_][A-Za-z0-9_]*)\s*:/, 1]
  return nil unless name

  declaration_comment = declaration[/\s+#.*\z/] || ""
  declaration_base = declaration.sub(/\s+#.*\z/, "").rstrip
  declaration_match = declaration_base.match(/\A(\s*)(let|var)\s+#{Regexp.escape(name)}\s*=\s*.+\z/)
  return nil unless declaration_match
  return nil if declaration_base.end_with?(" else:")
  return nil unless guard.match?(/\A\s*if\s+(?:#{Regexp.escape(name)}\s*==\s*null|null\s*==\s*#{Regexp.escape(name)})\s*:\s*(?:#.*)?\z/)

  new_text = +"#{declaration_base} else:"
  new_text << declaration_comment unless declaration_comment.empty?
  new_text << "\n"

  { start_line_idx: if_idx - 1, end_line_idx: if_idx, new_text: }
end

.collect_reserved_primitive_name_fixes(source, path: nil, sema_facts: UNSET, unresolved_import_paths: UNSET) ⇒ Object



275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
# File 'lib/milk_tea/tooling/linter.rb', line 275

def self.collect_reserved_primitive_name_fixes(source, path: nil, sema_facts: UNSET, unresolved_import_paths: UNSET)
  sema_facts_provided = !sema_facts.equal?(UNSET)
  unresolved_import_paths_provided = !unresolved_import_paths.equal?(UNSET)
  context = nil
  if !sema_facts_provided || !unresolved_import_paths_provided
    context = best_effort_lint_context(source, path:)
    sema_facts = context[:facts] unless sema_facts_provided
    unresolved_import_paths = context[:unresolved_import_paths] unless unresolved_import_paths_provided
  end

  ast = sema_facts&.ast || context&.fetch(:ast, nil) || Parser.parse(source, path:)
  imported_modules = context&.fetch(:imported_modules, nil)
  imported_modules ||= imported_modules_from_facts(ast, sema_facts)
  linter = new(path:, sema_facts:, source:, unresolved_import_paths:, imported_modules:, source_ast: ast)
  linter.lint(ast)
  linter.reserved_primitive_name_fixes
end

.default_config_sourceObject



261
262
263
264
265
266
267
268
269
270
271
272
273
# File 'lib/milk_tea/tooling/linter.rb', line 261

def self.default_config_source
  lines = [
    "# Default Milk Tea lint configuration.",
    "# Remove entries from `select` to disable rules globally.",
    "max_line_length: #{Formatter::DEFAULT_MAX_LINE_LENGTH}",
    "select:",
  ]
  KNOWN_RULE_CODES.each do |code|
    lines << "  - #{code}"
  end
  lines << "ignore: []"
  "#{lines.join("\n")}\n"
end

.effective_max_line_length(path = nil) ⇒ Object



257
258
259
# File 'lib/milk_tea/tooling/linter.rb', line 257

def self.effective_max_line_length(path = nil)
  load_config(path)&.fetch(:max_line_length, nil) || Formatter::DEFAULT_MAX_LINE_LENGTH
end

.error_signature_counts(errors) ⇒ Object



505
506
507
508
509
# File 'lib/milk_tea/tooling/linter.rb', line 505

def self.error_signature_counts(errors)
  Array(errors).each_with_object(Hash.new(0)) do |error, counts|
    counts[[error.line, error.column, error.message]] += 1
  end
end

.filter_by_rules(warnings, select:, ignore:) ⇒ Object



721
722
723
724
725
# File 'lib/milk_tea/tooling/linter.rb', line 721

def self.filter_by_rules(warnings, select:, ignore:)
  warnings = warnings.select { |w| select.include?(w.code) } if select
  warnings = warnings.reject { |w| ignore.include?(w.code) } if ignore
  warnings
end

.fix_source(source, path: nil, sema_facts: nil, select: nil, ignore: nil, max_passes: 5, profile: nil) ⇒ Object

Apply auto-fixable rules to source text. Handles: prefer-let, redundant-ignored-match-binding, prefer-let-else, prefer-var-else, redundant-bool-compare, redundant-else, redundant-return, reserved-primitive-name, trailing-list-comma. Returns the fixed source (may be identical if nothing was fixable).



358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
# File 'lib/milk_tea/tooling/linter.rb', line 358

def self.fix_source(source, path: nil, sema_facts: nil, select: nil, ignore: nil, max_passes: 5, profile: nil)
  pass_limit = [max_passes.to_i, 1].max
  current_source = source
  current_sema_facts = sema_facts

  pass_limit.times do
    updated_source = fix_source_single_pass_isolating_rules(
      current_source,
      path:,
      sema_facts: current_sema_facts,
      select:,
      ignore:,
      profile:,
    )
    return current_source if updated_source == current_source

    current_source = updated_source
    # Facts passed by callers are only valid for the first source snapshot.
    current_sema_facts = nil
  end

  current_source
end

.fix_source_single_pass(source, path: nil, sema_facts: nil, select: nil, ignore: nil) ⇒ Object



431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
# File 'lib/milk_tea/tooling/linter.rb', line 431

def self.fix_source_single_pass(source, path: nil, sema_facts: nil, select: nil, ignore: nil)
  cfg = load_config(path)
  effective_select = select || cfg&.fetch(:select, nil)
  effective_ignore = ignore || cfg&.fetch(:ignore, nil)
  rule_enabled = lambda do |code|
    next false if effective_select && !effective_select.include?(code)
    next false if effective_ignore && effective_ignore.include?(code)

    true
  end

  working_context = sema_facts ? nil : best_effort_lint_context(source, path:)
  working_sema_facts = sema_facts || working_context[:facts]
  unresolved_import_paths = sema_facts ? Set.new : working_context[:unresolved_import_paths]
  warnings = lint_source(
    source,
    path:,
    sema_facts: working_sema_facts,
    unresolved_import_paths:,
    select:,
    ignore:,
  )
  lines = source.lines

  warnings.group_by(&:code).each do |code, code_warnings|
    next unless rule_enabled.call(code)

    code_warnings.sort_by { |w| [-(w.line || 0), -(w.column || 0)] }.each do |warning|
      edits = FixEngine.edits_for_rule(code, lines, warning)
      FixEngine.apply_fix_edits(lines, edits)
    end
  end

  fixed_source = lines.join

  if rule_enabled.call("reserved-primitive-name")
    reserved_warnings = lint_source(fixed_source, path:, select: Set["reserved-primitive-name"]).select do |w|
      w.code == "reserved-primitive-name" && w.line && w.column && w.symbol_name
    end
    unless reserved_warnings.empty?
      warning_sites = reserved_warnings.each_with_object(Set.new) do |warning, sites|
        sites << [warning.line, warning.column, warning.symbol_name]
      end
      reserved_fixes = collect_reserved_primitive_name_fixes(fixed_source, path:).select do |fix|
        declaration_site = fix.sites.first
        warning_sites.include?([declaration_site.line, declaration_site.column, fix.original_name])
      end

      fixed_source = apply_reserved_primitive_name_fixes(fixed_source, reserved_fixes)
    end
  end
  # Validation compares a best-effort re-analysis of the fixed source against
  # a baseline. When the caller supplied rich (full-package) facts there is
  # no working_context, but the fixed-source check is still best-effort — so
  # the baseline must also come from a best-effort analysis of the ORIGINAL
  # source, otherwise pre-existing best-effort-only errors (e.g. unresolved
  # cross-module imports in std files) would be counted as "new" and reject
  # every otherwise-valid fix.
  baseline_context = working_context || best_effort_lint_context(source, path:)
  validated_fixed_source(source, fixed_source, path:, baseline_errors: baseline_context[:errors])
end

.fix_source_single_pass_isolating_rules(source, path: nil, sema_facts: nil, select: nil, ignore: nil, profile: nil) ⇒ Object



382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
# File 'lib/milk_tea/tooling/linter.rb', line 382

def self.fix_source_single_pass_isolating_rules(source, path: nil, sema_facts: nil, select: nil, ignore: nil, profile: nil)
  cfg = load_config(path)
  effective_select = select || cfg&.fetch(:select, nil)
  effective_ignore = ignore || cfg&.fetch(:ignore, nil)
  enabled_rules = AUTO_FIXABLE_RULE_CODES.select do |rule_code|
    next false if effective_select && !effective_select.include?(rule_code)
    next false if effective_ignore && effective_ignore.include?(rule_code)

    true
  end
  return source if enabled_rules.empty?

  current_source = source
  current_sema_facts = sema_facts

  # When no semantic facts are supplied, omit the argument entirely so
  # lint_source rebuilds a best-effort context. Passing an explicit `nil`
  # would suppress sema-dependent rules (e.g. widening redundant-cast).
  preflight_args = {
    path:,
    select: Set.new(enabled_rules),
    ignore: effective_ignore,
    profile:,
  }
  preflight_args[:sema_facts] = current_sema_facts if current_sema_facts
  preflight_warnings = lint_source(current_source, **preflight_args)
  return current_source if preflight_warnings.empty?

  preflight_codes = preflight_warnings.map(&:code).to_set
  active_rules = enabled_rules.select { |rule_code| preflight_codes.include?(rule_code) }
  return current_source if active_rules.empty?

  active_rules.each do |rule_code|
    updated_source = fix_source_single_pass(
      current_source,
      path:,
      sema_facts: current_sema_facts,
      select: Set[rule_code],
      ignore: effective_ignore,
    )
    next if updated_source == current_source

    current_source = updated_source
    current_sema_facts = nil
  end

  current_source
end

.imported_modules_from_facts(ast, sema_facts) ⇒ Object



341
342
343
344
345
346
347
348
349
# File 'lib/milk_tea/tooling/linter.rb', line 341

def self.imported_modules_from_facts(ast, sema_facts)
  return {} unless ast && sema_facts

  ast.imports.each_with_object({}) do |import, imported_modules|
    alias_name = import.alias_name || import.path.parts.last
    module_binding = sema_facts.imports[alias_name]
    imported_modules[import.path.to_s] = module_binding if module_binding
  end
end

.introduces_new_errors?(modified_counts, baseline_counts) ⇒ Boolean

Returns:

  • (Boolean)


511
512
513
514
515
# File 'lib/milk_tea/tooling/linter.rb', line 511

def self.introduces_new_errors?(modified_counts, baseline_counts)
  modified_counts.any? do |signature, count|
    count > baseline_counts.fetch(signature, 0)
  end
end

.line_start_offsets(source) ⇒ Object



542
543
544
545
546
547
548
549
550
# File 'lib/milk_tea/tooling/linter.rb', line 542

def self.line_start_offsets(source)
  offsets = []
  start_offset = 0
  source.lines.each do |line|
    offsets << start_offset
    start_offset += line.length
  end
  offsets
end

.lint_source(source, path: nil, select: nil, ignore: nil, sema_facts: UNSET, unresolved_import_paths: UNSET, profile: nil, lint_tier: :full) ⇒ Object



203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
# File 'lib/milk_tea/tooling/linter.rb', line 203

def self.lint_source(source, path: nil, select: nil, ignore: nil, sema_facts: UNSET, unresolved_import_paths: UNSET, profile: nil, lint_tier: :full)
  sema_facts_provided = !sema_facts.equal?(UNSET)
  unresolved_import_paths_provided = !unresolved_import_paths.equal?(UNSET)
  if sema_facts_provided && !unresolved_import_paths_provided
    # Callers that already computed semantic facts should not pay for a
    # second context bootstrap only to derive unresolved imports.
    unresolved_import_paths = Set.new
    unresolved_import_paths_provided = true
  end
  cfg = load_config(path)
  context = nil
  if !sema_facts_provided || !unresolved_import_paths_provided
    context = best_effort_lint_context(source, path:, profile:, label: "context_bootstrap")
    sema_facts = context[:facts] unless sema_facts_provided
    unresolved_import_paths = context[:unresolved_import_paths] unless unresolved_import_paths_provided
  end

  ast = profile_phase(profile, "resolve_ast") do
    sema_facts&.ast || context&.fetch(:ast, nil) || Parser.parse(source, path:)
  end
  imported_modules = context&.fetch(:imported_modules, nil)
  imported_modules ||= imported_modules_from_facts(ast, sema_facts)
  suppressions = {}
  if source.match?(/#\s*lint:\s*ignore(?:\(|\b)/)
    trivia = profile_phase(profile, "lex_trivia") { Lexer.lex_with_trivia(source, path:).trivia }
    suppressions = profile_phase(profile, "parse_suppressions") { parse_suppressions(trivia) }
  end
  warnings = new(
    path:,
    sema_facts:,
    source:,
    unresolved_import_paths:,
    imported_modules:,
    source_ast: ast,
    profile:,
    lint_tier: normalize_lint_tier(lint_tier),
    max_line_length: cfg&.fetch(:max_line_length, nil),
  ).lint(ast)
  warnings = profile_phase(profile, "apply_suppressions") { apply_suppressions(warnings, suppressions) }

  # Layer in config-file defaults before per-call overrides
  if cfg
    select ||= cfg[:select]
    ignore ||= cfg[:ignore]
  end

  warnings = profile_phase(profile, "filter_rules") { filter_by_rules(warnings, select:, ignore:) }
  warnings
end

.load_config(path) ⇒ Object

Load the nearest .mt-lint.yml walking up from the source file's directory. Returns Hash { select: Set|nil, ignore: Set|nil } or nil if no config found.



295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
# File 'lib/milk_tea/tooling/linter.rb', line 295

def self.load_config(path)
  resolved_path = resolve_lint_path(path)
  return nil unless resolved_path

  dir = File.directory?(resolved_path) ? resolved_path : File.dirname(resolved_path)
  # Walk up until we either find a config or leave the project
  100.times do
    candidate = File.join(dir, DEFAULT_CONFIG_FILE_NAME)
    if File.exist?(candidate)
      return parse_config_file(candidate)
    end
    parent = File.dirname(dir)
    break if parent == dir  # filesystem root

    dir = parent
  end
  nil
rescue StandardError
  nil
end

.load_lint_package_graph(path) ⇒ Object



686
687
688
689
690
# File 'lib/milk_tea/tooling/linter.rb', line 686

def self.load_lint_package_graph(path)
  PackageGraph.load(path)
rescue PackageManifestError, PackageLockError
  nil
end

.negate_expression_text(text) ⇒ Object



679
680
681
682
683
684
# File 'lib/milk_tea/tooling/linter.rb', line 679

def self.negate_expression_text(text)
  stripped = text.strip
  return "not #{stripped}" if stripped.match?(/\A[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*\z/)

  "not (#{stripped})"
end

.normalize_lint_tier(tier) ⇒ Object



198
199
200
201
# File 'lib/milk_tea/tooling/linter.rb', line 198

def self.normalize_lint_tier(tier)
  normalized = tier.to_s.strip.downcase.to_sym
  LINT_TIERS.include?(normalized) ? normalized : :full
end

.parse_config_file(path) ⇒ Object



316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
# File 'lib/milk_tea/tooling/linter.rb', line 316

def self.parse_config_file(path)
  require "yaml"
  raw = YAML.safe_load_file(path, symbolize_names: true) || {}
  result = {}
  if (s = raw[:select])
    result[:select] = Array(s).map(&:to_s).to_set
  end
  if (i = raw[:ignore])
    result[:ignore] = Array(i).map(&:to_s).to_set
  end
  if raw.key?(:max_line_length)
    max_line_length = raw[:max_line_length].to_i
    result[:max_line_length] = max_line_length if max_line_length.positive?
  end
  result
rescue StandardError
  {}
end

.parse_suppressions(trivia) ⇒ Object

Parse # lint: ignore / # lint: ignore(rule1, rule2) comments. Returns Hash { line_number => Set | :all }



694
695
696
697
698
699
700
701
702
703
704
705
706
# File 'lib/milk_tea/tooling/linter.rb', line 694

def self.parse_suppressions(trivia)
  result = {}
  trivia.select { |t| t.kind == :comment }.each do |t|
    text = t.text.strip
    if (m = text.match(/\A#\s*lint:\s*ignore\(([^)]+)\)\z/))
      codes = m[1].split(",").map(&:strip).to_set
      result[t.line] = codes
    elsif text.match(/\A#\s*lint:\s*ignore\z/)
      result[t.line] = :all
    end
  end
  result
end

.profile_phase(profile, name) ⇒ Object



335
336
337
338
339
# File 'lib/milk_tea/tooling/linter.rb', line 335

def self.profile_phase(profile, name)
  return yield unless profile

  profile.measure(name) { yield }
end

.quick_fix_title(code) ⇒ Object



253
254
255
# File 'lib/milk_tea/tooling/linter.rb', line 253

def self.quick_fix_title(code)
  STATIC_QUICK_FIX_TITLES[code]
end

.redundant_bool_compare_replacement(expression_text) ⇒ Object



649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
# File 'lib/milk_tea/tooling/linter.rb', line 649

def self.redundant_bool_compare_replacement(expression_text)
  match = expression_text.match(/\A\s*(.+?)\s*(==|!=)\s*(.+?)\s*\z/m)
  return nil unless match

  left_text = match[1].strip
  operator = match[2]
  right_text = match[3].strip
  left_bool = bool_literal_value(left_text)
  right_bool = bool_literal_value(right_text)
  return nil if left_bool.nil? == right_bool.nil?

  literal_value = left_bool.nil? ? right_bool : left_bool
  compared_text = left_bool.nil? ? left_text : right_text
  negate = if operator == "=="
             literal_value == false
           else
             literal_value == true
           end

  negate ? negate_expression_text(compared_text) : compared_text
end

.redundant_ignored_match_binding_span(line, column:) ⇒ Object



631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
# File 'lib/milk_tea/tooling/linter.rb', line 631

def self.redundant_ignored_match_binding_span(line, column:)
  anchor_idx = column - 1
  return nil unless anchor_idx >= 0 && anchor_idx < line.length

  scan_start = [anchor_idx - 5, 0].max
  match = line[scan_start..]&.match(/\s+as\s+_/)
  return nil unless match

  start_char = scan_start + match.begin(0)
  end_char = scan_start + match.end(0)
  return nil unless anchor_idx >= start_char && anchor_idx < end_char

  {
    start_char:,
    end_char:,
  }
end

.resolve_lint_path(path) ⇒ Object



619
620
621
622
623
624
625
626
627
628
629
# File 'lib/milk_tea/tooling/linter.rb', line 619

def self.resolve_lint_path(path)
  return nil unless path.is_a?(String) && !path.empty?

  if path.start_with?("file://")
    CGI.unescape(URI.parse(path).path)
  else
    File.expand_path(path)
  end
rescue StandardError
  nil
end

.validated_fixed_source(original_source, fixed_source, path:, baseline_errors:) ⇒ Object



493
494
495
496
497
498
499
500
501
502
503
# File 'lib/milk_tea/tooling/linter.rb', line 493

def self.validated_fixed_source(original_source, fixed_source, path:, baseline_errors:)
  return fixed_source if fixed_source == original_source

  fixed_context = best_effort_lint_context(fixed_source, path:)
  return fixed_source if fixed_context[:ast] && !introduces_new_errors?(
    error_signature_counts(fixed_context[:errors]),
    error_signature_counts(baseline_errors),
  )

  original_source
end

Instance Method Details

#full_tier?Boolean

Returns:

  • (Boolean)


778
779
780
# File 'lib/milk_tea/tooling/linter.rb', line 778

def full_tier?
  @lint_tier == :full
end

#lint(ast) ⇒ Object



768
769
770
771
772
773
774
775
776
# File 'lib/milk_tea/tooling/linter.rb', line 768

def lint(ast)
  @source_ast ||= ast
  visit_source_file(ast)
  profile_phase("rule.doc_tag") { emit_doc_tag_warnings(ast) } if full_tier?
  profile_phase("rule.event_capacity") { emit_event_capacity_warnings(ast) }
  profile_phase("rule.trailing_list_comma") { emit_trailing_list_comma_warnings(ast) }
  profile_phase("rule.line_too_long") { emit_line_too_long_warnings }
  @warnings
end

#reserved_primitive_name_fixesObject



782
783
784
# File 'lib/milk_tea/tooling/linter.rb', line 782

def reserved_primitive_name_fixes
  @reserved_primitive_name_fixes
end