veragem 0.1.0 → 0.2.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.
Files changed (59) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +230 -34
  3. data/Gemfile.lock +254 -0
  4. data/README.md +143 -48
  5. data/lib/veragem/citation.rb +116 -0
  6. data/lib/veragem/generator.rb +105 -56
  7. data/lib/veragem/license.rb +27 -0
  8. data/lib/veragem/name.rb +110 -48
  9. data/lib/veragem/options.rb +116 -30
  10. data/lib/veragem/project.rb +200 -39
  11. data/lib/veragem/questions.rb +163 -0
  12. data/lib/veragem/summary.rb +62 -0
  13. data/lib/veragem/version.rb +2 -1
  14. data/lib/veragem.rb +12 -3
  15. data/plugins.rb +4 -0
  16. data/sig/veragem.rbs +151 -23
  17. data/template/.gitattributes +3 -0
  18. data/template/.githooks/pre-commit +2 -3
  19. data/template/.github/PULL_REQUEST_TEMPLATE.md +2 -1
  20. data/template/.github/dependabot.yml +4 -8
  21. data/template/.github/image-registries.sh +4 -3
  22. data/template/.github/workflows/dependabot-automerge.yml +29 -34
  23. data/template/.github/workflows/dependency-review.yml +19 -15
  24. data/template/.github/workflows/image.yml +65 -14
  25. data/template/.github/workflows/links.yml +35 -0
  26. data/template/.github/workflows/nix.yml +3 -3
  27. data/template/.github/workflows/release.yml +135 -22
  28. data/template/.github/workflows/ruby-ci.yml +31 -9
  29. data/template/.github/workflows/security.yml +15 -9
  30. data/template/.github/workflows/style-check-cocogitto.yml +3 -4
  31. data/template/.github/workflows/style-check-dprint.yml +10 -10
  32. data/template/.gitignore +5 -0
  33. data/template/.mutineer.yml +28 -0
  34. data/template/.rubocop.yml +49 -10
  35. data/template/.simplecov.erb +10 -0
  36. data/template/.yard-lint.yml +386 -0
  37. data/template/.yardopts +2 -0
  38. data/template/AGENTS.md.erb +76 -38
  39. data/template/CITATION.cff.erb +50 -0
  40. data/template/CODE_OF_CONDUCT.md.erb +1 -1
  41. data/template/CONTRIBUTING.md.erb +107 -48
  42. data/template/Gemfile +10 -7
  43. data/template/README.md.erb +12 -12
  44. data/template/Rakefile +167 -31
  45. data/template/Steepfile.erb +1 -1
  46. data/template/bin/setup +2 -0
  47. data/template/cliff.toml +40 -23
  48. data/template/dprint.json +1 -6
  49. data/template/flake.lock +3 -3
  50. data/template/flake.nix +25 -10
  51. data/template/lib/%path%/cli.rb.erb +19 -15
  52. data/template/lib/%path%/version.rb.erb +1 -1
  53. data/template/lib/%path%.rb.erb +3 -1
  54. data/template/sig/%path%.rbs.erb +1 -1
  55. data/template/test/test_%underscored%.rb.erb +4 -3
  56. data/template/test/test_cli.rb.erb +37 -12
  57. data/template/test/test_helper.rb.erb +15 -7
  58. metadata +16 -4
  59. data/template/.github/workflows/rubocop-autocorrect.yml +0 -114
data/README.md CHANGED
@@ -1,28 +1,42 @@
1
1
  # veragem
2
2
 
