Skip to content

Skip: replace include? with glob-based matching to eliminate false positives #2

@jcouball

Description

@jcouball

Summary

Replace the String#include? substring test in Example#generate with Ant-style glob matching via File.fnmatch so that YardExampleTest.skip patterns are precise and unambiguous.

Motivation

The current skip check uses substring matching:

# lib/yard_example_test/example.rb
next if YardExampleTest.skips.any? { |skip| this.definition.include?(skip) }

Because YARD paths such as "FooBar#baz" contain shorter names as substrings, a pattern like "Foo" — intended to skip only the Foo class — will also silently skip FooBar#baz, Foo::Bar#baz, and any other path that happens to contain "Foo".

Concrete example

YardExampleTest.skip('Foo')   # intended: skip Foo only

# Paths that are INCORRECTLY skipped:
#   FooBar#baz          — starts with "Foo"
#   Foo::Bar.baz        — "Foo" is a prefix component
#   SomeClass#get_foo   — "foo" embedded in method name (case-sensitive, but still fragile)

There is also no way to express "skip all methods of Foo but not Foo itself", or "skip only the Foo class but not its subnamespace".

Current behaviour

# lib/yard_example_test/example.rb
next if YardExampleTest.skips.any? { |skip| this.definition.include?(skip) }

this.definition is a YARD path string such as "MyClass#my_method" or "MyClass::Nested.class_method".

Proposed solution

Normalise ::/ in both the pattern and the definition, then delegate to File.fnmatch with File::FNM_PATHNAME:

next if YardExampleTest.skips.any? do |skip|
  pattern    = skip.gsub('::', '/')
  definition = this.definition.gsub('::', '/')
  File.fnmatch(pattern, definition, File::FNM_PATHNAME)
end

With FNM_PATHNAME, * matches any characters except /, so it naturally stops at namespace (::) and method separator (#, .) boundaries. ** matches across boundaries.

Pattern reference

Pattern Matches Does not match
"MyClass" MyClass MyClass#foo, FooMyClass
"MyClass#*" MyClass#foo, MyClass#bar MyClass.foo, MyClass::Nested#foo
"MyClass.*" MyClass.foo MyClass#foo
"MyClass::*" MyClass::Nested MyClass, MyClass::Nested#foo
"MyClass::**" MyClass, MyClass::Nested, MyClass::Nested::Deep MyClass#foo (method)
"MyClass::**::*" MyClass::Nested, MyClass::Nested::Deep MyClass itself
"MyClass::**::AnotherClass" MyClass::AnotherClass, MyClass::Nested::AnotherClass MyClass::SomethingElse
"MyClass::**#*" MyClass#foo, MyClass::Nested#bar MyClass, MyClass::Nested

Note: ** followed by a literal segment (e.g. "MyClass::**::AnotherClass") requires the definition to have that segment at any depth inside MyClass. Because File::FNM_PATHNAME requires ** to be its own path component, patterns must use :: separators correctly (e.g. MyClass::**::Something, not MyClass::**Something).

Scope

In scope

  • Replace the include? check in Example#generate with File.fnmatch-based matching
  • Update YardExampleTest.skip API docs to describe the new glob syntax and list example patterns
  • Update the generate YARD doc (step 2 of the numbered list) to reference glob semantics
  • Unit/integration tests covering the pattern table above

Out of scope

  • Changes to how skip patterns are stored (Array unchanged)
  • Case-insensitive matching
  • Negation patterns (e.g. !MyClass#foo)

Implementation notes

  1. Normalisation — both skip and this.definition must have :: replaced with / before passing to File.fnmatch. The YARD separators # and . are left as-is; File::FNM_PATHNAME treats them as ordinary characters, so * will not cross them.

  2. File::FNM_PATHNAME — without this flag, * matches / and the namespace isolation breaks down. Always pass this flag.

  3. ** semanticsFile.fnmatch("MyClass/**", "MyClass/Nested/Deep", File::FNM_PATHNAME) returns true; File.fnmatch("MyClass/**", "MyClass", File::FNM_PATHNAME) returns false. A convenience helper definition_matches?(skip, definition) could abstract the normalisation.

  4. Backward compatibility — existing plain-name patterns (e.g. "MyClass") already work correctly as exact matches under the new scheme, no migration needed for users who skip by full class name.

Acceptance criteria

  • skip("Foo") no longer matches FooBar#baz
  • skip("Foo") still matches Foo exactly
  • skip("Foo#*") skips all instance methods of Foo but not Foo itself
  • skip("Foo::*") skips direct namespace children of Foo but not Foo itself or deeper descendants
  • skip("Foo::**") skips Foo and all descendants (classes and modules, not methods)
  • skip("Foo::**#*") skips all instance methods at any depth under Foo
  • YardExampleTest.skip YARD doc lists the supported glob syntax
  • All existing cucumber scenarios continue to pass

TDD guidance

Starter tests (RSpec or Minitest)

# For each row: [skip_pattern, definition, should_skip?]
CASES = [
  ['Foo',           'Foo',              true],
  ['Foo',           'FooBar',           false],
  ['Foo',           'FooBar#baz',       false],
  ['Foo',           'Foo#bar',          false],
  ['Foo#*',         'Foo#bar',          true],
  ['Foo#*',         'Foo#baz',          true],
  ['Foo#*',         'Foo.bar',          false],
  ['Foo#*',         'Foo::Nested#bar',  false],
  ['Foo.*',         'Foo.bar',          true],
  ['Foo.*',         'Foo#bar',          false],
  ['Foo::*',        'Foo::Bar',         true],
  ['Foo::*',        'Foo',              false],
  ['Foo::*',        'Foo::Bar::Baz',    false],
  ['Foo::**',       'Foo',              true],
  ['Foo::**',       'Foo::Bar',         true],
  ['Foo::**',       'Foo::Bar::Baz',    true],
  ['Foo::**',       'Foo#bar',          false],
  ['Foo::**::*',    'Foo::Bar',         true],
  ['Foo::**::*',    'Foo',              false],
  ['Foo::**#*',     'Foo#bar',          true],
  ['Foo::**#*',     'Foo::Bar#baz',     true],
  ['Foo::**#*',     'Foo',              false],
].freeze

Branch & PR guidance

  • Branch from initial_implementation
  • Suggested branch name: refactor/skip-glob-matching
  • PR title: refactor(skip): use File.fnmatch for glob-based skip pattern matching
  • Reference this issue in the PR description

Related

  • lib/yard_example_test/example.rbExample#generate, line with include? check
  • lib/yard_example_test.rbYardExampleTest.skip and YardExampleTest.skips API

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions