cybertrain 0.2.0 → 0.2.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2bfb12d19791bff7ce88504f9ffb5fe6e2d90ae7a55aa24f5571b1909cf11976
4
- data.tar.gz: 0eaac7a028bbf9be6c013b716ecec76f02c91fb10aa04e855f7b777f250c8712
3
+ metadata.gz: '0800072e567ad58afad9d1e6531aeff2aa0e82d8d9c69f2091fb6d3f5e0a5d61'
4
+ data.tar.gz: 9e93d0e6dc888ad648ab6acef7d78f52822b72f11aa7435dfd4aeae601334e8a
5
5
  SHA512:
6
- metadata.gz: 66bf793c97cf16d109519514b10c15d929e627137e58a4663d04ad3e88a746fa12e95b39358f15ebe8ba643107a2f8f30dbee54cbc9e95ea5127a4cc870fb417
7
- data.tar.gz: 02bcced8e7ab059cb479b06835420eb241271b4447ed0e7d16c2801c6ed50b6b0d3587ff6557ae9b36501a5a108b05c2a4e7d5757caf7887c353ed41e4c2deb7
6
+ metadata.gz: 6df2e10e9e78787c2328aa2d96116d44bf437a3de08bd312b513129f8321043503df2fae5afc1b8b14e332e0c44d7990765d846e7bdfd04a8f503490f0066a3a
7
+ data.tar.gz: eb2b9396571a00406590c504e96c330937570609f0d8d860885f9cf739ab13e6c90a4859d5266d77f847fc2a7ac25078779721046237da3400e0ecde8a24e6e6
data/README.md CHANGED
@@ -6,7 +6,8 @@
6
6
  </p>
7
7
  <p align="center">
8
8
  <a href="https://saeki-mototsune.github.io/CyberTrain/">Homepage</a> ·
9
- <a href="https://saeki-mototsune.github.io/CyberTrain/tutorial.html">Tutorial</a>
9
+ <a href="https://saeki-mototsune.github.io/CyberTrain/tutorial.html">Tutorial</a> ·
10
+ <a href="https://saeki-mototsune.github.io/CyberTrain/api/">API reference</a>
10
11
  </p>
11
12
 
12
13
  # cybertrain
@@ -34,6 +35,38 @@ authentication, mailers, jobs, WebSockets/ActionCable, an asset pipeline,
34
35
  i18n, and anything beyond a minimal `render json:` — see "Differences from
35
36
  Rails" below.
36
37
 