3
- A Bundler plugin that generates a Ruby gem with its toolchain wired up: the Ruby counterpart of
4
- the [templates](https://github.com/veralvx/templates) repository's Python and Rust templates.
3
+ A Bundler plugin that generates a Ruby 4.0+ gem with its toolchain wired up: Steep types,
4
+ RuboCop, Minitest at 100% coverage with mutation testing, YARD, CI on Linux, macOS and Windows, a
5
+ Nix flake and releases by Trusted Publishing. It is the Ruby counterpart of the
6
+ [templates](https://github.com/veralvx/templates) repository's Python and Rust templates.
5
7
  `bundle gem` gives a gemspec, a Rakefile, Minitest and RuboCop; `bundle veragem NAME` gives that
6
8
  and the rest:
7
9
 
8
- - **Ruby 4.0** or later, `required_ruby_version` and CI alike.
9
- - **Types**: Steep checks `lib/` against the signatures in `sig/`, its strictest diagnostics on;
10
- TypeProf infers signatures to start them from, and its editor support is configured.
10
+ - **Ruby 4.0** or later, `required_ruby_version` and CI alike, on Linux, macOS and Windows:
11
+ Gemfile.lock holds Windows' platform (`bin/setup`), `.gitattributes` keeps every text file LF,
12
+ and the executables' mode is in git's index, which a commit keeps where the file system has none.
13
+ - **Types**: Steep checks `lib/` against the signatures in `sig/`, its `strict` diagnostics on, and
14
+ that each call is typed (`rake steep:typed`: none on an untyped receiver); TypeProf infers
15
+ signatures to start them from, and its editor support is configured.
11
16
  - **Lint and format**: RuboCop with every new cop, rubocop-minitest, rubocop-packaging,
12
17
  rubocop-performance and rubocop-rake; the project is indexed first (rubydex), so the cops that see
13
18
  across files do. In CI, offences are annotated on the pull request and a failed run's summary
14
- holds the safe corrections as a patch; a Dependabot bump the new cops fail gets them as a commit,
15
- then merges as any bump once you approve its runs (`rubocop-autocorrect.yml`, no token to set).
19
+ holds the safe corrections as a patch, to `git apply`: a Dependabot bump the new cops fail stays
20
+ unmerged until you apply it, no workflow pushing code the bumped gems wrote.
21
+ - **Documentation**: YARD, every public object of `lib/` documented, held by yard-lint at 100%
22
+ coverage (`rake yard` writes it to `doc/`).
16
23
  - **Tests**: Minitest, a property test with [pbt](https://github.com/ohbarye/pbt), and SimpleCov's
17
- `strict` profile: every line, branch and method of `lib/` covered.
18
- - **Security**: bundler-audit over `Gemfile.lock`, daily in CI; a pull request's new gems and
19
- actions reviewed for their licences and advisories (`dependency-review.yml`, advisory, as in the
20
- Python and Rust templates); zizmor over the workflows and Dependabot's configuration; Dependabot
21
- for the gems, the actions and the flake, a release proposed once a week old.
24
+ `strict` profile: every line, branch and method of `lib/` covered; a warning from `lib/` or the
25
+ tests fails them. [mutineer](https://github.com/davidteren/mutineer) mutates `lib/`: one mutant
26
+ no test catches, or an ignore that names none, fails it. `rake checks` mutates the lines changed
27
+ since `origin/main`, CI every line (`rake mutation`).
28
+ - **Links**: lychee checks the links of the files git tracks but the tests' (`rake links`), in
29
+ `rake checks` and monthly (`links.yml`).
30
+ - **Security**: bundler-audit over `Gemfile.lock`, daily and on every pull request; a pull request's
31
+ new gems reviewed for their licences and advisories, its new actions for their licences
32
+ (`dependency-review.yml`, not a required check, as in the Python and Rust templates); zizmor over
33
+ the workflows and Dependabot's configuration; Dependabot for the gems, the actions and the flake,
34
+ each update proposed once its release is a week old.
22
35
  - **Commits and releases**: Conventional Commits (cocogitto, in CI and opt-in git hooks), a
23
36
  changelog written by git-cliff at each release, `rake "bump[minor]"` then Bundler's own
24
- `rake release`; the tag publishes to RubyGems by Trusted Publishing, no API key stored, and makes
25
- the GitHub release.
37
+ `rake release`; the tag publishes to RubyGems by Trusted Publishing, no API key stored, the gem
38
+ built apart from the tools' gems and signed (its Sigstore attestation, RubyGems' provenance), and
39
+ makes the GitHub release.
26
40
  - **Nix**: a flake with the development shell (Ruby and every tool the checks run), the gem as a
27
41
  package (`nix build`), `nix fmt`, and checks for the Nix, the shell scripts, the workflows and
28
42
  the package. With `--exe`, `nix run` runs the command, and its OCI image (`nix build .#image`)
@@ -31,11 +45,11 @@ and the rest:
31
45
  `AGENTS.md`, `SECURITY.md`, `CODE_OF_CONDUCT.md`), issue and pull request templates.
32
46
 
33
47
  In the generated gem, `bundle exec rake` runs the Ruby checks, as CI does;
34
- `bundle exec rake checks` runs everything CI runs.
48
+ `bundle exec rake checks` runs CI's checks, the links too, but mutates the changed lines alone.
35
49
 
36
50
  ## Install
37
51
 
38
- Ruby 4.0 or later, then:
52
+ Ruby 4.0 or later and git, then:
39
53
 
40
54
  ```sh
41
55
  bundle plugin install veragem
@@ -43,69 +57,146 @@ bundle plugin install veragem
43
57
 
44
58
  Run outside a project, it installs for your user, and `bundle veragem` then works anywhere. From
45
59
  the repository instead: `bundle plugin install veragem --git https://github.com/veralvx/veragem`
46
- (or `--path` a local checkout).
60
+ (or `--path` a local checkout). This README follows `main`, which can be ahead of the release:
61
+ [CHANGELOG.md](CHANGELOG.md) lists what each release holds, and `--git` installs `main`.
47
62
 
48
63
  ## Usage
49
64
 
50
65
  ```sh
51
- bundle veragem my_gem # and --author, --github where git config has no user.name, github.user
66
+ bundle veragem my_gem # with a terminal for input, it asks for what no option gives
52
67
  cd my_gem
53
68
  bin/setup # the gems, and Gemfile.lock written
54
69
  git add .
55
70
  git commit -m "chore: initial commit from veragem"
56
- bundle exec rake checks # its tools: the generated CONTRIBUTING.md's, or `nix develop`'s
71
+ bundle exec rake checks # the tools the generated CONTRIBUTING.md lists, or `nix develop`'s
57
72
  ```
58
73
 
59
- | Option | Default | Goes into |
60
- | ------------------- | ----------------------------------- | ----------------------------------------------- |
61
- | `NAME` | -- | the gem, its module, its files: as `bundle gem` |
62
- | `--exe` | a library alone | a command line: `exe/NAME`, `lib/NAME/cli.rb` |
63
- | `--license=LICENSE` | `dual` (MIT OR Apache-2.0) | `mit`, `apache` or `dual`: the gemspec, LICENSE |
64
- | `--author=NAME` | `git config user.name` (required) | the gemspec, the licence's copyright line |
65
- | `--email=EMAIL` | `git config user.email` (optional) | the gemspec, the code of conduct |
66
- | `--github=USER` | `git config github.user` (required) | the homepage, the repository's links |
67
- | `--summary=TEXT` | `A Ruby gem.` | the gemspec, the README, the module's comment |
74
+ As `bundle gem` does, veragem makes the git repository and adds the files, and runs no
75
+ `bundle install`: `bin/setup` writes the lock, to commit. The generated `CONTRIBUTING.md` lists
76
+ the first steps: the repository's settings, RubyGems' pending trusted publisher.
77
+
78
+ ### Options
79
+
80
+ | Option | Default | Goes into |
81
+ | -------------------------- | ------------------------------------------- | ------------------------------------------------------ |
82
+ | `NAME` | -- | the gem, its module, its files: as `bundle gem` |
83
+ | `--[no-]exe` | `--no-exe`: a library alone | a command line: `exe/NAME`, `lib/PATH/cli.rb` |
84
+ | `--license=LICENSE` | `dual` (MIT OR Apache-2.0) | `mit`, `apache` or `dual`: the gemspec, LICENSE |
85
+ | `--author=NAME` | `git config user.name` (required) | the gemspec, the licence's copyright line |
86
+ | `--[no-]email=EMAIL` | `git config user.email`; `--no-email`: none | the gemspec, the code of conduct |
87
+ | `--github=USER` | `git config github.user` (required) | the homepage, the repository's links |
88
+ | `--summary=TEXT` | `A Ruby gem.` | the gemspec, the README, the module's comment |
89
+ | `--[no-]citation` | `--no-citation` | `CITATION.cff`: GitHub's "Cite this repository" |
90
+ | `--family-names=NAMES` | (required with `--citation`) | with `--citation`: the author's, `CITATION.cff` |
91
+ | `--[no-]given-names=NAMES` | optional; `--no-given-names`: none | with `--citation`: the author's, `CITATION.cff` |
92
+ | `--[no-]alias=ALIAS` | optional; `--no-alias`: none | with `--citation`: the author's handle, `CITATION.cff` |
93
+ | `--orcid=ID` | optional | with `--citation`: the author's ORCID iD |
94
+ | `--keywords=LIST` | optional | with `--citation`: keywords, comma-separated |
95
+
96
+ A licence, an option's or an answer's, is taken in any case, abbreviated (`a`) or as its SPDX
97
+ expression (`Apache-2.0`, `MIT OR Apache-2.0` in either order). Without a terminal, when veragem
98
+ refuses a value from git's config, its error names the key and the option or options that replace
99
+ it.
100
+
101
+ ### Questions
102
+
103
+ With a terminal for input, `bundle veragem` asks for each value no option gives, the name too, once
104
+ the options are checked. A question offers its default unless veragem refuses it, saying why and
105
+ where it is from (`git config user.name`); Enter takes it, an optional one takes `-` for none, and a
106
+ refused answer, or none where there is no default, is asked again, saying why. `-` is no option's
107
+ value, as no answer can give it: `--no-email`, `--no-given-names` and `--no-alias` give none, and an
108
+ `--orcid` or `--keywords` left out does. With other input (CI, a pipe, `< /dev/null` to skip the
109
+ questions) nothing is asked: the defaults apply, and a missing name, author, GitHub user or, with
110
+ `--citation`, family names is refused.
111
+
112
+ ### CITATION.cff
113
+
114
+ `CITATION.cff` (Citation File Format 1.2.0) is for research software meant to be cited: GitHub
115
+ offers its "Cite this repository", and Zenodo, once the repository is enabled there (the generated
116
+ CONTRIBUTING.md's first steps), archives each release with its values and gives it a DOI. Its
117
+ author is a person, family names required, given names, alias, ORCID iD and keywords optional.
118
+ Asked at a terminal, the family names offer the author's last word, the given names the words before
119
+ the family names, where the author ends with them, and the alias, asked last, the GitHub user. That
120
+ user may be an organization, so it is only offered: without a terminal, there is no alias unless
121
+ `--alias` gives one. The citation's options need `--citation`: given without it, they are refused,
122
+ and at a terminal they make its question take y alone.
123
+
124
+ It holds what GitHub and Zenodo read, written as Zenodo's import takes it (narrower than CFF), but
125
+ the licences as CFF writes them:
126
+
127
+ - one licence as its SPDX identifier, and `dual`'s two as a list, CFF's OR: Zenodo's import refuses
128
+ a list, so it archives no release of a dual-licensed gem until
129
+ [zenodo/zenodo-rdm#1509](https://github.com/zenodo/zenodo-rdm/issues/1509) is fixed (first
130
+ reported as [zenodo/zenodo#2515](https://github.com/zenodo/zenodo/issues/2515));
131
+ - an ORCID iD Zenodo's check takes, its check digit and its block ORCID's, written as its URL;
132
+ - names without a comma: Zenodo reads one as the end of the family names (`Smith, Jr.`); an alias
133
+ may hold one, as Zenodo reads no alias;
134
+ - a name of 3 characters or more, Zenodo's least title;
135
+ - the repository, RubyGems' page as its artifact, and its API documentation on rubydoc.info as
136
+ its URL (there from the first release); no email (CFF's pattern is stricter than veragem's);
137
+ - its DOIs commented out, the concept DOI and a version's, to uncomment once Zenodo gives them
138
+ after a release.
139
+
140
+ `rake bump` sets its version and date at each release, and `nix flake check` validates it against
141
+ CFF's schema (cffconvert): a check of the schema alone, which passes files Zenodo's import refuses.
142
+
143
+ ### The name
68
144
 
69
145
  The name follows `bundle gem`'s rules, lower-case: `my_gem` is `MyGem` in `lib/my_gem.rb`, `my-gem`
70
146
  is `My::Gem` in `lib/my/gem.rb`. Refused before anything is written, a name:
71
147
 
72
- - whose module is already defined, by Ruby, Bundler, Rake or Minitest, or by its top-level library
73
- where this Ruby has one (`set`, `option_parser`, `date-holidays`: Date is a class);
74
- - whose file would shadow a library this Ruby has (`json`: lib/json.rb);
148
+ - whose module is already defined, by Ruby, by what its tools load with its code (Bundler, the
149
+ Rakefile and its tasks, the console, the tests: `c_s_v`, `j_s_o_n`, `weak_ref`), where installed,
150
+ or by its top-level library where this Ruby has one (`set`, `option_parser`, `date-holidays`: Date
151
+ is a class);
152
+ - whose file would shadow a library this Ruby has, in any case, as macOS and Windows ignore it
153
+ (`ripper`: lib/ripper.rb; `english`: English.rb);
75
154
  - whose module its tests or `gem build` would take for Minitest's or RubyGems' own
76
155
  (`lifecycle_hooks`, `date_like`);
77
156
  - that `bundle gem` would refuse or mangle (`1gem`, `my--gem`);
78
- - one of the Gemfile's own tools (`rubocop-rake`);
157
+ - a gem the Gemfile's tools load, theirs included (`rubocop-rake`, `parser`): the tools' bundle
158
+ would resolve it to the new gem;
79
159
  - `test_helper`, whose lib/test_helper.rb would shadow the tests', or one that would write a
80
160
  template file twice (`helper`: test/test_helper.rb);
161
+ - one of Windows' device names, a word between its dashes (`con`, `my-nul`), no file's there;
81
162
  - longer than 40 characters or deeper than 4 modules (the generated code's lines fit RuboCop's);
82
163
  - with `--exe`, whose module is CLI or VERSION (`my-c_l_i`: its command line would find another
83
164
  first).
84
165
 
85
- The summary is plain text: written into the README as given, Markdown in it is dprint's to check.
86
- As `bundle gem` does, veragem makes the git repository and adds the files, and runs no
87
- `bundle install`: `bin/setup` writes the lock, to commit. The generated `CONTRIBUTING.md` lists
88
- the first steps: the repository's settings, RubyGems' pending trusted publisher.
166
+ ### The summary
167
+
168
+ The summary is plain text, as RubyGems shows it: the README shows it as written, its Markdown
169
+ characters escaped (`_`, `` ` ``, `<`...), a URL's `://` and `www.` too, which GitHub would link
170
+ with its escapes shown, and its spaces single, as dprint writes them; GitHub's emoji (`:gem:`)
171
+ alone it cannot keep. It is the module's comment too, so one the gem's yard-lint or RuboCop would
172
+ not take as its documentation is refused: a tag first, YARD's (`@param`, `(see`) or written
173
+ informally (`Note:`, `See also:`), a RuboCop annotation (`HACK:`, `review the code`), a magic
174
+ comment (`encoding:`, `warn_indent:`), Markdown yard-lint refuses (an em or en dash outside code,
175
+ a `•` list marker or a code fence first), a backtick or `**` left open.
89
176
 
90
177
  ## What you get
91
178
 
92
179
  ```
93
- NAME.gemspec the gem: its version from lib/NAME/version.rb, its files lib/, sig/, exe/
180
+ NAME.gemspec the gem: its version from lib/PATH/version.rb (`my-gem`'s lib/my/gem/), its
181
+ files lib/, sig/, exe/, the README, the changelog and the licences
94
182
  Gemfile the gemspec, then the tools; Gemfile.lock pins them (written by bin/setup)
95
- Rakefile `rake`: RuboCop, Steep, the tests; `rake checks`: what CI runs;
183
+ Rakefile `rake`: RuboCop, Steep, the tests, yard-lint; `rake checks`: what CI runs;
96
184
  `rake "bump[level]"`, `rake release`; `rake -T` lists them all
97
185
  Steepfile, sig/ the types; typeprof.conf.jsonc, TypeProf's editor support
98
- .rubocop.yml RuboCop and its extensions
99
- lib/, test/ starter code: typed, documented, every line and branch tested
186
+ .rubocop.yml RuboCop and its extensions; .yard-lint.yml, the documentation's lint
187
+ .simplecov, .mutineer.yml the coverage gate; the mutation test's operators and its ignores
188
+ lib/, test/, exe/ starter code (exe/NAME with --exe): typed, documented, every line and
189
+ branch tested
100
190
  bin/setup, bin/console
101
191
  flake.nix, flake.lock the dev shell, the package (and the command's image), `nix fmt`, the checks
102
192
  .githooks/ opt-in (`rake hooks`): the quick checks before a commit, Conventional Commits
103
193
  cliff.toml, cog.toml git-cliff's changelog, cocogitto's commit checks
104
194
  dprint.json, .editorconfig
105
195
  .github/ CI, the flake, bundler-audit, the dependency review, the release (and the
106
- image), the style checks, Dependabot, its auto-merge and its bumps'
107
- RuboCop corrections; issue and pull request templates
196
+ image), the style checks, the links, Dependabot and its auto-merge; issue
197
+ and pull request templates
108
198
  README, CONTRIBUTING, AGENTS, SECURITY, CODE_OF_CONDUCT, LICENSE-MIT / LICENSE-APACHE
199
+ CITATION.cff with --citation: the citation, its version and date `rake bump`'s
109
200
  ```
110
201
 
111
202
  The Gemfile leaves the development tools unconstrained: the lock pins them at the versions current
@@ -120,11 +211,15 @@ packages few of them, and older).
120
211
  property), the options, every file of each kind and licence rendered, parsed and kept executable,
121
212
  the gemspec read back, the values written as given whatever the locale.
122
213
  - The harness here is the template's: this repository's Gemfile, Rakefile, workflows, hooks, flake
123
- and tools' configuration are the template's own files, byte for byte (`test/test_sync.rb`), so
124
- the template's are checked in place by this repository's checks, and a command line's own
125
- (`image.yml`) by the smoke test's command gem.
214
+ and tools' configuration are the template's own plain files, byte for byte (`test/test_sync.rb`;
215
+ but `dependabot.yml`, which moves both, `.mutineer.yml`, whose ignored mutants are the plugin's,
216
+ and the smoke test's workflow, the plugin's alone), so the template's are checked in place by
217
+ this repository's checks, and a command line's own (`image.yml`) by the smoke test's command
218
+ gem.
126
219
  - The smoke test (`dev/smoke.sh`, `smoke.yml`, weekly too): the plugin installed from this checkout
127
- generates a gem of each kind and licence, and each passes its own `rake checks` and builds.
220
+ generates a gem of each kind and licence, and each, its git hooks on, passes its own
221
+ `rake checks`, but the links (`links.yml`'s), and builds; the citation's gem is released three
222
+ times (`rake bump`, then `rake release` to a local remote): its first release, a patch, a minor.
128
223
 
129
224
  ## Contributing
130
225
 
@@ -137,8 +232,8 @@ packages few of them, and older).
137
232
  Licensed under either of
138
233
 
139
234
  - Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or
140
- <http://www.apache.org/licenses/LICENSE-2.0>)
141
- - MIT license ([LICENSE-MIT](LICENSE-MIT) or <http://opensource.org/licenses/MIT>)
235
+ <https://www.apache.org/licenses/LICENSE-2.0>)
236
+ - MIT license ([LICENSE-MIT](LICENSE-MIT) or <https://opensource.org/license/MIT>)
142
237
 
143
238
  at your option.
144
239
 
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Veragem
4
+ # CITATION.cff's rules beyond its values' text, as Zenodo's import takes them: who may give them,
5
+ # the gem's name, the author's names (no comma), alias (which Zenodo reads not) and ORCID iD, the
6
+ # keywords; and what its questions offer.
7
+ module Citation
8
+ # The values CITATION.cff alone writes.
9
+ KEYS = %i[given_names family_names alias orcid keywords].freeze
10
+ # An ORCID iD, bare or as its URL, spaces around it (Unicode's too): its 16th character, the
11
+ # check digit. Matched ASCII-downcased: /i would widen its letters to Unicode's (ſ).
12
+ ORCID = %r{\A[[:space:]]*(?:https?://orcid\.org/)?(\d{4}-\d{4}-\d{4}-\d{3}[\dx])[[:space:]]*\z}
13
+ # The ORCID iDs ORCID issues, without their check digit: those Zenodo's import takes (idutils).
14
+ ORCID_BLOCKS = [15_000_000..35_000_000, 900_000_000_000..900_100_000_000].freeze
15
+ private_constant :ORCID, :ORCID_BLOCKS
16
+
17
+ # Whether a citation's values are given: --no-given-names' false is none.
18
+ # @param values [Hash{Symbol => String, Boolean, nil}]
19
+ # @return [Boolean]
20
+ def self.cited?(values) = values.values_at(*KEYS).any?
21
+
22
+ # A citation's values, given only with a citation.
23
+ # @param values [Hash{Symbol => String, Boolean, nil}] a citation's among them
24
+ # @param citation [Boolean] whether the gem has CITATION.cff
25
+ # @return [void]
26
+ # @raise [Veragem::Error] one is given without a citation
27
+ def self.check_values(values, citation)
28
+ return if citation || !cited?(values)
29
+
30
+ raise Error, "--given-names, --family-names, --alias, --orcid and --keywords are CITATION.cff's: " \
31
+ "they need a citation"
32
+ end
33
+
34
+ # The value a citation's +key+ takes, as Project.check's: an ORCID iD its URL, keywords joined by
35
+ # ", ".
36
+ # @param key [Symbol] one of KEYS
37
+ # @param value [String] valid UTF-8, as Project.check passes it
38
+ # @yieldparam field [String] a value's name, as a refusal names it
39
+ # @yieldparam text [String] the value, or a keyword
40
+ # @yieldreturn [String] it checked, one line
41
+ # @return [String]
42
+ # @raise [Veragem::Error] +key+ cannot take it
43
+ def self.check(key, value)
44
+ field = key.name.tr("_", " ")
45
+ case key
46
+ when :orcid then orcid(value)
47
+ when :keywords then keywords(value) { yield("keyword", it) }.join(", ")
48
+ # Zenodo's import reads no alias: a comma divides nothing in it
49
+ when :alias then yield(field, value)
50
+ else names(field, yield(field, value))
51
+ end
52
+ end
53
+
54
+ # The name, CITATION.cff's title: Zenodo's import refuses one under 3 characters.
55
+ # @param name [String] the gem's
56
+ # @param citation [Boolean] whether the gem has CITATION.cff
57
+ # @return [void]
58
+ # @raise [Veragem::Error] the citation cannot have the name
59
+ def self.check_name(name, citation)
60
+ return unless citation && name.length < 3
61
+
62
+ raise Error, "invalid gem name #{name} for a citation: Zenodo takes a title of 3 characters or more"
63
+ end
64
+
65
+ # What a citation's question offers. The citation, y where its values are given, which need one.
66
+ # The names, guessed from the author's words, Unicode's spaces between them: the last the family
67
+ # names, those before the family names the given names -- none where the author does not end
68
+ # with them. The alias, the GitHub user: only offered, as it may name an organization.
69
+ # @param key [Symbol] the question's
70
+ # @param values [Hash{Symbol => String, Boolean, nil}] the answers so far, checked
71
+ # @return [String, nil] nil for none, and for any other question
72
+ def self.default(key, values)
73
+ author = values[:author].to_s
74
+ case key
75
+ when :citation then cited?(values) ? "y" : "n"
76
+ when :family_names then author.split(/[[:space:]]+/).last
77
+ when :given_names then author[/\A(.+?)[[:space:]]+#{Regexp.escape(values[:family_names].to_s)}\z/, 1]
78
+ when :alias then values[:github].to_s
79
+ end
80
+ end
81
+
82
+ # An author's names, as one line: no comma, which Zenodo reads as the family names' end
83
+ # ("family, given"), so "Smith, Jr." would lose its given names.
84
+ private_class_method def self.names(field, names)
85
+ return names unless names.include?(",")
86
+
87
+ raise Error, "invalid #{field} #{names.inspect}: no comma, Zenodo's divide of family and given names"
88
+ end
89
+
90
+ # Keywords, comma-separated, as CFF's list takes them: blanks and repeats dropped, each checked
91
+ # by the block.
92
+ private_class_method def self.keywords(value, &)
93
+ words = value.split(",").grep_v(/\A[[:space:]]*\z/).map(&).uniq
94
+ return words unless words.empty?
95
+
96
+ raise Error, "invalid keywords #{value.inspect}: one or more, comma-separated"
97
+ end
98
+
99
+ # An ORCID iD as CFF writes it, its URL, checked as Zenodo's import checks it (idutils' is_orcid):
100
+ # its check digit ISO 7064 MOD 11-2's, its number in a block ORCID issues. Zenodo refuses the
101
+ # whole citation for a bad one.
102
+ private_class_method def self.orcid(value)
103
+ id = value.downcase(:ascii)[ORCID, 1]&.upcase
104
+ return "https://orcid.org/#{id}" if id && valid?(id.delete("-"))
105
+
106
+ raise Error, "invalid ORCID iD #{value.inspect}: 0000-0002-1825-0097, or its https://orcid.org/ URL"
107
+ end
108
+
109
+ private_class_method def self.valid?(digits)
110
+ *number, last = digits.chars
111
+ sum = number.reduce(0) { |total, digit| (total + digit.to_i) * 2 }
112
+ check = (12 - (sum % 11)) % 11
113
+ last == (check == 10 ? "X" : check.to_s) && ORCID_BLOCKS.any? { it.cover?(number.join.to_i) }
114
+ end
115
+ end
116
+ end
@@ -4,14 +4,19 @@ require "erb"
4
4
  require "fileutils"
5
5
 
6
6
  module Veragem
7
- # Writes a Project's gem from template/: each `.erb` file rendered, every other copied, each with
8
- # its mode; then a git repository, its files added, as `bundle gem` leaves one.
7
+ # Writes a Project's gem from template/: each `.erb` file rendered, every other copied, the
8
+ # executables executable; then a git repository, its files added, as `bundle gem` leaves one.
9
9
  module Generator
10
- # __dir__ is nil only in code evaluated from a string, never in a required file
11
- TEMPLATE = File.expand_path("../../template", __dir__) # steep:ignore ArgumentTypeMismatch
12
- # The files a command line alone needs: its code, its test, its OCI image's workflow.
10
+ # template/: a generated gem's files, ERB and plain. (`|| raise`: __dir__ is nil only in code
11
+ # evaluated from a string, never in a required file.)
12
+ TEMPLATE = File.expand_path("../../template", __dir__ || raise)
13
+ # The files a command line alone needs: its code, its test, its OCI image's workflow and its script.
13
14
  EXE = %w[exe/%name%.erb lib/%path%/cli.rb.erb test/test_cli.rb.erb .github/workflows/image.yml
14
15
  .github/image-registries.sh].freeze
16
+ # The files a generated gem runs as commands: executable there, and so in git's index, which a
17
+ # commit keeps, where the file system has no mode (Windows: core.fileMode false).
18
+ EXECUTABLES = %w[.githooks/commit-msg .githooks/pre-commit .githooks/pre-push .github/image-registries.sh
19
+ .github/release-notes.sh bin/console.erb bin/setup exe/%name%.erb].freeze
15
20
  # git's variables that point it at another repository (`git rev-parse --local-env-vars`, but the
16
21
  # configuration given with `git -c`), which a git hook sets: unset, the repository is the gem's.
17
22
  GIT_LOCAL = %w[GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_CONFIG GIT_OBJECT_DIRECTORY GIT_DIR GIT_WORK_TREE
@@ -19,66 +24,96 @@ module Veragem
19
24
  GIT_PREFIX GIT_SHALLOW_FILE GIT_COMMON_DIR].to_h { [it, nil] }.freeze
20
25
  # Each LICENSE-* file's SPDX identifier.
21
26
  LICENSE_FILES = { "LICENSE-MIT.erb" => "MIT", "LICENSE-APACHE.erb" => "Apache-2.0" }.freeze
27
+ # The citation's file, a citation's alone (--citation).
28
+ CITATION = "CITATION.cff.erb"
29
+ # The tools' resolution: this repository's Gemfile is the template's (test_sync.rb), so its lock
30
+ # names every gem a generated gem's tools load, theirs included.
31
+ TOOLS_LOCK = File.expand_path("../../Gemfile.lock", __dir__ || raise)
32
+ private_constant :GIT_LOCAL, :LICENSE_FILES, :CITATION, :TOOLS_LOCK
22
33
 
23
- # Each template file the project takes, and its path in the gem.
24
- def self.files(project)
25
- skipped = skipped(project)
26
- files = Dir.glob("**/*", File::FNM_DOTMATCH, base: TEMPLATE).sort.to_h { [it, path_of(it, project)] }
27
- files.reject! { |file, _| skipped.include?(file) || File.directory?(File.join(TEMPLATE, file)) }
28
- check(project, files.values)
29
- check_command(project)
30
- files
34
+ # Into +target+, a new directory: a failure, an interrupt included, leaves none behind.
35
+ # @param project [Veragem::Project] the gem
36
+ # @param target [String] its directory, not yet there
37
+ # @return [void]
38
+ # @raise [Veragem::Error] +target+ exists, its name cannot be generated, or a write or git fails
39
+ def self.generate(project, target)
40
+ check(project.name, exe: project.exe, citation: project.citation, target:)
41
+ written = files(project)
42
+ Dir.mkdir(target)
43
+ removed_unless_done(target) do
44
+ written.each { |file, path| write(file, File.join(target, path), project) }
45
+ stage(target, written.filter_map { |file, path| path if EXECUTABLES.include?(file) })
46
+ end
47
+ rescue SystemCallError => e
48
+ raise Error, e.message
31
49
  end
32
50
 
33
- # A name can be a tool's, which the Gemfile would name twice, or a template file's: `helper`'s
34
- # test would be test/test_helper.rb.
35
- def self.check(project, paths)
36
- raise Error, "invalid gem name #{project.name}: the Gemfile's own tool" if tools.include?(project.name)
37
-
38
- clash = paths.tally.find { |_, count| count > 1 }
39
- raise Error, "invalid gem name #{project.name}: it would write #{clash[0]} twice" if clash
51
+ # Every rule a gem's name meets before anything is written, its kinds' and its directory's: the
52
+ # questions' and the generation's one check.
53
+ # @param name [String] as typed
54
+ # @param exe [Boolean] whether the gem has a command line
55
+ # @param citation [Boolean] whether the gem has CITATION.cff
56
+ # @param target [String] its directory, not yet there
57
+ # @return [String] +name+
58
+ # @raise [Veragem::Error] the gem cannot have the name, its modules could not be checked, or
59
+ # +target+ exists
60
+ def self.check(name, exe:, citation:, target:)
61
+ Name.check(name)
62
+ check_target(target)
63
+ check_name(name, exe)
64
+ Citation.check_name(name, citation)
65
+ Name.check_modules(name)
66
+ name
40
67
  end
41
68
 
42
- # A command line names the gem's module from within it, <Gem>::CLI, whose constant lookup finds
43
- # CLI and VERSION first: the gem's module cannot be named either.
44
- def self.check_command(project)
45
- innermost = project.modules.last
46
- return unless project.exe && %w[CLI VERSION].include?(innermost)
69
+ # What a name and its kind decide. A gem the Gemfile's tools load (its lock's), the bundle would
70
+ # resolve to the new gem itself and break them. A command line names the gem's module from
71
+ # within it, <Gem>::CLI, whose constant lookup finds CLI and VERSION first: neither can be its
72
+ # module. Two files at one path: `helper`'s test would be test/test_helper.rb.
73
+ # @param name [String] one Name.check takes
74
+ # @param exe [Boolean] whether the gem has a command line
75
+ # @return [void]
76
+ # @raise [Veragem::Error] the kind cannot have the name
77
+ def self.check_name(name, exe)
78
+ raise Error, "invalid gem name #{name}: a gem the Gemfile's tools load" if tools.include?(name)
79
+
80
+ innermost = Name.modules(name).last
81
+ if exe && %w[CLI VERSION].include?(innermost)
82
+ raise Error, "invalid gem name #{name}: its command line would find another #{innermost} before its module"
83
+ end
47
84
 
48
- raise Error, "invalid gem name #{project.name}: its command line would find another #{innermost} " \
49
- "before its module"
85
+ clash = templates(exe).map { path_of(it, name) }.tally.find { |_, count| count > 1 }
86
+ raise Error, "invalid gem name #{name}: it would write #{clash[0]} twice" if clash
50
87
  end
51
88
 
52
- # The gems template/Gemfile names.
53
- def self.tools = File.read(File.join(TEMPLATE, "Gemfile")).scan(/^gem "([^"]+)"/).flatten
89
+ # +target+ is free: neither a file nor a link, dangling ones too
90
+ private_class_method def self.check_target(target)
91
+ raise Error, "#{target} already exists" if File.exist?(target) || File.symlink?(target)
92
+ end
54
93
 
55
- # The files another kind or licence takes.
56
- def self.skipped(project)
57
- licenses = LICENSE_FILES.reject { |_, spdx| project.licenses.include?(spdx) }.keys
58
- project.exe ? licenses : licenses + EXE
94
+ private_class_method def self.files(project)
95
+ skipped = LICENSE_FILES.reject { |_, spdx| project.licenses.include?(spdx) }.keys
96
+ skipped << CITATION unless project.citation
97
+ (templates(project.exe) - skipped).to_h { [it, path_of(it, project.name)] }
59
98
  end
60
99
 
61
- def self.path_of(file, project)
62
- file.delete_suffix(".erb").gsub("%name%", project.name).gsub("%path%", project.path)
63
- .gsub("%underscored%", project.underscored)
100
+ # The template's files a kind takes (+exe+, a command line's too), each licence's included.
101
+ private_class_method def self.templates(exe)
102
+ files = Dir.glob("**/*", File::FNM_DOTMATCH, base: TEMPLATE)
103
+ files.reject { File.directory?(File.join(TEMPLATE, it)) || (!exe && EXE.include?(it)) }
64
104
  end
65
105
 
66
- # Into +target+, a new directory: a failure, an interrupt included, leaves none behind.
67
- def self.generate(project, target)
68
- raise Error, "#{target} already exists" if File.exist?(target) || File.symlink?(target)
106
+ # The lock's gems, its GEM section's specs (a dependency indented deeper), its lines LF's or CRLF's
107
+ private_class_method def self.tools(lock = File.read(TOOLS_LOCK))
108
+ lock[/^GEM\r?\n.*?^\r?$/m].to_s.scan(/^ {4}(\S+) \(/).flatten
109
+ end
69
110
 
70
- files = files(project)
71
- Dir.mkdir(target)
72
- removed_unless_done(target) do
73
- files.each { |file, path| write(File.join(TEMPLATE, file), File.join(target, path), project) }
74
- git(target, "init", "--quiet", "--initial-branch=main")
75
- git(target, "add", "--all")
76
- end
77
- rescue SystemCallError => e
78
- raise Error, e.message
111
+ private_class_method def self.path_of(file, name)
112
+ file.delete_suffix(".erb").gsub("%name%", name).gsub("%path%", Name.path(name))
113
+ .gsub("%underscored%", Name.underscored(name))
79
114
  end
80
115
 
81
- def self.removed_unless_done(target)
116
+ private_class_method def self.removed_unless_done(target)
82
117
  done = false
83
118
  begin
84
119
  yield
@@ -88,22 +123,36 @@ module Veragem
88
123
  end
89
124
  end
90
125
 
91
- def self.write(source, destination, project)
126
+ private_class_method def self.write(file, destination, project)
127
+ source = File.join(TEMPLATE, file)
92
128
  FileUtils.mkdir_p(File.dirname(destination))
93
- File.binwrite(destination, source.end_with?(".erb") ? render(source, project) : File.binread(source))
94
- return unless File.executable?(source)
129
+ File.binwrite(destination, file.end_with?(".erb") ? render(source, project) : File.binread(source))
130
+ return unless EXECUTABLES.include?(file)
95
131
 
96
- # executable where readable, as `chmod +x` under the umask
132
+ # executable where readable: the umask decides, as it does the read bits
97
133
  mode = File.stat(destination).mode
98
134
  File.chmod(mode | ((mode & 0o444) >> 2), destination)
99
135
  end
100
136
 
101
- def self.render(source, project)
137
+ # A repository of its own, every file added, the executables' mode in the index too
138
+ private_class_method def self.stage(target, executables)
139
+ git(target, "init", "--quiet", "--initial-branch=main")
140
+ # --force: the user's own excludes (core.excludesFile) keep none of the gem's files out
141
+ git(target, "add", "--all", "--force")
142
+ git(target, "update-index", "--chmod=+x", "--", *executables)
143
+ end
144
+
145
+ # The ERB's values are +project+'s: its methods, called bare.
146
+ private_class_method def self.render(source, project)
102
147
  ERB.new(File.read(source, encoding: Encoding::UTF_8), trim_mode: "-").result(project.instance_eval { binding })
103
148
  end
104
149
 
105
- def self.git(dir, *args)
106
- system(GIT_LOCAL, "git", *args, chdir: dir) or raise Error, "git #{args.join(" ")} failed in #{dir}"
150
+ # system's nil: git could not run; false: it failed
151
+ private_class_method def self.git(dir, *args)
152
+ case system(GIT_LOCAL, "git", *args, chdir: dir)
153
+ when nil then raise Error, "cannot run git in #{dir}"
154
+ when false then raise Error, "git #{args.join(" ")} failed in #{dir}"
155
+ end
107
156
  end
108
157
  end
109
158
  end