Module: Pikuri::Skill::PathMatcher

Defined in:
lib/pikuri/skill/path_matcher.rb

Overview

Parses a skill's paths: frontmatter key — gitignore pattern syntax, ripgrep's flavour of it — and answers whether a touched file fires what it parsed:

patterns = PathMatcher.parse('{app,lib}/**/*.rb, docs', path: md)
# => ["{app,lib}/**/*.rb", "docs"]
PathMatcher.match?(patterns, 'lib/pikuri/agent.rb')   # => true
PathMatcher.match?(patterns, 'spec/agent_spec.rb')    # => false
PathMatcher.parse(nil, path: md)                      # => [] — advertised always

A scalar comma-splits; a trailing /** goes, because gitignore matches a directory and its contents either way and src / src/** should compare equal.

Implementation details

UNCONDITIONAL is an inversion trap worth knowing before you author frontmatter: paths: [], paths: ["**"] and a bare paths: with nothing under it all mean advertise unconditionally — the opposite of what "this skill matches no paths" is meant to say, and where an unreadable value lands too, which is why that one warns. Both harnesses that honour the key read it this way; hiding a skill from the model is a different key's job.

Gitignore syntax is the rules a .gitignore author already has, and they are emphatically not minimatch's: a slash-less pattern floats to any depth (+build.gradle+ fires on app/build.gradle), a pattern carrying a slash anywhere is rooted at the workspace root (+src/main+ never fires on vendor/src/main), a bare directory covers everything beneath it, and {app,lib} expands. PathMatcher.match? gets the directory rule by walking the touched path's ancestors, and reads no filesystem — a write to a path that does not exist yet still fires. COMPARISON.md (Skills → Frontmatter) carries the measurement behind each rule.

Constant Summary collapse

UNCONDITIONAL =

The normalized form of "no gate": this skill is advertised on every turn, like a skill that never mentioned paths: at all.

[].freeze

Class Method Summary collapse

Class Method Details

.match?(patterns, path) ⇒ Boolean

Whether a touched file fires any of these patterns.

PathMatcher.match?(['ideas'], 'ideas/sub/note.md')          # => true
PathMatcher.match?(['src/main'], 'other/src/main/A.java')   # => false — rooted

Parameters:

  • patterns (Array<String>)

    as parse returns; UNCONDITIONAL matches nothing, since a skill with no gate is never asked

  • path (String)

    the touched file, relative to the workspace root

Returns:

  • (Boolean)

Raises:

  • (ArgumentError)

    on an absolute path — the caller resolves a model-supplied one against the workspace root first, and a path left absolute would quietly match against the wrong tree



84
85
86
87
88
89
90
91
92
93
# File 'lib/pikuri/skill/path_matcher.rb', line 84

def match?(patterns, path)
  raise ArgumentError, "path must be workspace-relative, got #{path.inspect}" if path.start_with?('/')

  segments = path.delete_prefix('./').split('/')
  ancestors = (1..segments.size).map { |depth| segments.first(depth).join('/') }
  patterns.any? do |pattern|
    glob = glob_for(pattern)
    ancestors.any? { |ancestor| File.fnmatch?(glob, ancestor, FNMATCH_FLAGS) }
  end
end

.parse(value, path:) ⇒ Array<String>

Returns frozen normalized patterns, or UNCONDITIONAL.

Parameters:

  • value (String, Array<String>, nil)

    the raw frontmatter paths: value; nil when the key is absent

  • path (String)

    the SKILL.md location, used in warnings only

Returns:

  • (Array<String>)

    frozen normalized patterns, or UNCONDITIONAL



59
60
61
62
63
64
65
66
67
68
69
70
# File 'lib/pikuri/skill/path_matcher.rb', line 59

def parse(value, path:)
  return UNCONDITIONAL if value.nil?

  patterns = coerce(value)
  if patterns.nil?
    LOGGER.warn("#{path}: 'paths:' must be a string or a list of strings, got " \
                "#{value.class}; ignored, so the skill stays unconditional")
    return UNCONDITIONAL
  end

  normalize(patterns)
end