38
+ ## Try it in the browser
39
+
40
+ [Open the playground in GitHub Codespaces](https://codespaces.new/saeki-mototsune/CyberTrain?quickstart=1)
41
+ to try cybertrain without installing anything: VS Code opens in your browser with
42
+ Spinel 2026.09.12, the `cybertrain` CLI and the blog from the walkthrough below
43
+ already created (`cybertrain new blog`, the article scaffold, the root route and
44
+ the first migration, built once so the server starts without compiling), its
45
+ development server running in a terminal and the app, showing the article list,
46
+ in the editor's preview. The app is a git repository with one commit, so Source
47
+ Control shows what you change. `PLAYGROUND.md` in the app lists what to try, from
48
+ editing a view to carrying on with the walkthrough's comments. A view edit shows
49
+ on the next reload; a Ruby edit is a full rebuild, about a minute, after which the
50
+ server restarts by itself; the preview does not reload by itself. `cybertrain new`
51
+ works there with no network.
52
+
53
+ - A GitHub account is required, and the codespace runs on your own Codespaces
54
+ quota: GitHub's free plan includes 120 core-hours and 15 GB-month of storage a
55
+ month, about 60 hours on the default 2-core machine the playground uses. GitHub
56
+ stops an idle codespace after 30 minutes by default; its storage counts until
57
+ you delete it.
58
+ - Inside the preview, pop-ups and `confirm()` dialogs do not work; the Ports
59
+ view's "Open in Browser" shows the app in a normal tab.
60
+ - The same image runs locally (it is published for linux/amd64; on arm64, build
61
+ it from a checkout):
62
+
63
+ ```sh
64
+ docker run --rm -it --init -p 127.0.0.1:3000:3000 -e CYBERTRAIN_HOST=0.0.0.0 ghcr.io/saeki-mototsune/cybertrain-playground
65
+ ```
66
+
67
+ Then open http://localhost:3000. [playground/README.md](playground/README.md)
68
+ describes the image, building it and the Codespaces setup.
69
+
37
70
  ## Requirements
38
71
 
39
72
  - Spinel `2026.09.12` (`Cybertrain::SPINEL_TAG`, the same release as
@@ -150,7 +183,7 @@ if they are not that release.
150
183
 
151
184
  `cybertrain new` writes the application with its `spin.toml` pointing at
152
185
  the release matching the CLI (`cybertrain = { git =
153
- "https://github.com/saeki-mototsune/cybertrain", ref = "v0.2.0" }`), makes
186
+ "https://github.com/saeki-mototsune/cybertrain", ref = "v0.2.1" }`), makes
154
187
  sure Spinel is installed, then runs `spin lock` (spin fetches the framework
155
188
  into its cache, `~/.cache/spin/packages/`, and pins the commit in
156
189
  `spin.lock`) and `spin run gen`, much as `rails new` runs `bundle install`,
@@ -481,7 +514,10 @@ compiler output on every HTML response. None of this loads in production.
481
514
  | Session holds any marshalled object | Session values are Strings only, HMAC-signed cookie |
482
515
  | Many database adapters | SQLite only, via FFI |
483
516
  | `has_many :through`, `includes`, `pluck`, `dependent:`, enums, STI, polymorphic associations | Not implemented; associations come from schema foreign keys only |
517
+ | Any column name; an association can shadow `errors` | A column name must be an ASCII identifier (`[A-Za-z_][A-Za-z0-9_]*`): a schema with a non-ASCII column name (`名前`, `prénom`) generated under CRuby before PR #10 and `spin run gen` refuses it now, so rename the column when upgrading (a name like `first-name` was never a method name). A column named like a method the generated class or the framework calls on a record (`errors`, `save`, `attributes`, `hash`, `to_s`, `to_ary`, `raise`, ...) or like a Ruby keyword (`end`, `class`, `begin`) generates under `<column>_column` (`hash_column`, reader, writer and ivar) with a note in the generated file; its SQL name keeps working in `read_attribute`, params and `attributes["hash"]`, and a template reaches it under either name (`post.hash`, `post.hash_column`), except `errors` and `to_param`, which a template resolves as the model methods, so use `post.errors_column` there; the query API (`where`, `order`, `find_by`) always takes the SQL column name (`Post.where(hash: 1)`), the Ruby name being the reader/writer and the template name only. A column named like another Object method (`display`, `tap`, `methods`) generates under its own name with a note. `cybertrain generate scaffold` refuses keywords and reserved names (it would have to call a renamed reader) and accepts a shadowing name with a note, like the generator. An association whose plain name a column, another association or a `Model` method already owns is emitted under a fallback name with a comment in the generated file: `<table>_as_<column stem>` for a `has_many` (`comments_as_article`), `<stem>_as_<column>` for a `belongs_to` (`author_as_author_id`) |
484
518
  | `namespace`, format/`respond_to`, `constraints`, `mount` | Not implemented — flat names, `render json:` only |
519
+ | `rescue StandardError` catches a bad `JSON.parse` | Under Spinel `JSON::ParserError` is not a `StandardError`: app code must `rescue JSON::ParserError, StandardError`. The server catches it as a last resort and answers 500 |
520
+ | `order("lower(title)")`, `order("posts.title")`, `order(params[:sort])` | `order` takes only `column [ASC\|DESC]` lists (each column quoted) and raises `ArgumentError` (a 500: the string is the developer's) on anything else; request data must go through an allowlist first, e.g. `SORTS = { "title" => "title", "newest" => "created_at DESC" }` and `Post.order(SORTS.fetch(params[:sort].to_s, "id"))`, because a well-formed term can still name a missing column (a 500 from SQLite) or a column the client must not sort by; raw ORDER BY text goes through `order_sql` (`Post.order_sql("lower(title)")`, also on a relation), which must never see request data. A `limit`/`offset` on `delete_all` is honoured (a subselect), and request parameters nest at most 32 levels / 4096 pairs (400 past that). `Query.parse`, `Query.value_of`, `Query.decode` and `request.form_value` / `query_value` raise `Cybertrain::QueryMalformed` (a `StandardError` under `Cybertrain::QueryInvalid`, not an `ArgumentError`) on a malformed percent-escape, on both runtimes (`Cookies.parse` still keeps the raw value). They raise it too on an invalid UTF-8 byte sequence, raw (whatever the String's tag) or percent-encoded (Rails likewise answers 400 for an invalid parameter encoding), and so does a raw byte that only an escape would complete (`a=\xC3%A9`); `Cookies.parse` keeps such a value raw, and a path with an invalid sequence (raw, or percent-decoded in a segment) is a 400 as well (`request.path_segments`; a malformed escape in a path stays literal, a 404 by non-match). App code that rescued `ArgumentError` around them should rescue `Cybertrain::QueryInvalid` instead (upgrade note). The request keeps no parameter tree of its own (the Router builds `params`, the middleware read one key); an app that wants the whole tree parses it itself, `Query.parse(request.utf8_query_string)`, and owns the result |
485
521
  | Full backtrace on an exception | Class, message, request line, template name/line — Spinel exposes no backtraces |
486
522
  | Rack, its middleware, and any gem in a `Gemfile` | No Rack compatibility; a small fixed middleware set; Spinel's own `spin-index`, limited to what compiles under its Ruby subset |
487
523
  | minitest / RSpec | `Cybertrain::Test` — reflection-based runners can't work ahead-of-time |
@@ -508,13 +544,27 @@ end
508
544
  | `CYBERTRAIN_DATABASE` | `database_path` | `storage/#{env}.sqlite3` |
509
545
  | `CYBERTRAIN_SECRET_KEY_BASE` | `secret_key_base` | required in production; dev/test auto-generate one into `tmp/secret_key` |
510
546
  | `SPINEL_WORKERS` | `workers` | `1` |
547
+ | `CYBERTRAIN_HOST` | `host` | `"127.0.0.1"`; `0.0.0.0` listens on every interface (containers) |
548
+ | `CYBERTRAIN_SESSION_SAME_SITE` | `session_same_site` | `"Lax"`; also `Strict` or `None` (`None` always adds `Secure`); anything else stops the server at boot |
549
+ | `CYBERTRAIN_SESSION_PARTITIONED` | `session_partitioned` | `false`; `1` or `true` adds `Partitioned` (and `Secure`) |
550
+
551
+ Use `None` (with `CYBERTRAIN_SESSION_PARTITIONED=1`) only for an app shown
552
+ inside another site's frame, such as the playground's Codespaces preview; the
553
+ browser must reach the app over HTTPS.
511
554
 
512
- Other attributes with fixed, overridable defaults: `host` (`"127.0.0.1"`),
513
- `views_root` (`"app/views"`), `public_root` (`"public"`), `layout`
555
+ Other attributes with fixed, overridable defaults: `views_root`
556
+ (`"app/views"`), `public_root` (`"public"`), `layout`
514
557
  (`"layouts/application"`), `log_level` (`:info`), `session_cookie_name`,
515
558
  `session_max_age` (2 weeks), `session_secure` (`true` in production, which
516
- marks the session cookie `Secure`; `false` elsewhere), `pool_size` (4),
517
- `static_files`/`csrf` (`true`).
559
+ marks the session cookie `Secure`; `false` elsewhere, though `SameSite=None`
560
+ and `Partitioned` add `Secure` anyway), `pool_size` (4), `static_files`/`csrf`
561
+ (`true`), `max_render_depth` (12 renders open at once: the page and its
562
+ partials; raise it for partials that legitimately recurse deeper; it must be
563
+ at least 1, or boot fails). Upgrade note: render nesting, unlimited before, is
564
+ now capped at 12 by default (the page is depth 1, so partials can nest 11
565
+ levels; the layout renders after the page and does not nest), and an app with
566
+ a deeper tree (threaded comments, a category menu) gets a template error until
567
+ it sets `max_render_depth`.
518
568
 
