this_is_ruby 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 0a2047298c7f0c4729968ed6827707200b80ad5810ccde2c5fcd6fb009d4b662
4
+ data.tar.gz: 541e8c560fdb8441a4aa4ec0f040827a10454feb8bd467cad44840a614a05455
5
+ SHA512:
6
+ metadata.gz: f09cc8394377d79592a9e8e845a6da04c1dd4fcfa113ebebcf0460af1d8cc3b8c240f8e944048b9a471790d926e1d49123533546aaa093b111598255173de0f4
7
+ data.tar.gz: f4eeae23cde25381e1fd4132f6904e7c4f5a07a1980a8e7855e115d5532ad8c84fb6b25af916264b786e2ec64f2e93f1679597edddfe70c7c7251aea287f35c2
data/CHANGELOG.md ADDED
@@ -0,0 +1,25 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ Initial release.
6
+
7
+ - Recognises generated error pages, bundler and asset-pipeline output,
8
+ Webpacker and Vite builds, SimpleCov reports, SQL structure dumps and
9
+ shadcn/ui components.
10
+ - Writes a managed block in `.gitattributes`, leaving the rest of the file
11
+ untouched, and stays quiet about anything already declared there.
12
+ - `plan`, `apply` and `check` commands; `--all-frontend` opt-in.
13
+
14
+ ## Unreleased
15
+
16
+ - Exit 1 on an unknown command or option instead of 0, so a typo in a CI step
17
+ fails rather than silently skipping `check`.
18
+ - Leave a path alone when its Linguist attribute is already decided, including
19
+ when it was explicitly unset (`-linguist-generated`) or made unspecified
20
+ (`!linguist-generated`). Writing our line after such a declaration reversed
21
+ it, because the last match wins.
22
+ - Rewrite the managed block where it already sits, so content below it stays
23
+ below it.
24
+ - Read `git rev-parse` from stdout alone: with `GIT_TRACE` set, or on any git
25
+ warning, the merged stream became part of the repository root.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Evil Martians
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,95 @@
1
+ # this_is_ruby
2
+
3
+ GitHub decides a repository's language by counting bytes. Ruby code can often be outnumbered by sheer volume of JS/TS/HTML boilerplate. So GitHub labels the repo HTML or TypeScript.
4
+
5
+ This gem gives you controls.
6
+
7
+ ## Install
8
+
9
+ ```ruby
10
+ group :development do
11
+ gem "this_is_ruby", require: false
12
+ end
13
+ ```
14
+
15
+ ## Use
16
+
17
+ ```console
18
+ $ this_is_ruby # write the managed block into .gitattributes
19
+ $ this_is_ruby plan # show what that would write, change nothing
20
+ $ this_is_ruby check # exit 1 when .gitattributes is out of date
21
+ ```
22
+
23
+ `check` belongs in CI
24
+
25
+ ## What it recognises
26
+
27
+ | | attribute |
28
+ |---|---|
29
+ | error pages from `rails new`, verified by the comment naming their own path | `linguist-generated` |
30
+ | `app/assets/builds/` from jsbundling, cssbundling, dartsass, tailwindcss-rails | `linguist-generated` |
31
+ | `public/assets/` precompiled asset pipeline output | `linguist-generated` |
32
+ | `public/packs/`, `public/packs-test/` from Webpacker and Shakapacker | `linguist-generated` |
33
+ | `public/vite*/` from vite_rails | `linguist-generated` |
34
+ | SimpleCov HTML reports, found by the report's own title | `linguist-generated` |
35
+ | `db/structure.sql`, matching how Rails already treats `db/schema.rb` | `linguist-generated` |
36
+ | shadcn/ui components, when `components.json` shows a generator put them there | `linguist-vendored` |
37
+
38
+
39
+ By default the gem only points at files a tool produced. On [inertia-rails/react-starter-kit][irsk], where GitHub counts TypeScript at 60.6%, that clears the HTML but leaves TypeScript the primary language.
40
+
41
+ ## Make it Ruby
42
+
43
+ If you think that despite a huge amount of hand-written JS/TS/HTML, the important work in your repository is Ruby, you can say so:
44
+
45
+ ```console
46
+ $ this_is_ruby --all-frontend
47
+ ```
48
+ On the same repository that marks `app/javascript/` as `linguist-vendored`, and
49
+ GitHub then counts it as **82.6% Ruby, 1.1% TypeScript**. That is not an
50
+ estimate: [irinanazarova/react-starter-kit-this-is-ruby][demo] is a fork of the
51
+ kit whose only change is the sixteen lines this command wrote. Compare its
52
+ language bar with [the upstream one][irsk].
53
+
54
+ The one line that matters is which attribute it uses. [Generated files are suppressed in diffs][docs]; vendored files are not. So `--all-frontend` emits `linguist-vendored`, every source file keeps showing up in full in pull requests, and the only thing that changes is the color of the bar at the top of the page:
55
+
56
+ ```console
57
+ $ git check-attr linguist-vendored linguist-generated -- app/javascript/pages/home/index.tsx
58
+ app/javascript/pages/home/index.tsx: linguist-vendored: set
59
+ app/javascript/pages/home/index.tsx: linguist-generated: unspecified
60
+ ```
61
+
62
+ ## Side effects
63
+
64
+ `linguist-*` attributes are a GitHub convention, and plain git does not act on
65
+ them. Your local `git diff`, your merges, your CI and every checkout stay
66
+ byte-for-byte what they were. Away from GitHub's own pages, nothing changes.
67
+
68
+ On GitHub itself:
69
+
70
+ - **`linguist-generated` collapses the file in diffs.** Linguist's docs put it
71
+ plainly: these files "are suppressed in diffs". Reviewers get a "Load diff"
72
+ button instead of the patch. The gem uses this attribute only for tool
73
+ output; everything hand-written gets `linguist-vendored`, which leaves diffs
74
+ alone. That is why `--all-frontend` vendors your frontend rather than
75
+ marking it generated.
76
+ - **Code search still indexes the files.** They stay findable, and GitHub adds
77
+ `is:generated` and `is:vendored` filters, so a query written as
78
+ `-is:vendored` will skip them.
79
+ - **Syntax highlighting is untouched.** Highlighting follows
80
+ `linguist-language=`, which this gem never writes.
81
+
82
+ Nothing is deleted, moved or rewritten. The files stay in the repository and
83
+ in its history, and GitHub only reads `.gitattributes` once you commit it.
84
+
85
+ ## A note on patterns
86
+
87
+ Directory rules emit `dir/**`, never `dir/*`. In gitignore syntax, which `.gitattributes` shares, a single star does not cross a slash, so `vendor/*` reaches `vendor/turbo.js` and never `vendor/javascript/turbo.js`. There is a spec that asks `git check-attr` rather than trusting the documentation.
88
+
89
+ ## License
90
+
91
+ MIT.
92
+
93
+ [irsk]: https://github.com/inertia-rails/react-starter-kit
94
+ [docs]: https://github.com/github-linguist/linguist/blob/main/docs/overrides.md
95
+ [demo]: https://github.com/irinanazarova/react-starter-kit-this-is-ruby
data/exe/this_is_ruby ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "this_is_ruby"
5
+
6
+ exit ThisIsRuby::CLI.call(ARGV)
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThisIsRuby
4
+ # Reads and rewrites .gitattributes, touching only the block it owns.
5
+ #
6
+ # Everything outside the markers is preserved: Rails writes its own lines
7
+ # there, and so do people. The block is rewritten where it already sits, so
8
+ # what someone put below it stays below it.
9
+ class AttributesFile
10
+ BEGIN_MARKER = "# --- this_is_ruby: begin ---"
11
+ END_MARKER = "# --- this_is_ruby: end ---"
12
+ BLOCK = /^#{Regexp.escape(BEGIN_MARKER)}\n.*?^#{Regexp.escape(END_MARKER)}\n?/m
13
+
14
+ PREAMBLE = [
15
+ "# Managed by this_is_ruby. Re-run it when your build setup changes.",
16
+ "# Every path below is excluded from this repository's language stats.",
17
+ "# Edit above or below this block, never inside it."
18
+ ].freeze
19
+
20
+ attr_reader :path
21
+
22
+ def initialize(path)
23
+ @path = Pathname.new(path)
24
+ end
25
+
26
+ def current = path.file? ? path.read : ""
27
+
28
+ # Patterns already declared outside our block, as pattern => attributes.
29
+ #
30
+ # Attributes keep their `-` or `!` prefix: an owner who wrote
31
+ # `public/404.html -linguist-generated` has spoken about that path just as
32
+ # deliberately as one who set it, and Plan reads both as "leave it alone".
33
+ def existing_attributes
34
+ before, after = split
35
+ "#{before}#{after}".each_line.with_object({}) do |line, declared|
36
+ body = line.split("#", 2).first.to_s.strip
37
+ next if body.empty?
38
+
39
+ pattern, *attributes = body.split(/\s+/)
40
+ (declared[pattern] ||= []).concat(attributes)
41
+ end
42
+ end
43
+
44
+ def render(emissions)
45
+ before, after = split
46
+ join(before, emissions.empty? ? nil : block(emissions), after)
47
+ end
48
+
49
+ def stale?(emissions) = render(emissions) != current
50
+
51
+ # Writes the file when the rendering differs, and says whether it did.
52
+ def write(emissions)
53
+ contents = render(emissions)
54
+ return false if contents == current
55
+
56
+ path.write(contents)
57
+ true
58
+ end
59
+
60
+ private
61
+
62
+ # The file around our block. With no block, all of it is "before".
63
+ def split
64
+ match = BLOCK.match(current)
65
+ match ? [match.pre_match, match.post_match] : [current, ""]
66
+ end
67
+
68
+ def block(emissions)
69
+ body = "#{BEGIN_MARKER}\n"
70
+ PREAMBLE.each { |line| body << line << "\n" }
71
+ emissions.chunk_while { |a, b| a.rule == b.rule }.each do |group|
72
+ body << "\n# #{group.first.rule.summary}\n"
73
+ group.each { |emission| body << emission.line << "\n" }
74
+ end
75
+ body << END_MARKER << "\n"
76
+ end
77
+
78
+ def join(*sections)
79
+ sections.compact.map { |section| tidy(section) }.reject(&:empty?).join("\n")
80
+ end
81
+
82
+ # One trailing newline and no leading blank lines, so the sections join
83
+ # with exactly one blank line between them however the file arrived.
84
+ def tidy(text)
85
+ trimmed = text.sub(/\A\n+/, "").sub(/\n*\z/, "")
86
+ trimmed.empty? ? "" : "#{trimmed}\n"
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module ThisIsRuby
6
+ # Three verbs: write the file, show what would be written, or fail when it
7
+ # has drifted. `check` is the one CI cares about.
8
+ class CLI
9
+ COMMANDS = %w[apply plan check].freeze
10
+
11
+ USAGE = <<~TEXT
12
+ Usage: this_is_ruby [command] [options]
13
+
14
+ Commands:
15
+ apply write the managed block into .gitattributes (default)
16
+ plan show what apply would write, change nothing
17
+ check exit 1 when .gitattributes is out of date (for CI)
18
+
19
+ Options:
20
+ TEXT
21
+
22
+ def self.call(argv, out: $stdout, err: $stderr)
23
+ new(argv, out:, err:).call
24
+ end
25
+
26
+ def initialize(argv, out: $stdout, err: $stderr)
27
+ @argv = argv
28
+ @out = out
29
+ @err = err
30
+ @options = {path: ".", all_frontend: false, color: out.tty?}
31
+ end
32
+
33
+ def call
34
+ command = parse
35
+ # --help and --version leave nothing to run; bad input leaves nothing to
36
+ # run either, but must not look like success to a CI step.
37
+ return command if command.is_a?(Integer)
38
+
39
+ repo = Repo.at(@options[:path])
40
+ attributes = AttributesFile.new(repo.root.join(".gitattributes"))
41
+ plan = Plan.build(repo, attributes:, all_frontend: @options[:all_frontend])
42
+ report = Report.new(plan, out: @out, color: @options[:color])
43
+
44
+ send(command, plan, attributes, report)
45
+ rescue Repo::NotAGitRepo => error
46
+ @err.puts "this_is_ruby: #{error.message}"
47
+ 1
48
+ end
49
+
50
+ private
51
+
52
+ def apply(plan, attributes, report)
53
+ report.call
54
+ report.wrote(display(attributes.path)) if attributes.write(plan.emissions)
55
+ 0
56
+ end
57
+
58
+ def plan(plan, attributes, report)
59
+ report.call
60
+ 0
61
+ end
62
+
63
+ def check(plan, attributes, report)
64
+ return 0 unless attributes.stale?(plan.emissions)
65
+
66
+ report.call
67
+ report.drift(display(attributes.path))
68
+ 1
69
+ end
70
+
71
+ def display(path)
72
+ path.relative_path_from(Pathname.pwd).to_s
73
+ rescue ArgumentError
74
+ path.to_s
75
+ end
76
+
77
+ def parse
78
+ parser = OptionParser.new do |opts|
79
+ opts.banner = USAGE
80
+ opts.on("--path DIR", "repository to inspect (default: .)") { |dir| @options[:path] = dir }
81
+ opts.on("--all-frontend", "also mark hand-written frontend sources as vendored") { @options[:all_frontend] = true }
82
+ opts.on("--[no-]color", "colourise output") { |on| @options[:color] = on }
83
+ opts.on("-v", "--version", "print the version") do
84
+ @out.puts VERSION
85
+ return 0
86
+ end
87
+ opts.on("-h", "--help", "print this message") do
88
+ @out.puts opts
89
+ return 0
90
+ end
91
+ end
92
+ rest = parser.parse(@argv)
93
+ return refuse(parser, "one command at a time, got #{rest.join(" ")}") if rest.size > 1
94
+
95
+ command = rest.first || "apply"
96
+ return refuse(parser, "unknown command #{command.inspect}") unless COMMANDS.include?(command)
97
+
98
+ command
99
+ rescue OptionParser::ParseError => error
100
+ refuse(parser, error.message)
101
+ end
102
+
103
+ # Returns the exit status, so a typo in a CI step fails instead of passing.
104
+ def refuse(parser, message)
105
+ @err.puts "this_is_ruby: #{message}"
106
+ @err.puts parser
107
+ 1
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThisIsRuby
4
+ # One line destined for .gitattributes, plus the tracked paths that earned
5
+ # it. The paths are what the report shows and what the language estimate
6
+ # subtracts; the pattern is all that reaches the file.
7
+ Emission = Data.define(:pattern, :attribute, :paths, :rule) do
8
+ def line
9
+ "#{pattern} #{attribute}"
10
+ end
11
+
12
+ def bytes(repo)
13
+ paths.sum { |path| repo.size(path) }
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThisIsRuby
4
+ # A rough stand-in for GitHub's language bar.
5
+ #
6
+ # Linguist is far more careful than this: it sniffs content, resolves
7
+ # ambiguous extensions and applies heuristics we do not reimplement. The
8
+ # point here is only to show which way the bar moves, so the numbers are
9
+ # always presented as an estimate and GitHub stays the authority.
10
+ module LanguageEstimate
11
+ # Only types Linguist counts: programming and markup. Anything absent
12
+ # here (JSON, YAML, Markdown, lockfiles) is data or prose and scores
13
+ # nothing, which is why package-lock.json never mattered.
14
+ EXTENSIONS = {
15
+ ".rb" => "Ruby", ".rake" => "Ruby", ".gemspec" => "Ruby", ".ru" => "Ruby",
16
+ ".erb" => "HTML", ".html" => "HTML", ".htm" => "HTML",
17
+ ".haml" => "Haml", ".slim" => "Slim",
18
+ ".ts" => "TypeScript", ".tsx" => "TypeScript", ".mts" => "TypeScript",
19
+ ".js" => "JavaScript", ".jsx" => "JavaScript", ".mjs" => "JavaScript",
20
+ ".css" => "CSS", ".scss" => "SCSS", ".sass" => "SCSS", ".less" => "Less",
21
+ ".vue" => "Vue", ".svelte" => "Svelte",
22
+ ".py" => "Python", ".go" => "Go", ".rs" => "Rust", ".java" => "Java",
23
+ ".sh" => "Shell", ".bash" => "Shell", ".sql" => "SQL", ".ejs" => "EJS"
24
+ }.freeze
25
+
26
+ FILENAMES = {
27
+ "Gemfile" => "Ruby", "Rakefile" => "Ruby", "Guardfile" => "Ruby",
28
+ "Capfile" => "Ruby", "Dockerfile" => "Dockerfile"
29
+ }.freeze
30
+
31
+ # The parts of Linguist's own vendor list a Ruby project actually hits.
32
+ # Mirrored here so the estimate does not count bytes GitHub never counts.
33
+ VENDORED = Regexp.union(
34
+ %r{(\A|/)node_modules/},
35
+ %r{(\A|/)bower_components/},
36
+ %r{(\A|/)\.yarn/(releases|plugins|sdks|versions|unplugged)/},
37
+ %r{(\A|/)vendors?/},
38
+ /\.min\.(js|css)\z/
39
+ )
40
+
41
+ module_function
42
+
43
+ # Returns [[language, bytes], ...], largest first.
44
+ def of(repo, excluding: Set.new)
45
+ totals = Hash.new(0)
46
+ repo.tracked.each do |path|
47
+ next if excluding.include?(path) || VENDORED.match?(path)
48
+
49
+ language = language_for(path)
50
+ totals[language] += repo.size(path) if language
51
+ end
52
+ totals.sort_by { |language, bytes| [-bytes, language] }
53
+ end
54
+
55
+ def language_for(path)
56
+ name = File.basename(path)
57
+ return FILENAMES[name] if FILENAMES.key?(name)
58
+
59
+ # `index.html.erb` is ERB templating HTML; GitHub reports it as HTML.
60
+ EXTENSIONS[File.extname(name).downcase]
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThisIsRuby
4
+ # What we intend to write, and what we deliberately left alone.
5
+ class Plan
6
+ LINGUIST_ATTRIBUTES = %w[linguist-generated linguist-vendored linguist-documentation].freeze
7
+
8
+ attr_reader :repo, :emissions, :declared
9
+
10
+ def self.build(repo, attributes:, all_frontend: false)
11
+ spoken_for = spoken_for(attributes.existing_attributes)
12
+ found = Rules.for_level(all_frontend:).flat_map { |rule| rule.apply(repo) }
13
+ declared, emissions = found.partition do |emission|
14
+ spoken_for.any? { |pattern| covers?(pattern, emission.pattern) }
15
+ end
16
+ new(repo:, emissions: prune(emissions), declared:)
17
+ end
18
+
19
+ # Patterns whose Linguist attributes the owner has already decided, set or
20
+ # unset. `-linguist-generated` is a decision too, and writing our own line
21
+ # after it would quietly reverse it, since the last match wins.
22
+ def self.spoken_for(existing)
23
+ existing.filter_map do |pattern, attributes|
24
+ names = attributes.map { |attribute| attribute.delete_prefix("-").delete_prefix("!").split("=").first }
25
+ pattern if (names & LINGUIST_ATTRIBUTES).any?
26
+ end
27
+ end
28
+
29
+ # Does a declared pattern speak for the one we were about to write?
30
+ #
31
+ # Only the two shapes we emit are resolved: `dir/**` reaches everything
32
+ # below it, and anything else matches the way a single star does in
33
+ # gitignore syntax. A declared pattern covering only some of an emission's
34
+ # paths is not detected.
35
+ def self.covers?(declared, pattern)
36
+ return true if declared == pattern
37
+ return pattern.start_with?(declared.delete_suffix("**")) if declared.end_with?("**")
38
+
39
+ File.fnmatch?(declared, pattern, File::FNM_PATHNAME)
40
+ end
41
+
42
+ # Drops a pattern when a broader one already carries the same attribute:
43
+ # once `app/javascript/**` is vendored, saying so again about
44
+ # `app/javascript/components/ui/**` only makes the file longer.
45
+ def self.prune(emissions)
46
+ emissions.reject do |emission|
47
+ emissions.any? do |other|
48
+ next false unless other.pattern.end_with?("**")
49
+ next false if other.pattern == emission.pattern || other.attribute != emission.attribute
50
+
51
+ emission.pattern.start_with?(other.pattern.delete_suffix("**"))
52
+ end
53
+ end
54
+ end
55
+
56
+ def initialize(repo:, emissions:, declared:)
57
+ @repo = repo
58
+ @emissions = emissions.freeze
59
+ @declared = declared.freeze
60
+ end
61
+
62
+ def empty? = emissions.empty?
63
+
64
+ def claimed_paths
65
+ @claimed_paths ||= emissions.flat_map(&:paths).to_set
66
+ end
67
+
68
+ def before = @before ||= LanguageEstimate.of(repo)
69
+
70
+ def after = @after ||= LanguageEstimate.of(repo, excluding: claimed_paths)
71
+ end
72
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "pathname"
5
+
6
+ module ThisIsRuby
7
+ # The git working tree under inspection.
8
+ #
9
+ # Only tracked files are considered. Linguist reads the repository, so a
10
+ # path git ignores is already invisible to it and needs no attribute.
11
+ # That is why a project that gitignores `coverage/`, as most do, gets
12
+ # nothing written about it.
13
+ class Repo
14
+ class NotAGitRepo < Error; end
15
+
16
+ # Reading a whole blob to look for a marker comment is wasteful, and the
17
+ # markers we check for all sit in the first few lines.
18
+ PEEK_BYTES = 4096
19
+
20
+ attr_reader :root
21
+
22
+ def self.at(path)
23
+ # capture3, not capture2e: git writes warnings and GIT_TRACE output to
24
+ # stderr while still succeeding, and merging them corrupts the path.
25
+ out, _err, status = Open3.capture3("git", "-C", path.to_s, "rev-parse", "--show-toplevel")
26
+ raise NotAGitRepo, "not a git repository: #{path}" unless status.success?
27
+
28
+ new(out.strip)
29
+ end
30
+
31
+ def initialize(root)
32
+ @root = Pathname.new(root).expand_path
33
+ end
34
+
35
+ # Returns an Array of repo-relative paths, as git reports them.
36
+ def tracked
37
+ @tracked ||= begin
38
+ out, _err, status = Open3.capture3("git", "-C", root.to_s, "ls-files", "-z")
39
+ raise NotAGitRepo, "cannot list files in #{root}" unless status.success?
40
+
41
+ out.split("\0").reject(&:empty?).freeze
42
+ end
43
+ end
44
+
45
+ def tracked?(path)
46
+ tracked_set.include?(path)
47
+ end
48
+
49
+ # Returns the first PEEK_BYTES of a tracked file, or nil when it is
50
+ # missing from the working tree (a sparse checkout, say).
51
+ def peek(path)
52
+ full = root.join(path)
53
+ return unless full.file?
54
+
55
+ full.open("rb") { |io| io.read(PEEK_BYTES) }
56
+ end
57
+
58
+ def size(path)
59
+ full = root.join(path)
60
+ full.file? ? full.size : 0
61
+ end
62
+
63
+ private
64
+
65
+ def tracked_set
66
+ @tracked_set ||= tracked.to_set
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThisIsRuby
4
+ # Prints what the plan found. Everything here is presentation; decisions
5
+ # were made in Plan.
6
+ class Report
7
+ TOP = 5
8
+
9
+ def initialize(plan, out: $stdout, color: out.tty?)
10
+ @plan = plan
11
+ @out = out
12
+ @color = color
13
+ end
14
+
15
+ def call
16
+ if @plan.empty? && @plan.declared.empty?
17
+ say "Nothing to do. Linguist already counts this repository fairly."
18
+ return
19
+ end
20
+
21
+ findings
22
+ declared
23
+ language_bar
24
+ end
25
+
26
+ def wrote(path)
27
+ say "", "#{green("Wrote")} #{path}. Commit it: Linguist only reads what is in the repository."
28
+ end
29
+
30
+ def drift(path)
31
+ say "", "#{path} is out of date. Run #{bold("this_is_ruby")} and commit the result."
32
+ end
33
+
34
+ private
35
+
36
+ def findings
37
+ return if @plan.empty?
38
+
39
+ count = @plan.emissions.sum { |e| e.paths.size }
40
+ say "#{bold(count)} files will stop counting toward this repository's language bar:", ""
41
+ @plan.emissions.chunk_while { |a, b| a.rule == b.rule }.each do |group|
42
+ header = group.first.rule.summary
43
+ say " #{bold(header)} #{dim("#{human(group.sum { |e| e.bytes(@plan.repo) })}, #{group.sum { |e| e.paths.size }} files")}"
44
+ group.each { |emission| say " #{emission.line}" }
45
+ say ""
46
+ end
47
+ end
48
+
49
+ def declared
50
+ return if @plan.declared.empty?
51
+
52
+ say dim("Already decided in .gitattributes, left alone:")
53
+ @plan.declared.each { |emission| say dim(" #{emission.pattern}") }
54
+ say ""
55
+ end
56
+
57
+ def language_bar
58
+ before = @plan.before
59
+ after = @plan.after
60
+ return if before.empty?
61
+
62
+ say "Estimated language bar #{dim("(GitHub is the authority; this is a guide)")}", ""
63
+ rows = (before.first(TOP).map(&:first) | after.first(TOP).map(&:first))
64
+ width = rows.map(&:length).max
65
+ say " #{"".ljust(width)} #{"now".rjust(7)} #{"after".rjust(7)}"
66
+ rows.each do |language|
67
+ say " #{language.ljust(width)} #{share(before, language).rjust(7)} #{share(after, language).rjust(7)}"
68
+ end
69
+ say ""
70
+ winner = after.first&.first
71
+ say " Primary language: #{bold(before.first.first)} #{dim("->")} #{green(winner)}" if winner && winner != before.first.first
72
+ end
73
+
74
+ def share(totals, language)
75
+ total = totals.sum(&:last)
76
+ return "--" if total.zero?
77
+
78
+ bytes = totals.assoc(language)&.last.to_i
79
+ bytes.zero? ? "--" : format("%.1f%%", 100.0 * bytes / total)
80
+ end
81
+
82
+ def human(bytes)
83
+ units = ["B", "KB", "MB", "GB"]
84
+ unit = units.shift
85
+ value = bytes.to_f
86
+ while value >= 1024 && units.any?
87
+ value /= 1024
88
+ unit = units.shift
89
+ end
90
+ (unit == "B") ? "#{bytes} B" : format("%.1f %s", value, unit)
91
+ end
92
+
93
+ def say(*lines) = lines.each { |line| @out.puts(line) }
94
+
95
+ def bold(text) = @color ? "\e[1m#{text}\e[0m" : text
96
+
97
+ def dim(text) = @color ? "\e[2m#{text}\e[0m" : text
98
+
99
+ def green(text) = @color ? "\e[32m#{text}\e[0m" : text
100
+ end
101
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThisIsRuby
4
+ # One family of files that Linguist counts but nobody in the project wrote.
5
+ #
6
+ # A rule is handed the repo and returns a Hash of pattern => tracked paths.
7
+ # Rules never guess: when the evidence is not on disk they return nothing,
8
+ # so a project that has no coverage report gets no line about one.
9
+ #
10
+ # :safe rules find files a tool produced, and always run. :frontend rules
11
+ # find hand-written sources, and run only with --all-frontend, because
12
+ # excluding those is the owner's call about what their repository is rather
13
+ # than a fact about who typed the file.
14
+ class Rule
15
+ LEVELS = %i[safe frontend].freeze
16
+
17
+ attr_reader :key, :attribute, :summary, :level
18
+
19
+ def initialize(key:, attribute:, summary:, level: :safe, &finder)
20
+ raise ArgumentError, "unknown level #{level.inspect}" unless LEVELS.include?(level)
21
+ raise ArgumentError, "rule #{key} needs a finder" unless finder
22
+
23
+ @key = key
24
+ @attribute = attribute
25
+ @summary = summary
26
+ @level = level
27
+ @finder = finder
28
+ end
29
+
30
+ def safe? = level == :safe
31
+
32
+ # Returns an Array of Emission, empty when this repo has nothing to claim.
33
+ def apply(repo)
34
+ found = @finder.call(repo) || {}
35
+ found.filter_map do |pattern, paths|
36
+ next if paths.nil? || paths.empty?
37
+
38
+ Emission.new(pattern:, attribute:, paths: paths.sort.freeze, rule: self)
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThisIsRuby
4
+ # What this gem knows how to recognise.
5
+ #
6
+ # Nothing here duplicates Linguist's own defaults: `node_modules/`,
7
+ # `vendor/`, `.yarn/`, minified JavaScript and lockfiles are already
8
+ # excluded upstream, and repeating them would only make the file longer.
9
+ module Rules
10
+ # Written by `rails new` since Rails 3.0, each naming its own path in a
11
+ # comment. Rails 8.1 ships these lines itself; earlier versions, and any
12
+ # app generated with --skip-git, do not.
13
+ RAILS_ERROR_PAGES = %w[400 404 406-unsupported-browser 422 500].freeze
14
+
15
+ # SimpleCov's HTML formatter has opened its report with this title since
16
+ # 0.x. The directory is configurable, so we find it by the marker rather
17
+ # than by assuming `coverage/`.
18
+ SIMPLECOV_MARKER = "<title>Code coverage for"
19
+
20
+ # Where a Rails project keeps hand-written frontend sources, in the order
21
+ # the generators have used over the years.
22
+ FRONTEND_ROOTS = %w[app/javascript app/frontend app/webpack frontend].freeze
23
+
24
+ module_function
25
+
26
+ # Returns the tracked paths under `dir`, or an empty Array.
27
+ def under(repo, dir)
28
+ prefix = "#{dir}/"
29
+ repo.tracked.select { |path| path.start_with?(prefix) }
30
+ end
31
+
32
+ # A recursive pattern. `dir/*` would only reach the directory's immediate
33
+ # children. In gitignore syntax, which .gitattributes shares, a single
34
+ # star does not cross a slash.
35
+ def glob(dir) = "#{dir}/**"
36
+
37
+ def claim(repo, *dirs)
38
+ dirs.to_h { |dir| [glob(dir), under(repo, dir)] }
39
+ end
40
+
41
+ ALL = [
42
+ Rule.new(
43
+ key: :rails_error_pages,
44
+ attribute: "linguist-generated",
45
+ summary: "error pages written by `rails new`"
46
+ ) do |repo|
47
+ RAILS_ERROR_PAGES.filter_map { |page|
48
+ path = "public/#{page}.html"
49
+ next unless repo.tracked?(path)
50
+ # Only the generated page says where it lives. A hand-written 404
51
+ # is the author's own work and stays counted.
52
+ next unless repo.peek(path)&.include?("<!-- This file lives in #{path} -->")
53
+
54
+ [path, [path]]
55
+ }.to_h
56
+ end,
57
+
58
+ Rule.new(
59
+ key: :bundled_assets,
60
+ attribute: "linguist-generated",
61
+ summary: "bundler output (jsbundling, cssbundling, dartsass, tailwind)"
62
+ ) { |repo| claim(repo, "app/assets/builds") },
63
+
64
+ Rule.new(
65
+ key: :precompiled_assets,
66
+ attribute: "linguist-generated",
67
+ summary: "precompiled asset pipeline output"
68
+ ) { |repo| claim(repo, "public/assets") },
69
+
70
+ Rule.new(
71
+ key: :webpacker_packs,
72
+ attribute: "linguist-generated",
73
+ summary: "Webpacker/Shakapacker output"
74
+ ) { |repo| claim(repo, "public/packs", "public/packs-test") },
75
+
76
+ Rule.new(
77
+ key: :vite_build,
78
+ attribute: "linguist-generated",
79
+ summary: "vite_rails build output"
80
+ ) { |repo| claim(repo, "public/vite", "public/vite-dev", "public/vite-test") },
81
+
82
+ Rule.new(
83
+ key: :coverage_report,
84
+ attribute: "linguist-generated",
85
+ summary: "SimpleCov HTML report"
86
+ ) do |repo|
87
+ dirs = repo.tracked.filter_map { |path|
88
+ next unless path.end_with?("/index.html")
89
+ next unless repo.peek(path)&.include?(SIMPLECOV_MARKER)
90
+
91
+ File.dirname(path)
92
+ }.uniq
93
+ claim(repo, *dirs)
94
+ end,
95
+
96
+ Rule.new(
97
+ key: :sql_schema,
98
+ attribute: "linguist-generated",
99
+ summary: "SQL structure dump (Rails already marks db/schema.rb)"
100
+ ) do |repo|
101
+ path = "db/structure.sql"
102
+ repo.tracked?(path) ? {path => [path]} : {}
103
+ end,
104
+
105
+ Rule.new(
106
+ key: :shadcn_components,
107
+ attribute: "linguist-vendored",
108
+ summary: "shadcn/ui components, copied in by its generator"
109
+ ) do |repo|
110
+ # `components.json` is the generator's config; without it these are
111
+ # just ordinary components someone wrote.
112
+ next({}) unless repo.tracked?("components.json")
113
+
114
+ dirs = repo.tracked.filter_map { |path|
115
+ dir = File.dirname(path)
116
+ dir if dir.end_with?("components/ui") && path.match?(/\.(tsx|jsx|ts|js|vue|svelte)\z/)
117
+ }.uniq
118
+ claim(repo, *dirs)
119
+ end,
120
+
121
+ Rule.new(
122
+ key: :frontend_sources,
123
+ attribute: "linguist-vendored",
124
+ summary: "frontend sources, excluded at your request (--all-frontend)",
125
+ level: :frontend
126
+ ) do |repo|
127
+ present = FRONTEND_ROOTS.select { |dir| under(repo, dir).any? }
128
+ claim(repo, *present)
129
+ end
130
+ ].freeze
131
+
132
+ def for_level(all_frontend:)
133
+ all_frontend ? ALL : ALL.select(&:safe?)
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThisIsRuby
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "this_is_ruby/version"
4
+
5
+ # Tells GitHub Linguist which files in a Ruby project nobody actually wrote.
6
+ #
7
+ # GitHub picks a repository's language by counting bytes, and a Rails app
8
+ # carries a lot of bytes it never authored: generated error pages, compiled
9
+ # assets, coverage reports, components copied in by a frontend generator.
10
+ # Counted together they routinely outweigh the app's own Ruby, and the repo
11
+ # is labelled HTML or TypeScript.
12
+ #
13
+ # Linguist already has the fix, `.gitattributes` overrides, but nobody
14
+ # maintains that file by hand. This gem writes it from what is actually on
15
+ # disk.
16
+ module ThisIsRuby
17
+ class Error < StandardError; end
18
+ end
19
+
20
+ require_relative "this_is_ruby/repo"
21
+ require_relative "this_is_ruby/emission"
22
+ require_relative "this_is_ruby/rule"
23
+ require_relative "this_is_ruby/rules"
24
+ require_relative "this_is_ruby/plan"
25
+ require_relative "this_is_ruby/attributes_file"
26
+ require_relative "this_is_ruby/language_estimate"
27
+ require_relative "this_is_ruby/report"
28
+ require_relative "this_is_ruby/cli"
metadata ADDED
@@ -0,0 +1,65 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: this_is_ruby
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Irina Nazarova
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: |
13
+ GitHub picks a repository's language by counting bytes, and a Rails app
14
+ carries plenty it never authored: generated error pages, compiled assets,
15
+ coverage reports, components copied in by a frontend generator. Together
16
+ they routinely outweigh the app's own Ruby. this_is_ruby finds them and
17
+ writes the .gitattributes overrides Linguist already understands.
18
+ email:
19
+ - inazarova@evilmartians.com
20
+ executables:
21
+ - this_is_ruby
22
+ extensions: []
23
+ extra_rdoc_files: []
24
+ files:
25
+ - CHANGELOG.md
26
+ - LICENSE.txt
27
+ - README.md
28
+ - exe/this_is_ruby
29
+ - lib/this_is_ruby.rb
30
+ - lib/this_is_ruby/attributes_file.rb
31
+ - lib/this_is_ruby/cli.rb
32
+ - lib/this_is_ruby/emission.rb
33
+ - lib/this_is_ruby/language_estimate.rb
34
+ - lib/this_is_ruby/plan.rb
35
+ - lib/this_is_ruby/repo.rb
36
+ - lib/this_is_ruby/report.rb
37
+ - lib/this_is_ruby/rule.rb
38
+ - lib/this_is_ruby/rules.rb
39
+ - lib/this_is_ruby/version.rb
40
+ homepage: https://github.com/evilmartians/this_is_ruby
41
+ licenses:
42
+ - MIT
43
+ metadata:
44
+ source_code_uri: https://github.com/evilmartians/this_is_ruby
45
+ changelog_uri: https://github.com/evilmartians/this_is_ruby/blob/main/CHANGELOG.md
46
+ bug_tracker_uri: https://github.com/evilmartians/this_is_ruby/issues
47
+ rubygems_mfa_required: 'true'
48
+ rdoc_options: []
49
+ require_paths:
50
+ - lib
51
+ required_ruby_version: !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - ">="
54
+ - !ruby/object:Gem::Version
55
+ version: 3.2.0
56
+ required_rubygems_version: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ requirements: []
62
+ rubygems_version: 3.6.7
63
+ specification_version: 4
64
+ summary: Tell GitHub which files in your Ruby project nobody wrote
65
+ test_files: []