ts_schema_spec 0.5.4

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: c8202e8af0fa27c82203f869cd593208ee48dd95bfe23f6ab53083c47442fc47
4
+ data.tar.gz: 3afcb839cc834657613896897ba454234a07facc8b0ffc3a9e8ce54abea5dffb
5
+ SHA512:
6
+ metadata.gz: a4608416aebf93c1125f9df04126b44479f52bf95e7605f924aa14af04b18fbafef30f39e3cf0ff16c38b948d9f970b37fb552b867cb9725764d569b83cdb1d4
7
+ data.tar.gz: 8ff1849e182079b473b130922e9315dfc92766c5d84bad559d52e715c01e3ffbc86effafa971b445f2fc2ee307a6819be81f45266d9441c07333c0f658b38600
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Vernon Coffey
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,288 @@
1
+ # ts_schema_spec
2
+
3
+ Add tests to your Ruby test suite that assert a JSON payload generated in Ruby
4
+ matches the TypeScript type of the consumer.
5
+
6
+ Built for RSpec, but could be ported to other test frameworks (Minitest, etc.).
7
+
8
+ `react_component_props`, an optional React-specific helper, reads props out of
9
+ rendered mounts.
10
+
11
+ React is the case this gem was built for, not a requirement. `match_schema`
12
+ checks any payload against any exported TypeScript type, so a Stimulus
13
+ controller or a plain fetch client works the same way.
14
+
15
+ ## Install
16
+
17
+ ```ruby
18
+ # Gemfile
19
+ gem "ts_schema_spec", github: "nitidbit/ts_schema_spec", tag: "v0.5.3", group: :test
20
+ ```
21
+
22
+ This gem requires ts-json-schema-generator, resolved from your project's
23
+ `node_modules`:
24
+
25
+ ```
26
+ npm install --save-dev ts-json-schema-generator
27
+ ```
28
+
29
+ Add the following to your test suite:
30
+
31
+ ```ruby
32
+ # spec/rails_helper.rb
33
+ require "ts_schema_spec/rspec"
34
+ require "ts_schema_spec/react_component_props" # optional, for parsing props from rendered HTML. See below.
35
+
36
+ RSpec.configure do |config|
37
+ config.include TsSchemaSpec::ReactComponentProps
38
+ end
39
+ ```
40
+
41
+ ### Path aliases
42
+
43
+ If your TypeScript imports through aliases (`@/components/Foo`), point the
44
+ generator at your tsconfig:
45
+
46
+ ```ruby
47
+ # spec/rails_helper.rb
48
+ TsSchemaSpec.configure do |config|
49
+ config.tsconfig = Rails.root.join("tsconfig.json").to_s
50
+ config.generator_args = [] # anything else to pass through
51
+ end
52
+ ```
53
+
54
+ **This is not optional decoration.** An import the generator cannot resolve
55
+ does not fail — it becomes an empty schema, and an empty schema validates
56
+ anything:
57
+
58
+ ```json
59
+ "role": {}
60
+ ```
61
+
62
+ So an aliased type without a tsconfig gives you a green spec that accepts a
63
+ string, a number or null where an object was declared. If any type you assert
64
+ on imports through an alias, set this.
65
+
66
+ ### The agent skill
67
+
68
+ The gem ships the skill that teaches an agent when one of these specs is owed
69
+ and how to write it. Claude Code reads skills from the working tree, so it has
70
+ to be copied in and committed:
71
+
72
+ ```
73
+ RAILS_ENV=test bundle exec rake ts_schema_spec:install_skill
74
+ ```
75
+
76
+ `RAILS_ENV=test` is required when the gem is in `group: :test`, as above.
77
+
78
+ It lands in `.claude/skills/react-prop-type-spec/SKILL.md`, stamped with the
79
+ gem version. To keep the skill in sync with the gem version:
80
+
81
+ ```ruby
82
+ # spec/ts_schema_spec_skill_spec.rb
83
+ it "has the skill matching the installed gem" do
84
+ expect { TsSchemaSpec::Skill.check!(Rails.root) }.to_not raise_error
85
+ end
86
+ ```
87
+
88
+ ## Use
89
+
90
+ ### A JSON endpoint
91
+
92
+ ```ruby
93
+ it "matches AccountPayload" do
94
+ create(:account, :with_roles)
95
+ create(:account, :unassigned)
96
+
97
+ get :index, format: :json
98
+
99
+ expect(response).to be_successful
100
+ expect(response.parsed_body["accounts"])
101
+ .to match_schema("app/javascript/types/account.ts", "AccountPayload")
102
+ end
103
+ ```
104
+
105
+ ### Props from an HTML mount
106
+
107
+ `react_component_props` parses the rendered `data-react-props` attributes and
108
+ returns **an array** — one entry per mount of that component on the page. It
109
+ needs `render_views`.
110
+
111
+ It assumes [react-rails](https://github.com/reactjs/react-rails) conventions:
112
+ `data-react-class` and `data-react-props` on the mount element. A namespaced
113
+ class matches on its trailing segment, so `admin/SidebarNav` and
114
+ `Admin.SidebarNav` both answer to `"SidebarNav"`.
115
+
116
+ Other integrations mount differently — react_on_rails uses its own attributes
117
+ — and against those it finds nothing and reports an empty collection rather
118
+ than a missing-attribute error. Supporting another convention is a small
119
+ change to one file; open a PR if you need it.
120
+
121
+ ```ruby
122
+ describe "the props handed to RoleMatrix" do
123
+ render_views
124
+
125
+ role_matrix = "app/javascript/components/roles/RoleMatrix.tsx"
126
+
127
+ it "matches RoleMatrixProps" do
128
+ account = create(:account, :with_roles)
129
+
130
+ get :show, params: { id: account.id }
131
+
132
+ expect(response).to be_successful
133
+ props = react_component_props("RoleMatrix")
134
+ expect(props).to match_schema(role_matrix, "RoleMatrixProps")
135
+ end
136
+ end
137
+ ```
138
+
139
+ `match_schema` accepts a hash or an array of hashes. Given an array, it
140
+ validates every item and fails on an empty one — in both directions, so
141
+ `to_not match_schema` does not pass vacuously either. A page that stopped
142
+ rendering the component fails rather than passing silently. The alternative
143
+ construction, `expect(props).to all match_schema(...)`, would pass on an empty
144
+ array.
145
+
146
+ ## API
147
+
148
+ | Call | Returns |
149
+ | --------------------------------------- | ----------------------------------------------------------- |
150
+ | `TsSchemaSpec.schema_for(path, type)` | a `JSONSchemer` schema scoped to that exported type |
151
+ | `match_schema(path, type)` | matcher; validates a hash, or every item of an array |
152
+ | `react_component_props(name[, html])` | array of props hashes, one per mount |
153
+ | `TsSchemaSpec::Skill.check!(root)` | raises if the installed skill is stale or missing |
154
+ | `TsSchemaSpec.configure` | sets `tsconfig` and extra generator arguments |
155
+ | `TsSchemaSpec.clear_cache!` | drops the generated-schema cache |
156
+
157
+ `path` is resolved from wherever the suite runs, which is the Rails root in
158
+ practice. Name the type at the assertion, so an example says which type it is
159
+ checking. When several examples read the same source, bind the path to a local
160
+ variable or a `let` — a constant assigned inside a `describe` block is global,
161
+ so two spec files that both use one will collide.
162
+
163
+ `schema_for` stays public, but is not needed when using the RSpec matcher,
164
+ which calls it internally.
165
+
166
+ ## What a failure looks like
167
+
168
+ ```
169
+ expected the payload to match Role (app/javascript/types/role.ts), but:
170
+ - /id: value at `/id` is not a number
171
+ - /created_at: object property at `/created_at` is a disallowed additional property
172
+ - (root): object at root is missing required properties: shortcode
173
+
174
+ Payload was:
175
+ {
176
+ "id": "1",
177
+ "name": "Nurse",
178
+ "created_at": "2026-09-11"
179
+ }
180
+ ```
181
+
182
+ ## Best practices
183
+
184
+ **Build records that cover the variations.** One response only exercises the
185
+ branches it took. When the type has optional fields, enum values or nullable
186
+ associations, create as many records, with as many different traits, as it
187
+ takes to put those branches in the payload.
188
+
189
+ **Tighten the type first.** A schema is only as strong as the type it comes
190
+ from, and an all-optional type is satisfied by `{}` — asserting against one
191
+ passes while catching nothing, which is worse than no spec, because it reads
192
+ as coverage.
193
+
194
+ - A field the server always sends: **required**.
195
+ - A field that can be null: `string | null`, not `string?`.
196
+ - A field with a fixed set of values: a literal union
197
+ (`"draft" | "published"`), not `string` — which catches a typo and a value
198
+ the consumer was never taught about.
199
+ - An index signature or `Record<string, unknown>`: declare the keys the
200
+ component actually reads, required, alongside it.
201
+
202
+ A literal union only stays honest while it tracks the Ruby enum it mirrors,
203
+ which needs some way of sharing constants from Ruby into TypeScript — a
204
+ generator, a shared JSON file, whatever suits your repo. That is outside this
205
+ gem's scope, but with one in place, and a record for each value in the
206
+ example, a drifted union fails here rather than in the browser.
207
+
208
+ **Pass the whole collection.** `match_schema` validates every item and fails
209
+ on an empty one. Avoid `expect(props).to all match_schema(...)`, which
210
+ passes on an empty array, hiding an issue with generation.
211
+
212
+ **Assert the response is successful first.** Otherwise a redirect or a 500
213
+ arrives as a schema failure, and you debug the wrong thing.
214
+
215
+ **Let the matcher do the shape checking.** Hand-written field assertions
216
+ alongside it duplicate what the type already says, and go stale separately.
217
+
218
+ **When it fails, fix Rails.** The type is the consumer's contract: if the
219
+ component needs a field, the payload is wrong. Loosen the type only when the
220
+ component genuinely does not need what it declares — loosening is always the
221
+ quicker route to green, and it is how this stops catching anything.
222
+
223
+ ## Gotchas
224
+
225
+ **Undeclared keys fail.** `ts-json-schema-generator` emits
226
+ `additionalProperties: false`, so a payload carrying a key the TypeScript does
227
+ not declare is an error, not a warning. That is deliberate — it catches Rails
228
+ sending something nobody typed — but expect it when an `as_json` emits
229
+ timestamps the React side ignores. Fix by declaring the field or narrowing the
230
+ `only:`.
231
+
232
+ ## Cost
233
+
234
+ One `npx ts-json-schema-generator` run per source file. Generated schemas are
235
+ cached in memory, keyed by the file and the generator arguments, so reading
236
+ three types out of one `.ts` costs one run rather than three, and changing
237
+ `TsSchemaSpec.configure` regenerates rather than serving a stale schema.
238
+
239
+ The cache lives in the process, so parallel test workers each pay for it once.
240
+
241
+ ## Troubleshooting
242
+
243
+ | Symptom | Cause |
244
+ | ---------------------------------------------------- | -------------------------------------------------------------- |
245
+ | `GenerationError: ... Is it exported?` | the type has no `export`, or the name is misspelled |
246
+ | `GenerationError` listing a rerunnable command | run it — the generator's own stderr is in the message |
247
+ | passes against an obviously wrong payload | the type is all-optional, or an unresolved import became `{}` |
248
+ | `could not run npx ts-json-schema-generator` | the generator is not in your `node_modules` |
249
+ | `disallowed additional property` | see above — the payload sends what TypeScript does not declare |
250
+ | `has no data-react-props attribute` | hand-written markup, or a mount from another integration |
251
+
252
+ ## What this can't catch
253
+
254
+ Worth knowing before you rely on it.
255
+
256
+ **Coverage is discipline-dependent.** This protects the actions somebody wrote
257
+ a spec for; an uncovered endpoint is exactly as exposed as before. That is what
258
+ the skill is for, and it is convention rather than enforcement.
259
+
260
+ **It only checks what TypeScript declares.** If Rails *intends* to send a field
261
+ nobody typed — say `as_json(only:)` carrying a misspelled attribute, which
262
+ Rails drops silently — no generated schema requires it, so nothing fails. A
263
+ structured serializer catches that class of mistake; this does not.
264
+
265
+ ## Alternatives
266
+
267
+ An alternate methodology is to generate the TypeScript types from Ruby.
268
+
269
+ [Typelizer](https://typelizer.dev/) and
270
+ [types_from_serializers](https://github.com/ElMassimo/types_from_serializers)
271
+ make Ruby canonical and generate the types, so drift becomes structurally
272
+ impossible rather than something you test for. That beats this.
273
+
274
+ They need serialization to exist somewhere nameable — they introspect Alba,
275
+ AMS, Oj, Panko or oj_serializers. An app that is plain `as_json` and
276
+ `render json:`, deciding payload shapes inline at each call site, has no
277
+ artifact for a generator to read. This gem is for that case: if a shape only
278
+ exists at its call site, the only way to learn it is to run the action and
279
+ look.
280
+
281
+ The other camp — [json_matchers](https://github.com/thoughtbot/json_matchers),
282
+ committee, rswag — validates against a hand-maintained JSON Schema file. That
283
+ is a third artifact to keep in sync with both sides, which is the same drift
284
+ problem relocated.
285
+
286
+ One gap stays open whichever you pick: `react_component` props. Typelizer
287
+ types serializer output and knows nothing about the outer props object a view
288
+ assembles, so nothing generated from your serializers covers it.
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TsSchemaSpec
4
+ # Repo-level generator settings. `tsconfig` matters more than it looks: an
5
+ # import the generator cannot resolve becomes an empty schema, which
6
+ # validates anything, so a repo using path aliases needs this set or its
7
+ # aliased types silently assert nothing.
8
+ class Config
9
+ attr_accessor :tsconfig, :generator_args
10
+
11
+ def initialize
12
+ @generator_args = []
13
+ end
14
+
15
+ def to_args
16
+ args = []
17
+ args += ["--tsconfig", tsconfig.to_s] if tsconfig
18
+ args + Array(generator_args)
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "open3"
5
+
6
+ module TsSchemaSpec
7
+ module Generator
8
+ COMMAND = %w[npx ts-json-schema-generator].freeze
9
+
10
+ class << self
11
+ def generate(source)
12
+ command = command_for(source)
13
+ stdout, stderr, status = Open3.capture3(*command)
14
+
15
+ unless status.success?
16
+ raise GenerationError, failure(command, stderr)
17
+ end
18
+
19
+ JSON.parse(stdout)
20
+ rescue JSON::ParserError => e
21
+ raise GenerationError, failure(command, "#{e.message}\n\n#{stderr}")
22
+ rescue Errno::ENOENT
23
+ raise GenerationError, missing_generator(command)
24
+ end
25
+
26
+ def command_for(source)
27
+ COMMAND + ["--path", source.to_s, "--no-type-check"] + TsSchemaSpec.config.to_args
28
+ end
29
+
30
+ private
31
+
32
+ # npx's own phrasing for "the package is not installed". Anything looser
33
+ # — a bare "not found" — swallows the generator's real diagnostic.
34
+ MISSING_PACKAGE = /missing packages|could not determine executable|npx.*not found|not found.*npx/i
35
+
36
+ def missing_generator(command, stderr = nil)
37
+ message = <<~MSG
38
+ could not run #{COMMAND.join(" ")}.
39
+
40
+ The generator parses your app's TypeScript, so it resolves from your
41
+ node_modules rather than from the gem:
42
+
43
+ npm install --save-dev ts-json-schema-generator
44
+
45
+ Rerun: #{command.join(" ")}
46
+ MSG
47
+ return message if stderr.to_s.strip.empty?
48
+
49
+ "#{message}\n#{stderr.strip}\n"
50
+ end
51
+
52
+ def failure(command, stderr)
53
+ return missing_generator(command, stderr) if stderr.to_s.match?(MISSING_PACKAGE)
54
+
55
+ <<~MSG
56
+ ts-json-schema-generator failed.
57
+
58
+ Rerun: #{command.join(" ")}
59
+
60
+ #{stderr.strip}
61
+ MSG
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ module TsSchemaSpec
6
+ class Railtie < Rails::Railtie
7
+ rake_tasks do
8
+ load File.expand_path("tasks/ts_schema_spec.rake", __dir__)
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "nokogiri"
5
+
6
+ require_relative "../ts_schema_spec"
7
+
8
+ module TsSchemaSpec
9
+ # Pulls props out of react-rails mount points in rendered HTML, so an HTML
10
+ # action's props can be schema-checked the same way a JSON payload is.
11
+ # Needs `render_views`.
12
+ module ReactComponentProps
13
+ def react_component_props(component_name, html = nil)
14
+ html ||= response.body
15
+
16
+ Nokogiri::HTML(html).css("[data-react-class]").filter_map do |node|
17
+ next unless react_class_matches?(node["data-react-class"], component_name)
18
+
19
+ parse_props(node, component_name)
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ # react-rails namespaces a component either way: "admin/SidebarNav" from
26
+ # the asset path, "Admin.SidebarNav" from the global its UJS resolves.
27
+ NAMESPACE_SEPARATOR = %r{[./]}
28
+
29
+ def react_class_matches?(react_class, component_name)
30
+ react_class == component_name ||
31
+ react_class.to_s.split(NAMESPACE_SEPARATOR).last == component_name
32
+ end
33
+
34
+ def parse_props(node, component_name)
35
+ raw = node["data-react-props"]
36
+ raise Error, "the #{component_name} mount has no data-react-props attribute" if raw.nil?
37
+
38
+ JSON.parse(raw)
39
+ rescue JSON::ParserError => e
40
+ raise Error, "could not parse data-react-props for #{component_name}: #{e.message}"
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "rspec/expectations"
5
+
6
+ require_relative "../ts_schema_spec"
7
+
8
+ module TsSchemaSpec
9
+ # A payload is often a collection — every mount of a component on the page,
10
+ # every record in an index response. Validating each item against an object
11
+ # schema is the common case, so the matcher handles it rather than leaving
12
+ # callers to reach for `all`, which passes vacuously on an empty collection
13
+ # and so silently asserts nothing.
14
+ module Matching
15
+ EMPTY = :empty_collection
16
+
17
+ BRANCHES = %w[anyOf oneOf allOf].freeze
18
+
19
+ class << self
20
+ def errors(schema, actual)
21
+ return schema.validate(actual).to_a unless collection?(schema, actual)
22
+ return [EMPTY] if actual.empty?
23
+
24
+ actual.each_with_index.flat_map do |item, index|
25
+ schema.validate(item).to_a.map do |error|
26
+ error.merge("data_pointer" => "/#{index}#{error["data_pointer"]}")
27
+ end
28
+ end
29
+ end
30
+
31
+ def empty?(errors)
32
+ errors == [EMPTY]
33
+ end
34
+
35
+ private
36
+
37
+ def collection?(schema, actual)
38
+ actual.is_a?(Array) && !describes_array?(schema)
39
+ end
40
+
41
+ # A type can reach "array" through a union or an alias, so the literal
42
+ # `"type"` of the schema is not enough: `Role[] | null` and
43
+ # `type Roles = RoleList` both describe an array without saying so here.
44
+ def describes_array?(schema, value = schema.value, seen = [])
45
+ return false unless value.is_a?(Hash)
46
+
47
+ if (pointer = value["$ref"])
48
+ return false if seen.include?(pointer)
49
+
50
+ return describes_array?(schema, resolve(schema, pointer), seen + [pointer])
51
+ end
52
+
53
+ return true if Array(value["type"]).include?("array")
54
+
55
+ BRANCHES.any? do |branch|
56
+ Array(value[branch]).any? { |option| describes_array?(schema, option, seen) }
57
+ end
58
+ end
59
+
60
+ def resolve(schema, pointer)
61
+ schema.ref(pointer).value
62
+ rescue StandardError
63
+ nil
64
+ end
65
+ end
66
+ end
67
+ end
68
+
69
+ RSpec::Matchers.define :match_schema do |source, type|
70
+ def validation_errors(actual, source, type)
71
+ schema = TsSchemaSpec.schema_for(source, type)
72
+ @errors = TsSchemaSpec::Matching.errors(schema, actual)
73
+ end
74
+
75
+ # Worded for both directions: this message is what an empty collection gets
76
+ # whichever way the assertion was written.
77
+ def empty_collection_message
78
+ <<~MSG
79
+ expected a non-empty collection, but it was empty, so nothing was
80
+ validated.
81
+
82
+ Usually the component was not rendered on the page, or no records
83
+ existed for the endpoint to serialize. If an empty result is what you
84
+ meant to assert, use `be_empty` or `eq([])` — match_schema on an empty
85
+ collection checks nothing.
86
+ MSG
87
+ end
88
+
89
+ match do |actual|
90
+ validation_errors(actual, source, type).empty?
91
+ end
92
+
93
+ # An empty collection fails either way round: negating the matcher would
94
+ # otherwise turn the vacuous pass back on.
95
+ match_when_negated do |actual|
96
+ errors = validation_errors(actual, source, type)
97
+ errors.any? && !TsSchemaSpec::Matching.empty?(errors)
98
+ end
99
+
100
+ failure_message do |actual|
101
+ next empty_collection_message if TsSchemaSpec::Matching.empty?(@errors)
102
+
103
+ details = @errors.map do |error|
104
+ pointer = error["data_pointer"]
105
+ pointer = "(root)" if pointer.nil? || pointer.empty?
106
+ " - #{pointer}: #{error["error"]}"
107
+ end
108
+
109
+ <<~MSG
110
+ expected the payload to match #{type} (#{source}), but:
111
+ #{details.join("\n")}
112
+
113
+ Payload was:
114
+ #{JSON.pretty_generate(actual)}
115
+ MSG
116
+ end
117
+
118
+ failure_message_when_negated do |actual|
119
+ next empty_collection_message if TsSchemaSpec::Matching.empty?(@errors)
120
+
121
+ "expected the payload not to match #{type} (#{source}), but it did:\n#{JSON.pretty_generate(actual)}"
122
+ end
123
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ require_relative "../ts_schema_spec"
6
+
7
+ module TsSchemaSpec
8
+ # The skill is instructions an agent follows to write these specs, so a
9
+ # silently outdated copy teaches the wrong API. The copy carries the gem
10
+ # version and `check!` fails when the two diverge.
11
+ module Skill
12
+ NAME = "react-prop-type-spec"
13
+ INSTALL_PATH = ".claude/skills/#{NAME}/SKILL.md"
14
+ SOURCE_PATH = File.expand_path("../../skills/#{NAME}/SKILL.md", __dir__)
15
+ STAMP = "ts_schema_spec_version"
16
+
17
+ class << self
18
+ def install(root = Dir.pwd)
19
+ destination = File.join(root, INSTALL_PATH)
20
+ FileUtils.mkdir_p(File.dirname(destination))
21
+ File.write(destination, stamped(File.read(SOURCE_PATH)))
22
+ destination
23
+ end
24
+
25
+ def installed_version(root = Dir.pwd)
26
+ contents = File.read(File.join(root, INSTALL_PATH))
27
+ contents[/^#{STAMP}: (.+)$/, 1]
28
+ rescue Errno::ENOENT
29
+ nil
30
+ end
31
+
32
+ def check!(root = Dir.pwd)
33
+ found = installed_version(root)
34
+ return true if found == TsSchemaSpec::VERSION
35
+
36
+ raise Error, <<~MSG
37
+ The #{NAME} skill in #{INSTALL_PATH} is #{found ? "stale (#{found})" : "not installed"};
38
+ the gem is #{TsSchemaSpec::VERSION}.
39
+
40
+ Run: bundle exec rake ts_schema_spec:install_skill
41
+ MSG
42
+ end
43
+
44
+ private
45
+
46
+ def stamped(contents)
47
+ body = contents.sub(/^#{STAMP}: .*\n/, "")
48
+ stamped = body.sub(/\A---\n/, "---\n#{STAMP}: #{TsSchemaSpec::VERSION}\n")
49
+
50
+ if stamped == body
51
+ raise Error, <<~MSG
52
+ #{SOURCE_PATH} does not open with YAML frontmatter, so there is
53
+ nowhere to put the version stamp that check! reads. Installing it
54
+ unstamped would fail that check for good.
55
+ MSG
56
+ end
57
+
58
+ stamped
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :ts_schema_spec do
4
+ desc "Copy the react-prop-type-spec agent skill into .claude/skills"
5
+ task :install_skill do
6
+ require "ts_schema_spec/skill"
7
+
8
+ destination = TsSchemaSpec::Skill.install(defined?(Rails) ? Rails.root.to_s : Dir.pwd)
9
+ puts "Installed #{TsSchemaSpec::Skill::NAME} v#{TsSchemaSpec::VERSION} to #{destination}"
10
+ puts "Commit it — Claude Code reads skills from the working tree."
11
+ end
12
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TsSchemaSpec
4
+ VERSION = "0.5.4"
5
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json_schemer"
4
+
5
+ require_relative "ts_schema_spec/version"
6
+ require_relative "ts_schema_spec/config"
7
+ require_relative "ts_schema_spec/generator"
8
+
9
+ require_relative "ts_schema_spec/railtie" if defined?(Rails::Railtie)
10
+
11
+ module TsSchemaSpec
12
+ class Error < StandardError; end
13
+ class GenerationError < Error; end
14
+
15
+ class << self
16
+ def schema_for(source, type)
17
+ validate_arguments!(source, type)
18
+ document = document_for(source)
19
+
20
+ unless document.fetch("definitions", {}).key?(type)
21
+ raise GenerationError, <<~MSG
22
+ #{source} does not define #{type.inspect}.
23
+
24
+ Generated definitions: #{document.fetch("definitions", {}).keys.sort.join(", ")}
25
+
26
+ Is it exported?
27
+ MSG
28
+ end
29
+
30
+ JSONSchemer.schema(document).ref("#/definitions/#{type}")
31
+ end
32
+
33
+ def clear_cache!
34
+ documents.clear
35
+ end
36
+
37
+ def config
38
+ @config ||= Config.new
39
+ end
40
+
41
+ def configure
42
+ yield(config)
43
+ clear_cache!
44
+ end
45
+
46
+ def reset_config!
47
+ @config = nil
48
+ clear_cache!
49
+ end
50
+
51
+ private
52
+
53
+ PATH_LIKE = %r{/|\.tsx?\z}
54
+
55
+ def validate_arguments!(source, type)
56
+ raise ArgumentError, "expects a path and a type name, e.g. schema_for(\"app/javascript/Foo.tsx\", \"FooProps\")" if type.nil?
57
+
58
+ return unless type.to_s.match?(PATH_LIKE) && !source.to_s.match?(PATH_LIKE)
59
+
60
+ raise ArgumentError, <<~MSG
61
+ the arguments look reversed: #{source.inspect} was given as the source
62
+ file and #{type.inspect} as the type name. Expected (path, type).
63
+ MSG
64
+ end
65
+
66
+ def document_for(source)
67
+ documents[[File.expand_path(source.to_s), config.to_args]] ||= Generator.generate(source)
68
+ end
69
+
70
+ def documents
71
+ @documents ||= {}
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,130 @@
1
+ ---
2
+ name: react-prop-type-spec
3
+ description: >
4
+ Write or update RSpec tests that use match_schema to verify a Rails
5
+ endpoint's payload matches the TypeScript type that consumes it. TRIGGER
6
+ automatically (without being asked) whenever data crossing from Ruby to
7
+ TypeScript is added or changed: adding a controller action; changing
8
+ serialization in an existing one (helper method, as_json fields, included
9
+ associations); adding or renaming a key in a render json: response or in
10
+ props handed to a component; converting a Rails-mounted component from .jsx
11
+ to .tsx; deleting a component's propTypes; or rendering an already-typed
12
+ component from an action that has no match_schema spec. React is the common
13
+ case, not a requirement — a Stimulus controller or a plain fetch client
14
+ reading the payload counts the same. Invoked as /react-prop-type-spec.
15
+ ---
16
+
17
+ A spec is owed any time data is handed from Ruby to TypeScript — not only when
18
+ something changes, and including where there is no Ruby diff at all. Two rules
19
+ keep that from multiplying:
20
+
21
+ - **Repeated mounts of one component are a single example.**
22
+ `react_component_props` returns every mount and `match_schema` checks each.
23
+ - **Assert on the component Rails mounts.** A child receiving props from its
24
+ parent is covered transitively; use the parent's props type.
25
+
26
+ ## Step 0 — Already covered?
27
+
28
+ Covered = the spec for **the action rendering it** has a `match_schema` example
29
+ on that component. A sibling component, or the same component from another
30
+ action, is not coverage. Covered → stop.
31
+
32
+ ## Step 1 — Find the TypeScript consumer
33
+
34
+ - **JSON** (`render json:`): find the `fetch`/`axios` call and the type it
35
+ parses into.
36
+ - **HTML** (`react_component`): find the view, the component it mounts, and its
37
+ props interface.
38
+
39
+ React is the common case, not a requirement — a payload read by a Stimulus
40
+ controller, a plain fetch client or any other TypeScript module is checked the
41
+ same way, via `response.parsed_body`. Only `react_component_props` is
42
+ React-specific.
43
+
44
+ No TypeScript consumer at all → exit the skill.
45
+
46
+ ## Step 2 — Export the type
47
+
48
+ `ts-json-schema-generator` only targets exported types. Add `export` if it is
49
+ missing.
50
+
51
+ ## Step 3 — Tighten the type
52
+
53
+ An all-optional type is satisfied by `{}`, so asserting on one asserts nothing.
54
+
55
+ - A field the server always sends: **required**.
56
+ - A field the server can send as null: `string | null`, not `string?`.
57
+ - A field with a fixed set of values: a literal union (`"draft" | "published"`),
58
+ not `string`.
59
+ - An index signature or `Record<string, unknown>`: keep the keys the component
60
+ actually reads **required alongside it**.
61
+
62
+ This edits the app's TypeScript rather than the test, so say that you did it. If
63
+ it cannot be tightened now, report which fields are unconstrained rather than
64
+ implying the spec covers them.
65
+
66
+ ## Step 4 — Write the test
67
+
68
+ | Action | Data source |
69
+ | ------ | ----------- |
70
+ | `render json:` | `response.parsed_body["key"]` |
71
+ | `react_component` | `react_component_props("ComponentName")` |
72
+
73
+ `react_component_props` returns **an array**, one entry per mount, and needs
74
+ `render_views`. Pass it straight to `match_schema`: it validates every entry
75
+ and fails on an empty collection. Never wrap it in `all`, which iterates zero
76
+ times on an empty array and asserts nothing.
77
+
78
+ ```ruby
79
+ describe "the props handed to MyComponent" do
80
+ render_views
81
+
82
+ it "matches MyComponentProps" do
83
+ record = create(:factory_name, trait_a: true)
84
+ create(:factory_name, :some_trait)
85
+
86
+ get :show, params: { id: record.id }
87
+
88
+ expect(response).to be_successful
89
+ props = react_component_props("MyComponent")
90
+ expect(props).to match_schema("app/javascript/MyComponent.tsx", "MyComponentProps")
91
+ end
92
+ end
93
+ ```
94
+
95
+ Name the file and type at the assertion. When several examples read the same
96
+ source, bind the path to a constant or a let variable.
97
+
98
+ Rules:
99
+
100
+ - Build multiple records with different traits, so optional fields, enum values
101
+ and nil associations are actually exercised. Use existing factory traits.
102
+ - Assert `response` is successful before asserting shape.
103
+ - Let `match_schema` do the shape checking; no hand-written field assertions.
104
+ - Several components in one action: an example each, or one example marked
105
+ `:aggregate_failures` if rendering the page is expensive — without it the
106
+ first mismatch hides the rest.
107
+ - Do not write a spec that only checks `response.status`, and do not duplicate
108
+ an existing `match_schema` for the same action.
109
+ - **When it fails, fix Rails.** The type is the consumer's contract: if the
110
+ component needs a field, the payload is wrong. Loosening the type is always
111
+ the quicker route to green, and it is how this stops catching anything.
112
+
113
+ ## Step 5 — Run it
114
+
115
+ ```
116
+ bundle exec rspec spec/controllers/my_controller_spec.rb --example "MyComponent"
117
+ ```
118
+
119
+ ## When a rule here does not fit
120
+
121
+ The gem's README carries the reasoning behind these rules, plus troubleshooting
122
+ for generator errors. It ships inside the gem, so this reads the version the
123
+ app actually has:
124
+
125
+ ```
126
+ cat "$(bundle show ts_schema_spec)/README.md"
127
+ ```
128
+
129
+ Sections: **Best practices**, **Gotchas**, **Troubleshooting**. If the command
130
+ fails, carry on — the rules above stand on their own.
metadata ADDED
@@ -0,0 +1,88 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ts_schema_spec
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.5.4
5
+ platform: ruby
6
+ authors:
7
+ - Vernon Coffey
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: json_schemer
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '2.0'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '3.0'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '2.0'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '3.0'
32
+ - !ruby/object:Gem::Dependency
33
+ name: nokogiri
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: '1.10'
39
+ type: :runtime
40
+ prerelease: false
41
+ version_requirements: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - ">="
44
+ - !ruby/object:Gem::Version
45
+ version: '1.10'
46
+ email:
47
+ - vccoffey@gmail.com
48
+ executables: []
49
+ extensions: []
50
+ extra_rdoc_files: []
51
+ files:
52
+ - LICENSE.txt
53
+ - README.md
54
+ - lib/ts_schema_spec.rb
55
+ - lib/ts_schema_spec/config.rb
56
+ - lib/ts_schema_spec/generator.rb
57
+ - lib/ts_schema_spec/railtie.rb
58
+ - lib/ts_schema_spec/react_component_props.rb
59
+ - lib/ts_schema_spec/rspec.rb
60
+ - lib/ts_schema_spec/skill.rb
61
+ - lib/ts_schema_spec/tasks/ts_schema_spec.rake
62
+ - lib/ts_schema_spec/version.rb
63
+ - skills/react-prop-type-spec/SKILL.md
64
+ homepage: https://github.com/nitidbit/ts_schema_spec
65
+ licenses:
66
+ - MIT
67
+ metadata:
68
+ homepage_uri: https://github.com/nitidbit/ts_schema_spec
69
+ source_code_uri: https://github.com/nitidbit/ts_schema_spec
70
+ rubygems_mfa_required: 'true'
71
+ rdoc_options: []
72
+ require_paths:
73
+ - lib
74
+ required_ruby_version: !ruby/object:Gem::Requirement
75
+ requirements:
76
+ - - ">="
77
+ - !ruby/object:Gem::Version
78
+ version: '3.1'
79
+ required_rubygems_version: !ruby/object:Gem::Requirement
80
+ requirements:
81
+ - - ">="
82
+ - !ruby/object:Gem::Version
83
+ version: '0'
84
+ requirements: []
85
+ rubygems_version: 4.0.20
86
+ specification_version: 4
87
+ summary: Assert that a Rails payload matches the TypeScript type the React side consumes.
88
+ test_files: []