519
569
  **Deployment:** `cybertrain build` produces `dist/`: the binary `dist/NAME`
520
570
  with the views embedded, `dist/public/` (static assets, or let a reverse
@@ -590,11 +640,19 @@ prints the diff stat).
590
640
 
591
641
  ## Learn more
592
642
 
593
- - [Homepage](https://saeki-mototsune.github.io/CyberTrain/) and
594
- [tutorial](https://saeki-mototsune.github.io/CyberTrain/tutorial.html) —
643
+ - [Homepage](https://saeki-mototsune.github.io/CyberTrain/),
644
+ [tutorial](https://saeki-mototsune.github.io/CyberTrain/tutorial.html) and
645
+ [playground](https://saeki-mototsune.github.io/CyberTrain/playground.html) —
595
646
  the source lives in [site/](site/), published by
596
647
  [.github/workflows/pages.yml](.github/workflows/pages.yml); the logo files and
597
648
  brand notes are in [site/assets/brand/](site/assets/brand/BRAND.md).
649
+ - [API reference](https://saeki-mototsune.github.io/CyberTrain/api/) — every
650
+ class and method an application uses (controllers, params, session and
651
+ flash, models and relations, routes, migrations, configuration, tests),
652
+ including what `spin run gen` generates. It is built by YARD
653
+ (`script/api-docs`, into `site/api/`) from the doc comments marked
654
+ `@api public` in `cybertrain/` and from [docs/api/](docs/api/); a method
655
+ without that tag is internal and stays out. CI fails on any YARD warning.
598
656
  - [docs/design.md](docs/design.md) — the design record (Japanese): every
599
657
  decision, what was rejected and why, and the Spinel constraints behind it.
600
658
  - [docs/template-language.md](docs/template-language.md) — the full template
@@ -615,8 +673,12 @@ before the gem is pushed:
615
673
  1. Bump `Cybertrain::VERSION` (`cybertrain/version.rb`) and `version` in
616
674
  `spin.toml` together (CI checks they match); spin caches a git
617
675
  dependency by that version.
618
- 2. Merge to `main`, then tag and push: `git tag v0.2.0 && git push origin v0.2.0`.
619
- 3. `gem build cybertrain.gemspec && gem push cybertrain-0.2.0.gem`.
676
+ 2. Merge to `main`, then tag and push: `git tag v0.2.1 && git push origin v0.2.1`.
677
+ 3. `gem build cybertrain.gemspec && gem push cybertrain-0.2.1.gem`.
678
+ 4. The tag also publishes the playground image
679
+ `ghcr.io/saeki-mototsune/cybertrain-playground:X.Y.Z` (and `latest`) through
680
+ [.github/workflows/playground-image.yml](.github/workflows/playground-image.yml);
681
+ check that run.
620
682
 
621
683
  `Cybertrain::SPINEL_TAG` (also in `cybertrain/version.rb`) is the Spinel
622
684
  release `cybertrain setup` installs. It must equal `SPINEL_TAG` in
@@ -0,0 +1,36 @@
1
+ module Cybertrain
2
+ # The one definition of "a name usable as the application's package name":
3
+ # `cybertrain build`/`server` (CLI::Build.app_name, reading spin.toml) and
4
+ # the development rebuilder (Dev::Rebuilder.new, which shells out with the
5
+ # name Application.new(name:) was given) both ask here, so the rules
6
+ # cannot drift apart. Application.new itself does not: production never
7
+ # builds. A leaf file with no requires: the CLI (CRuby) and the framework
8
+ # (Spinel) both load it. String methods only, no Regexp, because it
9
+ # compiles under Spinel (see Dev::Rebuilder).
10
+ module AppName
11
+ # "" when name works as a `spin build` target and as the build/bin/<name>
12
+ # path, else the reason (a String on every path, NOTES rules 10/34).
13
+ # Shell safety is not this method's job: both callers quote the name when
14
+ # they build a command ("my-app" and "MyApp" are fine for `spin build`),
15
+ # so only what breaks the target or the path is refused.
16
+ def self.problem(name)
17
+ # An empty name would become an explicit "" argument to spin.
18
+ return "cannot be empty" if name.empty?
19
+ # "." and ".." name build/bin/. and build/bin/.., which exist already
20
+ # (the latter is build/) and only fail later, in assemble's cp.
21
+ return "cannot be '.' or '..'" if name == "." || name == ".."
22
+ # Quoting leaves a leading "-" bare ("-" is in quote_arg's safe set),
23
+ # and even quoted `spin build '-x'` is parsed by spin as an option, not
24
+ # as the package name. Whether spin accepts `--` before the target is
25
+ # unverified, hence a refusal.
26
+ return "cannot start with '-' (spin would read it as an option)" if name.start_with?("-")
27
+ # A "/" would put the binary in a subdirectory of build/bin.
28
+ return "cannot contain '/'" if name.include?("/")
29
+ # Whitespace splits the target in the places that do not quote it, and
30
+ # a newline ends a command line.
31
+ return "cannot contain whitespace" if name.include?(" ") || name.include?("\t") || name.include?("\n") || name.include?("\r")
32
+
33
+ ""
34
+ end
35
+ end
36
+ end
@@ -1,10 +1,42 @@
1
1
  # `cybertrain build`: dist/ = the app binary with app/views embedded, plus
2
2
  # public/. Also the command lists `cybertrain db` and `server` run. Plain Ruby:
3
3
  # runs under CRuby (the gem) and compiles under Spinel (spin install).
4
+ require "cybertrain/app_name"
5
+
4
6
  module Cybertrain
5
7
  module CLI
6
8
  module Build
7
- # The [package] name in root/spin.toml, or "" when there is none.
9
+ # The entries assemble() keeps in dist/ besides the binary: the copied
10
+ # public/ and the storage/ and tmp/ directories. A package named like
11
+ # one of them would collide with it (the binary renamed to dist/public
12
+ # gets moved aside as dist/.public.old and deleted; a dist/storage
13
+ # directory cannot be replaced by the binary), so `cybertrain build`
14
+ # (require_dist_name!) and `cybertrain new` refuse these names;
15
+ # app_name, which every command reads, does not: `cybertrain db` and
16
+ # `server` work for an existing app named like one. assemble builds its
17
+ # paths from these constants so the two cannot drift.
18
+ DIST_PUBLIC = "public"
19
+ DIST_STORAGE = "storage"
20
+ DIST_TMP = "tmp"
21
+ DIST_ENTRIES = [DIST_PUBLIC, DIST_STORAGE, DIST_TMP]
22
+
23
+ # "" when name cannot collide with what assemble() puts in dist/, else
24
+ # the reason. A leading "." covers assemble's scratch entries
25
+ # (dist/.<name>.tmp, dist/.public.tmp, dist/.public.old) and "."/"..".
26
+ # The entry names match case-insensitively: on a case-insensitive
27
+ # filesystem (macOS's default APFS, one of the CI OSes; Windows) "Public"
28
+ # and "TMP" are the same directory entry as public and tmp. Only a
29
+ # different spelling of the whole name ("tmp2", "my_public") is another
30
+ # entry. downcase is ASCII-safe here: the entries are ASCII.
31
+ def self.dist_name_problem(name)
32
+ return "" unless DIST_ENTRIES.include?(name.downcase) || name.start_with?(".")
33
+
34
+ "collides with what `cybertrain build` keeps in dist/ (#{DIST_ENTRIES.join(", ")}) or its scratch entries: it cannot be one of those names (in any letter case) or start with '.'"
35
+ end
36
+
37
+ # The [package] name in root/spin.toml, or "" when there is none. The
38
+ # name ends up in shell command strings and file paths, so it must be
39
+ # a name `cybertrain new` could have produced.
8
40
  def self.app_name(root)
9
41
  path = "#{root}/spin.toml"
10
42
  return "" unless File.exist?(path)
@@ -22,11 +54,35 @@ module Cybertrain
22
54
  break
23
55
  end
24
56
  end
57
+ # An empty name is run_in_app's "no spin.toml with a [package] name".
58
+ # Any other name must be usable as a `spin build` target and a
59
+ # build/bin/<name> path: the same predicate Application.new applies to
60
+ # the dev rebuilder's target. The name reaches the shell through
61
+ # quote_arg, so only what breaks that target or path is refused.
62
+ return "" if name.empty?
63
+
64
+ problem = AppName.problem(name)
65
+ raise InvalidArgument, "spin.toml [package] name '#{name}' #{problem}" unless problem.empty?
66
+
25
67
  name
26
68
  end
27
69
 
70
+ # The build path's check, made once in CLI.build_app before the
71
+ # toolchain is touched (Build.run does not repeat it): a name that
72
+ # collides with a dist/ entry only matters to assemble. Returns nil.
73
+ def self.require_dist_name!(name)
74
+ problem = dist_name_problem(name)
75
+ raise InvalidArgument, "spin.toml [package] name '#{name}' #{problem}" unless problem.empty?
76
+
77
+ nil
78
+ end
79
+
80
+ # name goes through quote_arg too: app_name validates it, but the
81
+ # command strings stay safe for a caller that skipped that, whatever the
82
+ # spelling. An empty name cannot reach these from the CLI (run_in_app
83
+ # refuses it first); a direct caller passing "" gets `spin build ''`.
28
84
  def self.commands(name)
29
- ["spin run gen -- --embed-views", "spin build #{name}", "spin run gen"]
85
+ ["spin run gen -- --embed-views", "spin build #{quote_arg(name)}", "spin run gen"]
30
86
  end
31
87
 
32
88
  # `cybertrain db ARGS`, through bin/db.rb (the app binary cannot compile
@@ -55,7 +111,8 @@ module Cybertrain
55
111
 
56
112
  # port is "" (the app's default, 3000) or a port? string.
57
113
  def self.server_commands(name, port)
58
- run = port == "" ? "spin run #{name}" : "spin run #{name} -- #{port}"
114
+ target = quote_arg(name)
115
+ run = port == "" ? "spin run #{target}" : "spin run #{target} -- #{quote_arg(port)}"
59
116
  ["spin run gen", run]
60
117
  end
61
118
 
@@ -84,6 +141,8 @@ module Cybertrain
84
141
  end
85
142
 
86
143
  # Runs the build in root and assembles dist/. Returns the exit code.
144
+ # name must already have passed require_dist_name!: CLI.build_app checks
145
+ # it once, before the toolchain is fetched.
87
146
  def self.run(root, name, runner = Runner.new)
88
147
  steps = commands(name)
89
148
  ok = false
@@ -138,8 +197,8 @@ module Cybertrain
138
197
  dist = "#{root}/dist"
139
198
  Templates.mkdir_p(dist)
140
199
  binary_tmp = "#{dist}/.#{name}.tmp"
141
- public_tmp = "#{dist}/.public.tmp"
142
- public_old = "#{dist}/.public.old"
200
+ public_tmp = "#{dist}/.#{DIST_PUBLIC}.tmp"
201
+ public_old = "#{dist}/.#{DIST_PUBLIC}.old"
143
202
  # Leftovers of an interrupted earlier run.
144
203
  rm_tree(binary_tmp)
145
204
  rm_tree(public_tmp)
@@ -150,20 +209,20 @@ module Cybertrain
150
209
 
151
210
  File.rename(binary_tmp, "#{dist}/#{name}")
152
211
 
153
- if File.directory?("#{root}/public")
154
- public_command = "cp -R #{shell_quote("#{root}/public")} #{shell_quote(public_tmp)}"
155
- raise InvalidArgument, "could not copy public/ to dist/" unless system(public_command)
212
+ if File.directory?("#{root}/#{DIST_PUBLIC}")
213
+ public_command = "cp -R #{shell_quote("#{root}/#{DIST_PUBLIC}")} #{shell_quote(public_tmp)}"
214
+ raise InvalidArgument, "could not copy #{DIST_PUBLIC}/ to dist/" unless system(public_command)
156
215
  else
157
216
  Dir.mkdir(public_tmp)
158
217
  end
159
- public_dir = "#{dist}/public"
218
+ public_dir = "#{dist}/#{DIST_PUBLIC}"
160
219
  File.rename(public_dir, public_old) if File.exist?(public_dir) || File.symlink?(public_dir)
161
220
  File.rename(public_tmp, public_dir)
162
221
  rm_tree(public_old)
163
222
 
164
- Templates.mkdir_p("#{dist}/storage")
165
- Templates.mkdir_p("#{dist}/tmp")
166
- ["dist/#{name}", "dist/public/", "dist/storage/", "dist/tmp/"]
223
+ Templates.mkdir_p("#{dist}/#{DIST_STORAGE}")
224
+ Templates.mkdir_p("#{dist}/#{DIST_TMP}")
225
+ ["dist/#{name}", "dist/#{DIST_PUBLIC}/", "dist/#{DIST_STORAGE}/", "dist/#{DIST_TMP}/"]
167
226
  end
168
227
 
169
228
  # A symlink is removed itself, never followed: its target may be
@@ -14,6 +14,11 @@ module Cybertrain
14
14
  # relative to dir.
15
15
  def self.create(dir, framework_dep)
16
16
  package = File.basename(dir)
17
+ # Before anything is written: `public`, `storage` and `tmp` are valid
18
+ # identifiers but collide with dist/ entries (Build.dist_name_problem).
19
+ problem = Build.dist_name_problem(package)
20
+ raise InvalidArgument, "'#{package}' is not a valid app name: it #{problem}" unless problem.empty?
21
+
17
22
  title = Inflector.camelize(package)
18
23
  files = [
19
24
  ["spin.toml", Templates.spin_toml(package, framework_dep)],
@@ -2,6 +2,7 @@
2
2
  # migration, model, controller and views of a resource and adds
3
3
  # `resources :posts` to config/routes.rb, the way Rails' scaffold does.
4
4
  require "cybertrain/generator/inflector"
5
+ require "cybertrain/ident"
5
6
  require "cybertrain/cli/templates"
6
7
 
7
8
  module Cybertrain
@@ -15,17 +16,12 @@ module Cybertrain
15
16
  TYPES = %w[string text integer float boolean date datetime references]
16
17
 
17
18
  # Columns every table already has (the primary key and t.timestamps).
19
+ # Everything else a column may not be called -- Ruby keywords, names
20
+ # that collide with the generated model -- comes from Cybertrain::Ident,
21
+ # the same rules the generator applies, so the scaffold refuses the
22
+ # name instead of writing files that `spin run gen` then rejects.
18
23
  RESERVED_COLUMNS = %w[id created_at updated_at]
19
24
 
20
- # Ruby keywords: a column (or resource) named after one would generate
21
- # `def class` / `|end|` and break the generated code.
22
- RUBY_KEYWORDS = %w[
23
- __ENCODING__ __LINE__ __FILE__ BEGIN END alias and begin break case
24
- class def defined do else elsif end ensure false for if in module
25
- next nil not or redo rescue retry return self super then true undef
26
- unless until when while yield
27
- ]
28
-
29
25
  attr_reader :field_name, :field_type
30
26
 
31
27
  def self.parse(arg)
@@ -38,11 +34,18 @@ module Cybertrain
38
34
  type = parts.size > 1 ? parts[1].to_s : "string"
39
35
  type = "references" if type == "belongs_to"
40
36
  raise InvalidArgument, "bad field name '#{name}'" unless Templates.identifier?(name)
41
- raise InvalidArgument, "'#{name}' is a Ruby keyword and cannot name a field" if RUBY_KEYWORDS.include?(name)
42
37
  raise InvalidArgument, "unknown type '#{type}' for #{name} (use #{TYPES.join(", ")})" unless TYPES.include?(type)
43
38
 
44
39
  field = Field.new(name, type)
45
40
  raise InvalidArgument, "'#{field.column_name}' is a column every table already has" if RESERVED_COLUMNS.include?(field.column_name)
41
+ # Ident.unusable_reason is the one definition of a name the scaffold
42
+ # must not invent; Scaffold.refuse_unusable! turns its answer into the
43
+ # message. A name that only shadows an Object method is accepted, as the
44
+ # generator accepts it, and Scaffold.generate prints a note.
45
+ Scaffold.refuse_unusable!(field.column_name, "field")
46
+ # A references field also defines the reader `def <name>` (post:references
47
+ # -> def post), which answers to the same rules as a column.
48
+ Scaffold.refuse_unusable!(name, "reference") if field.reference?
46
49
 
47
50
  field
48
51
  end
@@ -109,6 +112,36 @@ module Cybertrain
109
112
  module Scaffold
110
113
  DRAW_LINE = "Cybertrain::Routes.draw do"
111
114
 
115
+ # Policy: the scaffold refuses what the generator would RENAME (Ruby
116
+ # keywords, Ident::RESERVED_COLUMN_NAMES): its views and controller call
117
+ # the reader by the field's name, so a renamed reader would break them.
118
+ # It accepts what the generator only annotates (a name that shadows an
119
+ # Object method, `method`, `display`, `send`) and says so on stdout
120
+ # (shadow_notes), as the generator does in the model file.
121
+ #
122
+ # Raises InvalidArgument when `name` is a name the scaffold must not
123
+ # invent, with the message for the reason Ident.unusable_reason gives;
124
+ # `what` says what the name would name ("field", "reference",
125
+ # "resource"). The one place that maps a reason to a message, shared by the
126
+ # field column, the references reader and the resource singular: a
127
+ # reason Ident adds must be added here, and the `else` raises for one
128
+ # that is not (a `case` without it would return nil and let the name
129
+ # through silently). Returns nil, explicitly, for a usable name (NOTES
130
+ # rules 10/34: one return type).
131
+ def self.refuse_unusable!(name, what)
132
+ case Ident.unusable_reason(name)
133
+ when ""
134
+ nil
135
+ when "keyword"
136
+ raise InvalidArgument, "'#{name}' is a Ruby keyword and cannot name a #{what}"
137
+ when "reserved"
138
+ raise InvalidArgument, "'#{name}' would shadow a method of the generated model (Cybertrain::Model); pick another #{what}"
139
+ else
140
+ raise RuntimeError, "Ident.unusable_reason('#{name}') answered a reason Scaffold.refuse_unusable! has no message for"
141
+ end
142
+ nil
143
+ end
144
+
112
145
  # Returns the paths it created (files that already exist are reported
113
146
  # as "identical" or "exist" and left alone). Raises InvalidArgument on a
114
147
  # bad name, a bad or duplicate field, or a routes file without a
@@ -120,7 +153,18 @@ module Cybertrain
120
153
 
121
154
  underscored = Inflector.underscore(name)
122
155
  raise InvalidArgument, "bad resource name '#{name}'" unless Templates.identifier?(underscored)
123
- raise InvalidArgument, "'#{name}' is a Ruby keyword and cannot name a resource" if Field::RUBY_KEYWORDS.include?(Inflector.singularize(underscored))
156
+ singular = Inflector.singularize(underscored)
157
+ # Policy (refuse_unusable!): the scaffold refuses a singular the
158
+ # generator would RENAME. A reserved one (update, save) would give
159
+ # every referencing model a `<stem>_as_<col>` reader, and the singular
160
+ # is also the model class name. A shadowing one (display, tap) is
161
+ # accepted with a note (shadow_notes). The generator itself still
162
+ # generates a schema with a reserved table (a hand-written table named
163
+ # `updates` works), so this is the scaffold's choice, not the
164
+ # generator's refusal. The plural (the has_many side) is left to the
165
+ # generator, which renames such a reader (`errors_as_post`) -- and
166
+ # nothing may reference the table.
167
+ Scaffold.refuse_unusable!(singular, "resource")
124
168
 
125
169
  parsed = Array.new(0) { Field.new("", "") }
126
170
  columns = Array.new(0) { "" }
@@ -145,9 +189,25 @@ module Cybertrain
145
189
  record(created, root, "#{views}/edit.html.erb", Templates.edit_view(res))
146
190
  record(created, root, "#{views}/_form.html.erb", Templates.form_partial(res))
147
191
  add_route(routes_path, res.plural)
192
+ shadow_notes(res).each { |note| puts note }
148
193
  created
149
194
  end
150
195
 
196
+ # One line per name the scaffold accepted that shadows an Object method
197
+ # (Ident::SHADOWING_COLUMN_NAMES), the generator's own note told on
198
+ # stdout: a field (or references reader) on the model, the resource
199
+ # singular on every model that references it.
200
+ def self.shadow_notes(res)
201
+ notes = Array.new(0) { "" }
202
+ res.fields.each do |f|
203
+ notes << "note: '#{f.field_name}' shadows Object##{f.field_name} on the generated model; call it as #{res.singular}.#{f.field_name}" if Ident.shadowing_column?(f.field_name)
204
+ end
205
+ if Ident.shadowing_column?(res.singular)
206
+ notes << "note: '#{res.singular}' shadows Object##{res.singular} on a model that references #{res.plural} (belongs_to reader); call it as record.#{res.singular}"
207
+ end
208
+ notes
209
+ end
210
+
151
211
  def self.record(created, root, path, content)
152
212
  created << path if Templates.write(root, path, content) == "create"
153
213
  nil
@@ -5,6 +5,8 @@
5
5
  # The scaffold templates take a Cybertrain::CLI::Resource (cli/scaffold.rb).
6
6
  # Generated views stay inside the template language of docs/design.md
7
7
  # section 7: literals, @ivars, locals, helper calls, `if` and `each do`.
8
+ require "cybertrain/ident"
9
+
8
10
  module Cybertrain
9
11
  module CLI
10
12
  module Templates
@@ -39,19 +41,12 @@ module Cybertrain
39
41
  nil
40
42
  end
41
43
 
42
- # A lowercase Ruby-ish name: "post", "blog_post", "released_on".
44
+ # A lowercase Ruby-ish name: "post", "blog_post", "released_on". The
45
+ # snake_case subset of Ident.column? (what the generator accepts),
46
+ # letter-first since it names packages, binaries and files too; one
47
+ # definition of the characters, so the two cannot drift apart.
43
48
  def self.identifier?(word)
44
- return false if word == ""
45
-
46
- ok = true
47
- first = true
48
- word.each_char do |ch|
49
- lower = ch >= "a" && ch <= "z"
50
- digit = ch >= "0" && ch <= "9"
51
- ok = false unless lower || (!first && (digit || ch == "_"))
52
- first = false
53
- end
54
- ok
49
+ Ident.snake_case?(word, false)
55
50
  end
56
51
 
57
52
  # "blog_posts" -> "Blog posts"
@@ -115,7 +110,8 @@ module Cybertrain
115
110
  <<~RUBY
116
111
  # Application settings; see Cybertrain::Config for every option.
117
112
  # Environment variables (PORT, CYBERTRAIN_ENV, CYBERTRAIN_DATABASE,
118
- # CYBERTRAIN_SECRET_KEY_BASE) are read before this block runs.
113
+ # CYBERTRAIN_SECRET_KEY_BASE, CYBERTRAIN_HOST, CYBERTRAIN_SESSION_SAME_SITE,
114
+ # CYBERTRAIN_SESSION_PARTITIONED) are read before this block runs.
119
115
  Cybertrain.configure do |c|
120
116
  # c.port = 3000
121
117
  # c.workers = 1
data/cybertrain/cli.rb CHANGED
@@ -67,7 +67,7 @@ module Cybertrain
67
67
  when "migration" then run_in_app { |_name| run_spin(Build.db_commands(["migrate"])) }
68
68
  when "db" then run_in_app { |_name| run_db(argv) }
69
69
  when "server" then run_in_app { |name| run_server(name, argv) }
70
- when "build" then run_in_app { |name| Toolchain.ensure! ? Build.run(".", name) : 1 }
70
+ when "build" then run_in_app { |name| build_app(name) }
71
71
  when "spin" then run_spin_passthrough(argv)
72
72
  when "setup" then Toolchain.setup(argv[1, argv.size - 1])
73
73
  when "doctor" then run_doctor(argv)
@@ -163,6 +163,14 @@ module Cybertrain
163
163
  yield name
164
164
  end
165
165
 
166
+ # `cybertrain build`: the one dist/ name check, made before the toolchain
167
+ # is fetched or any spin command runs; Build.run assumes a name that got
168
+ # past it.
169
+ def self.build_app(name)
170
+ Build.require_dist_name!(name)
171
+ Toolchain.ensure! ? Build.run(".", name) : 1
172
+ end
173
+
166
174
  # Runs spin commands in turn once a Spinel of the pinned release is on
167
175
  # PATH; stops at the first failure.
168
176
  def self.run_spin(commands)
@@ -55,8 +55,9 @@ module Cybertrain
55
55
  def self.underscore(camel_cased_word)
56
56
  return camel_cased_word.dup unless camel_cased_word.match?(/[A-Z-]|::/)
57
57
 
58
- # Two steps: in a threaded program a collection inside `chars` freed the
59
- # unnamed gsub result it was splitting ("Post" came back as "").
58
+ # Two steps, the gsub result held in a local: in a threaded program a
59
+ # collection inside `chars` frees an unnamed gsub result it is
60
+ # splitting ("Post" comes back as "").
60
61
  path = camel_cased_word.gsub("::", "/")
61
62
  chars = path.chars
62
63
  out = +""
@@ -0,0 +1,171 @@
1
+ # Cybertrain::Ident -- the one place that says what a column (or scaffold
2
+ # field) may be called. Used by the generator (ModelsEmitter), the ORM
3
+ # (Relation#order) and the CLI (`cybertrain generate scaffold`), so a name
4
+ # the scaffold accepts is a name the generator and the models accept, and
5
+ # the failure comes from the command that would write the files.
6
+ #
7
+ # Plain Ruby with no requires: the CLI gem ships this file and runs it under
8
+ # CRuby, the framework compiles it under Spinel (no Regexp, NOTES rule 1).
9
+ module Cybertrain
10
+ module Ident
11
+ # Column names whose plain reader the generated class cannot host: it
12
+ # would shadow a method the generated class or the framework calls on the
13
+ # record. The generator renames them, so an existing schema keeps
14
+ # generating: the column reads as `<column>_column`
15
+ # (ModelsEmitter.reader_name). The scaffold refuses them: its views and
16
+ # controller call the reader by the field's name, which a renamed reader
17
+ # would break. `id` is deliberately
18
+ # absent: the primary key is Model#id. Names that end in `?` or `!`
19
+ # cannot be columns at all (column? rejects them), so `valid?`,
20
+ # `persisted?`, `is_a?` need no entry; keywords (`class`) live in
21
+ # RUBY_KEYWORDS only, and the generator renames those the same way
22
+ # (`class_column`). Class methods (`table_name`, `column_names`,
23
+ # `from_row`) need no entry either: the framework calls them on the
24
+ # class (`self.class.table_name`, `Post.from_row`), which a column
25
+ # reader on the instance does not shadow, so a schema with a
26
+ # `table_name` column keeps generating. script/check-reserved-names
27
+ # keeps the Model and Object groups here and in SHADOWING_COLUMN_NAMES
28
+ # in step with the code (it skips `def self.` methods).
29
+ RESERVED_COLUMN_NAMES = [
30
+ # Cybertrain::Model: its methods and the ivars behind them (`errors`,
31
+ # `persisted` -> @errors, @persisted)
32
+ "errors", "persisted", "attributes", "save", "update", "destroy",
33
+ "reload", "model_name", "to_json", "as_json", "to_param", "to_row",
34
+ "load_row", "set_id", "run_callbacks", "insert_row", "update_row",
35
+ "read_attribute", "write_attribute", "assign_attributes",
36
+ "read_association", "call_view_method", "initialize",
37
+ # Object methods Ruby or the framework calls on a record without being
38
+ # asked: string interpolation and templates (to_s), error messages
39
+ # (inspect), Hash keys (hash)
40
+ "hash", "inspect", "to_s",
41
+ # Implicit-conversion and dispatch hooks Ruby calls on its own (none is
42
+ # defined on Object, so no reflection finds them): an arity-0 reader
43
+ # returning a String breaks `puts post` / `[post].flatten` / a splat
44
+ # (to_ary), string coercion (to_str), keyword splats (to_hash), `&post`
45
+ # (to_proc), Integer coercion (to_int), and turns every NoMethodError
46
+ # into an ArgumentError (method_missing). `respond_to_missing?` ends in
47
+ # `?` and needs no entry.
48
+ "to_ary", "to_str", "to_hash", "to_proc", "to_int", "to_a", "to_h",
49
+ "to_sym", "to_io", "to_path", "to_regexp", "coerce", "method_missing",
50
+ # Hooks Ruby itself calls WITH an argument, which it finds whether they
51
+ # are public or private (so Object.instance_methods never lists the
52
+ # private ones): `record.dup` / `clone` call initialize_copy(orig)
53
+ # (initialize_dup / initialize_clone on the way), and defining, removing
54
+ # or undefining a singleton method (`def record.x`) calls
55
+ # singleton_method_added / _removed / _undefined. A zero-arity column
56
+ # reader in their place raises ArgumentError at that call. These are
57
+ # reserved, not merely shadowing: the problem is Ruby's call, not a
58
+ # method the caller could have wanted. script/check-reserved-names
59
+ # fails when one of Ruby's private hooks is missing here.
60
+ "initialize_copy", "initialize_dup", "initialize_clone",
61
+ "singleton_method_added", "singleton_method_removed",
62
+ "singleton_method_undefined",
63
+ # The one Kernel method the model calls on implicit self (Model#save!
64
+ # and #reload would dispatch `raise RecordInvalid, ...` to the
65
+ # zero-arity reader), and its alias. No other private Kernel method is
66
+ # reserved: `open`, `format`, `load`, `print`, `select`, `test` are
67
+ # ordinary column names and nothing in the generated class calls them.
68
+ "raise", "fail"
69
+ ]
70
+
71
+ # The rest of Object's public instance methods: a column so named shadows
72
+ # them on its model (`record.display`, `record.tap` are the column), but
73
+ # neither the generated class nor the framework calls any of them on a
74
+ # record, so the generator accepts the column and writes a note into the
75
+ # generated file instead of refusing an existing schema. The scaffold
76
+ # accepts them the same way, with a note on stdout (Scaffold.shadow_notes):
77
+ # `payment.method` is the field, which its views and controller call.
78
+ SHADOWING_COLUMN_NAMES = [
79
+ "object_id", "__id__", "send", "__send__", "public_send", "freeze",
80
+ "display", "method", "methods", "public_method", "public_methods",
81
+ "private_methods", "protected_methods", "singleton_class",
82
+ "singleton_method", "singleton_methods", "define_singleton_method",
83
+ "remove_instance_variable", "instance_variable_get",
84
+ "instance_variable_set", "instance_variables", "instance_eval",
85
+ "instance_exec", "dup", "clone", "tap", "yield_self", "itself",
86
+ "extend", "enum_for", "to_enum"
87
+ ]
88
+
89
+ # Ruby keywords a column could be spelled like: `@end` / `def class` are
90
+ # not shapes worth supporting. The generator reads such a column as
91
+ # `<column>_column` (ModelsEmitter.reader_name), like a reserved name;
92
+ # the scaffold refuses them (its views call the reader by name). The
93
+ # uppercase ones are reachable since column? accepts capitals. `defined`
94
+ # is not here: the keyword is `defined?`, which column? rejects, and
95
+ # `defined` is an ordinary method name (`attr_accessor :defined` works).
96
+ RUBY_KEYWORDS = [
97
+ "alias", "and", "begin", "break", "case", "class", "def", "do",
98
+ "else", "elsif", "end", "ensure", "false", "for", "if", "in",
99
+ "module", "next", "nil", "not", "or", "redo", "rescue", "retry",
100
+ "return", "self", "super", "then", "true", "undef", "unless", "until",
101
+ "when", "while", "yield",
102
+ "BEGIN", "END", "__ENCODING__", "__FILE__", "__LINE__"
103
+ ]
104
+
105
+ # /\A[A-Za-z_][A-Za-z0-9_]*\z/ spelled out: what Ruby needs of a method
106
+ # name, so a schema with camelCase or mixed-case columns (`createdAt`,
107
+ # `userId`, a legacy SQLite table) generates under its own name, not a
108
+ # renamed one; `attr_accessor :createdAt` and `attr_accessor :Title` both
109
+ # compile and dispatch under Spinel (probed on 2026.09.12). The
110
+ # scaffold, which chooses new names, keeps the stricter snake_case rule
111
+ # (CLI::Templates.identifier?). A flag rather than a `return` inside
112
+ # the block, like Templates.identifier?: a `return` from an each_char
113
+ # block does leave the method under Spinel (probed), but the flag needs
114
+ # no such guarantee.
115
+ def self.column?(name)
116
+ return false if name.empty?
117
+
118
+ ok = true
119
+ first = true
120
+ name.each_char do |ch|
121
+ letter = (ch >= "a" && ch <= "z") || (ch >= "A" && ch <= "Z")
122
+ digit = ch >= "0" && ch <= "9"
123
+ ok = false unless letter || ch == "_" || (digit && !first)
124
+ first = false
125
+ end
126
+ ok
127
+ end
128
+
129
+ # The snake_case subset of column?: lowercase only, and a leading `_`
130
+ # only when allowed. CLI::Templates.identifier? (app, resource and field
131
+ # names: letter-first) and Template::Lexer.identifier? (template locals
132
+ # and block parameters: `_x` allowed) are both this predicate, so the two
133
+ # cannot drift. A positional default rather than a keyword argument: this
134
+ # file compiles under Spinel and keeps to the shapes it already uses.
135
+ def self.snake_case?(word, leading_underscore = true)
136
+ column?(word) && word == word.downcase && (leading_underscore || !word.start_with?("_"))
137
+ end
138
+
139
+ def self.keyword?(name)
140
+ RUBY_KEYWORDS.include?(name)
141
+ end
142
+
143
+ def self.reserved_column?(name)
144
+ RESERVED_COLUMN_NAMES.include?(name)
145
+ end
146
+
147
+ def self.shadowing_column?(name)
148
+ SHADOWING_COLUMN_NAMES.include?(name)
149
+ end
150
+
151
+ # The one definition of "a name the scaffold must not invent": "" when
152
+ # `name` may be chosen, otherwise why not -- "keyword" (RUBY_KEYWORDS) or
153
+ # "reserved" (RESERVED_COLUMN_NAMES): the two kinds the generator RENAMES,
154
+ # which the scaffold cannot accept because its views and controller call
155
+ # the reader by the field's name. A SHADOWING_COLUMN_NAMES entry is not
156
+ # refused: the generator keeps its name with a note, and so does the
157
+ # scaffold (shadowing_column?). The scaffold asks here at every place it
158
+ # takes a name from the user (field column, references reader, resource
159
+ # singular) and raises the message for the reason, so a new list of names
160
+ # is added to this method once, not to each call site. A String, not nil
161
+ # or a Symbol, so the return type is the same on every path (NOTES rules
162
+ # 10/34); a keyword that is also listed in a column array reports
163
+ # "keyword" first.
164
+ def self.unusable_reason(name)
165
+ return "keyword" if keyword?(name)
166
+ return "reserved" if reserved_column?(name)
167
+
168
+ ""
169
+ end
170
+ end
171
+ end
@@ -1,5 +1,5 @@
1
1
  module Cybertrain
2
- VERSION = "0.2.0"
2
+ VERSION = "0.2.1"
3
3
 
4
4
  # Where `cybertrain new` points an app's spin.toml by default: the git tag
5
5
  # "v#{VERSION}" of this repository. Kept here because the gem (CRuby) and
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cybertrain
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Saeki Mototsune
@@ -22,6 +22,7 @@ extensions: []
22
22
  extra_rdoc_files: []
23
23
  files:
24
24
  - README.md
25
+ - cybertrain/app_name.rb
25
26
  - cybertrain/cli.rb
26
27
  - cybertrain/cli/build.rb
27
28
  - cybertrain/cli/new_app.rb
@@ -29,6 +30,7 @@ files:
29
30
  - cybertrain/cli/templates.rb
30
31
  - cybertrain/cli/toolchain.rb
31
32
  - cybertrain/generator/inflector.rb
33
+ - cybertrain/ident.rb
32
34
  - cybertrain/version.rb
33
35
  - exe/cybertrain
34
36
  homepage: https://github.com/saeki-mototsune/cybertrain