module Backlog # Filesystem-only detection helpers for the `inspect` subcommand: does the # repo already have in-repo issue files. Deliberately narrow — network # checks (Planka, tea, gh) stay in bin/os-backlog where the rest of the # CLI's IO lives; this class exists so that piece is unit-testable without # touching the network or shelling out. class Inspector CANDIDATE_DIRS = ["docs/issues"].freeze CANDIDATE_FILES = ["ISSUES.md"].freeze # Labels that mark an issue as part of a dependency chain. Matched as # whole (hyphen-preserving) tokens, case-insensitively — "not-blocked" # does NOT match "blocked". CHAIN_LABELS = %w[blocked blocked-by depends depends-on prereq prerequisite waiting needs].freeze # Dependency language that must appear near a cross-reference for it to # count as chain evidence — a plain "fixed in #12" does not. DEPENDENCY_LANGUAGE = /\b(blocked|depends|after|requires|prereq|follows)/i CROSS_REF_WINDOW = 40 # chars either side of the "#N" reference # Pure classifier for the shape of a repo's open git issues — no shell, # no network. Feeds the route skill's split-vs-migrate recommendation # (ADR-0033: git issues are specs; a chain of interdependent issues is # spec-shaped and should stay put). # # @param issues [Array, nil] one hash per open issue: # {number:, title:, labels: [String], body:} # @return [String, nil] "sequential-chain" | "flat-adhoc" | "mixed", or # nil when there's no metadata or fewer than 3 issues (no evidence, # which is distinct from evidence of flatness) def self.classify_issue_shape(issues) return nil if issues.nil? || issues.size < 3 numbers = issues.map { |issue| issue[:number].to_i } with_evidence = issues.count { |issue| chain_evidence?(issue, numbers) } return "flat-adhoc" if with_evidence.zero? with_evidence * 3 >= issues.size * 2 ? "sequential-chain" : "mixed" end def self.chain_evidence?(issue, numbers) chain_label?(issue[:labels]) || chain_cross_reference?(issue[:body], own_number: issue[:number].to_i, numbers: numbers) end def self.chain_label?(labels) Array(labels).any? do |label| tokens = label.to_s.downcase.scan(/[a-z][a-z-]*[a-z]|[a-z]/) tokens.any? { |token| CHAIN_LABELS.include?(token) } end end def self.chain_cross_reference?(body, own_number:, numbers:) text = body.to_s text.enum_for(:scan, /#(\d+)/).any? do match = Regexp.last_match referenced = match[1].to_i next false if referenced == own_number || !numbers.include?(referenced) window_start = [match.begin(0) - CROSS_REF_WINDOW, 0].max window = text[window_start, (match.begin(0) - window_start) + match[0].length + CROSS_REF_WINDOW] window.match?(DEPENDENCY_LANGUAGE) end end # @param repo_path [String] absolute path to the repo # @return [Array] repo-relative paths of in-repo issue tracking # found (empty if none) def self.issue_files(repo_path) found = [] CANDIDATE_DIRS.each do |rel| found << rel if Dir.exist?(File.join(repo_path, rel)) end CANDIDATE_FILES.each do |rel| found << rel if File.exist?(File.join(repo_path, rel)) end found end end end