|
| 1 | +#!/usr/bin/env ruby |
| 2 | +# frozen_string_literal: true |
| 3 | + |
| 4 | +# Consistency linter for the rails-upgrade and upgrade-cleanup skills. |
| 5 | +# |
| 6 | +# Catches drift classes that are easy to introduce by hand and easy to miss |
| 7 | +# in review: broken file-path references in prose, orphaned reference / |
| 8 | +# workflow / version-guide files, SKILL.md index rows that don't match what's |
| 9 | +# on disk, gaps in "Step N" header numbering, and pattern-catalog filenames |
| 10 | +# whose derived version disagrees with their own `version:` key. |
| 11 | +# |
| 12 | +# Usage: ruby bin/lint-skill (exits 1 on any error) |
| 13 | + |
| 14 | +require "yaml" |
| 15 | + |
| 16 | +ROOT = File.expand_path("..", __dir__) |
| 17 | + |
| 18 | +SKILLS = { |
| 19 | + "rails-upgrade" => File.join(ROOT, "rails-upgrade"), |
| 20 | + "upgrade-cleanup" => File.join(ROOT, "upgrade-cleanup", "upgrade-cleanup") |
| 21 | +}.freeze |
| 22 | + |
| 23 | +$errors = [] |
| 24 | + |
| 25 | +def err(file, detail) |
| 26 | + $errors << "#{file.sub("#{ROOT}/", "")}: #{detail}" |
| 27 | +end |
| 28 | + |
| 29 | +def markdown_files_for(skill_dir) |
| 30 | + Dir.glob(File.join(skill_dir, "**", "*.md")).sort |
| 31 | +end |
| 32 | + |
| 33 | +# --------------------------------------------------------------------------- |
| 34 | +# 1. File-path tokens in prose resolve to real files on disk |
| 35 | +# --------------------------------------------------------------------------- |
| 36 | +PATH_TOKEN = %r{(?:references|workflows|version-guides|templates|examples|detection-scripts/patterns)/[A-Za-z0-9_./-]+\.(?:md|yml)} |
| 37 | + |
| 38 | +SKILLS.each_value do |skill_dir| |
| 39 | + markdown_files_for(skill_dir).each do |f| |
| 40 | + next if File.basename(f) == "CHANGELOG.md" # historical record — old paths intentionally no longer exist |
| 41 | + |
| 42 | + File.read(f).each_line.with_index(1) do |line, ln| |
| 43 | + next if line.include?(".github/") # e.g. `.github/workflows/ci.yml` — CI config, not a skill path |
| 44 | + next if line.include?("dual-boot") # cross-plugin reference into the external dual-boot skill repo |
| 45 | + |
| 46 | + line.scan(PATH_TOKEN) do |token| |
| 47 | + next if token.include?("{") || token.include?("<") || token.include?("*") |
| 48 | + |
| 49 | + path = File.join(skill_dir, token) |
| 50 | + err(f, "line #{ln}: path `#{token}` does not exist") unless File.exist?(path) |
| 51 | + end |
| 52 | + end |
| 53 | + end |
| 54 | +end |
| 55 | + |
| 56 | +# --------------------------------------------------------------------------- |
| 57 | +# 2. No orphaned reference / workflow / version-guide / template / example files |
| 58 | +# --------------------------------------------------------------------------- |
| 59 | +ORPHAN_CHECK_DIRS = %w[references workflows version-guides templates examples].freeze |
| 60 | + |
| 61 | +SKILLS.each do |skill_name, skill_dir| |
| 62 | + corpus = markdown_files_for(skill_dir).map { |f| [f, File.read(f)] } |
| 63 | + |
| 64 | + ORPHAN_CHECK_DIRS.each do |dir| |
| 65 | + Dir.glob(File.join(skill_dir, dir, "*.md")).sort.each do |file| |
| 66 | + base = File.basename(file) |
| 67 | + mentioned = corpus.any? { |f, content| f != file && content.include?(base) } |
| 68 | + err(file, "orphaned — no inbound pointer from any file in the #{skill_name} skill") unless mentioned |
| 69 | + end |
| 70 | + end |
| 71 | +end |
| 72 | + |
| 73 | +# --------------------------------------------------------------------------- |
| 74 | +# 3. SKILL.md bullet index matches files on disk (workflows/references/examples) |
| 75 | +# --------------------------------------------------------------------------- |
| 76 | +INDEX_CHECK_DIRS = %w[workflows references examples].freeze |
| 77 | + |
| 78 | +SKILLS.each do |skill_name, skill_dir| |
| 79 | + skill_md = File.join(skill_dir, "SKILL.md") |
| 80 | + next unless File.exist?(skill_md) |
| 81 | + |
| 82 | + content = File.read(skill_md).each_line.reject { |l| l.include?("dual-boot") }.join |
| 83 | + |
| 84 | + INDEX_CHECK_DIRS.each do |dir| |
| 85 | + on_disk = Dir.glob(File.join(skill_dir, dir, "*.md")).map { |f| File.basename(f) }.sort |
| 86 | + next if on_disk.empty? |
| 87 | + |
| 88 | + listed = content.scan(%r{`#{dir}/([A-Za-z0-9_./-]+\.md)`}).flatten.sort.uniq |
| 89 | + |
| 90 | + (on_disk - listed).each do |missing| |
| 91 | + err(skill_md, "#{dir}/#{missing} exists on disk but is not listed in SKILL.md") |
| 92 | + end |
| 93 | + (listed - on_disk).each do |stale| |
| 94 | + err(skill_md, "SKILL.md lists `#{dir}/#{stale}` but the file does not exist on disk") |
| 95 | + end |
| 96 | + end |
| 97 | +end |
| 98 | + |
| 99 | +# --------------------------------------------------------------------------- |
| 100 | +# 4. "Step N" headers run 1..N per workflow file, no gaps or duplicates |
| 101 | +# --------------------------------------------------------------------------- |
| 102 | +SKILLS.each_value do |skill_dir| |
| 103 | + Dir.glob(File.join(skill_dir, "workflows", "*.md")).sort.each do |f| |
| 104 | + steps = File.read(f).scan(/^(?:##|###)\s+Step (\d+)[:\s]/).flatten.map(&:to_i) |
| 105 | + next if steps.empty? |
| 106 | + |
| 107 | + expected = (1..steps.length).to_a |
| 108 | + err(f, "step headers are #{steps.inspect}, expected #{expected.inspect}") unless steps == expected |
| 109 | + end |
| 110 | +end |
| 111 | + |
| 112 | +# --------------------------------------------------------------------------- |
| 113 | +# 5. SKILL.md frontmatter `name:` matches its parent directory name |
| 114 | +# --------------------------------------------------------------------------- |
| 115 | +SKILLS.each_value do |skill_dir| |
| 116 | + skill_md = File.join(skill_dir, "SKILL.md") |
| 117 | + next unless File.exist?(skill_md) |
| 118 | + |
| 119 | + content = File.read(skill_md) |
| 120 | + frontmatter = content[/\A---\n(.*?)\n---/m, 1] |
| 121 | + |
| 122 | + if frontmatter.nil? |
| 123 | + err(skill_md, "missing YAML frontmatter (--- name: ... ---)") |
| 124 | + next |
| 125 | + end |
| 126 | + |
| 127 | + name = YAML.safe_load(frontmatter)["name"] |
| 128 | + expected = File.basename(skill_dir) |
| 129 | + |
| 130 | + err(skill_md, "frontmatter name `#{name}` does not match parent directory `#{expected}`") if name != expected |
| 131 | +end |
| 132 | + |
| 133 | +# --------------------------------------------------------------------------- |
| 134 | +# 6. Version-guide hop chain is unbroken (each hop's "to" == next hop's "from") |
| 135 | +# --------------------------------------------------------------------------- |
| 136 | +VERSION_GUIDES_DIR = File.join(SKILLS.fetch("rails-upgrade"), "version-guides") |
| 137 | +version_key = ->(v) { v.split(".").map(&:to_i) } |
| 138 | + |
| 139 | +hops = Dir.glob(File.join(VERSION_GUIDES_DIR, "upgrade-*-to-*.md")).sort.filter_map do |f| |
| 140 | + m = File.basename(f).match(/\Aupgrade-(\d+\.\d+)-to-(\d+\.\d+)\.md\z/) |
| 141 | + if m.nil? |
| 142 | + err(f, "filename doesn't match upgrade-<FROM>-to-<TO>.md") |
| 143 | + next |
| 144 | + end |
| 145 | + { file: f, from: m[1], to: m[2] } |
| 146 | +end |
| 147 | + |
| 148 | +hops.sort_by! { |h| version_key.call(h[:from]) } |
| 149 | + |
| 150 | +hops.each_cons(2) do |prev, cur| |
| 151 | + next if prev[:to] == cur[:from] |
| 152 | + |
| 153 | + err(cur[:file], "hop starts at #{cur[:from]} but the prior hop (#{File.basename(prev[:file])}) ends at #{prev[:to]} — gap or overlap in the version-guide chain") |
| 154 | +end |
| 155 | + |
| 156 | +# --------------------------------------------------------------------------- |
| 157 | +# 7. Pattern catalog filename <-> version sanity |
| 158 | +# --------------------------------------------------------------------------- |
| 159 | +PATTERNS_DIR = File.join(SKILLS.fetch("rails-upgrade"), "detection-scripts", "patterns") |
| 160 | + |
| 161 | +Dir.glob(File.join(PATTERNS_DIR, "rails-*-patterns.yml")).sort.each do |f| |
| 162 | + digits = File.basename(f)[/rails-(\d+)-patterns\.yml/, 1] |
| 163 | + |
| 164 | + if digits.nil? |
| 165 | + err(f, "filename doesn't match rails-<MAJORMINOR>-patterns.yml") |
| 166 | + next |
| 167 | + end |
| 168 | + |
| 169 | + if digits.length != 2 |
| 170 | + err(f, "cannot derive a dotted version from #{digits.length} digits (#{digits}) — update the MAJORMINOR mapping in bin/lint-skill") |
| 171 | + next |
| 172 | + end |
| 173 | + |
| 174 | + derived_version = "#{digits[0]}.#{digits[1]}" |
| 175 | + |
| 176 | + begin |
| 177 | + doc = YAML.safe_load_file(f) |
| 178 | + rescue Psych::SyntaxError => e |
| 179 | + err(f, "YAML parse error: #{e.message}") |
| 180 | + next |
| 181 | + end |
| 182 | + |
| 183 | + next unless doc.is_a?(Hash) |
| 184 | + |
| 185 | + declared_version = doc["version"] |
| 186 | + if declared_version && declared_version.to_s != derived_version |
| 187 | + err(f, "filename implies version #{derived_version} but `version:` key says #{declared_version.inspect}") |
| 188 | + end |
| 189 | +end |
| 190 | + |
| 191 | +# --------------------------------------------------------------------------- |
| 192 | +if $errors.empty? |
| 193 | + puts "lint-skill: OK" |
| 194 | +else |
| 195 | + $errors.each { |e| puts "ERROR #{e}" } |
| 196 | + puts "lint-skill: #{$errors.length} error(s)" |
| 197 | + exit 1 |
| 198 | +end |
0 commit comments