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
-
.match?(patterns, path) ⇒ Boolean
Whether a touched file fires any of these patterns.
-
.parse(value, path:) ⇒ Array<String>
Frozen normalized patterns, or UNCONDITIONAL.
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
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.
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 |