protostar 0.1.0.beta1
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 +7 -0
- data/CHANGELOG.md +60 -0
- data/LICENSE.txt +21 -0
- data/README.md +647 -0
- data/THIRD_PARTY_NOTICES.md +61 -0
- data/exe/protoc-gen-protostar +7 -0
- data/exe/protostar +7 -0
- data/lib/protostar/builder/generator.rb +71 -0
- data/lib/protostar/builder/sink.rb +35 -0
- data/lib/protostar/builder.rb +965 -0
- data/lib/protostar/cli.rb +267 -0
- data/lib/protostar/codec/decode_generator.rb +199 -0
- data/lib/protostar/codec/decoder.rb +296 -0
- data/lib/protostar/codec/encode_generator.rb +224 -0
- data/lib/protostar/codec/encoder.rb +121 -0
- data/lib/protostar/codec/sizer.rb +91 -0
- data/lib/protostar/codec.rb +15 -0
- data/lib/protostar/compiler/ast.rb +193 -0
- data/lib/protostar/compiler/emitters/descriptor_set.rb +57 -0
- data/lib/protostar/compiler/emitters/google_ruby.rb +103 -0
- data/lib/protostar/compiler/emitters/grpc.rb +175 -0
- data/lib/protostar/compiler/emitters/protostar_rbs.rb +252 -0
- data/lib/protostar/compiler/emitters/rbs.rb +394 -0
- data/lib/protostar/compiler/emitters/ruby.rb +503 -0
- data/lib/protostar/compiler/emitters.rb +268 -0
- data/lib/protostar/compiler/include/LICENSE +32 -0
- data/lib/protostar/compiler/include/google/protobuf/any.proto +106 -0
- data/lib/protostar/compiler/include/google/protobuf/api.proto +229 -0
- data/lib/protostar/compiler/include/google/protobuf/compiler/plugin.proto +180 -0
- data/lib/protostar/compiler/include/google/protobuf/cpp_features.proto +87 -0
- data/lib/protostar/compiler/include/google/protobuf/descriptor.proto +1532 -0
- data/lib/protostar/compiler/include/google/protobuf/duration.proto +115 -0
- data/lib/protostar/compiler/include/google/protobuf/empty.proto +51 -0
- data/lib/protostar/compiler/include/google/protobuf/field_mask.proto +243 -0
- data/lib/protostar/compiler/include/google/protobuf/java_features.proto +132 -0
- data/lib/protostar/compiler/include/google/protobuf/source_context.proto +48 -0
- data/lib/protostar/compiler/include/google/protobuf/struct.proto +111 -0
- data/lib/protostar/compiler/include/google/protobuf/timestamp.proto +145 -0
- data/lib/protostar/compiler/include/google/protobuf/type.proto +217 -0
- data/lib/protostar/compiler/include/google/protobuf/wrappers.proto +157 -0
- data/lib/protostar/compiler/lexer.rb +659 -0
- data/lib/protostar/compiler/linker.rb +894 -0
- data/lib/protostar/compiler/option_interpreter.rb +363 -0
- data/lib/protostar/compiler/parser.rb +1696 -0
- data/lib/protostar/compiler/retention.rb +104 -0
- data/lib/protostar/compiler/run.rb +184 -0
- data/lib/protostar/compiler/source_info.rb +161 -0
- data/lib/protostar/compiler/symbol_table.rb +223 -0
- data/lib/protostar/compiler/text_format.rb +340 -0
- data/lib/protostar/compiler/validator.rb +944 -0
- data/lib/protostar/compiler.rb +304 -0
- data/lib/protostar/diagnostics/codes.rb +1307 -0
- data/lib/protostar/diagnostics/formats.rb +64 -0
- data/lib/protostar/diagnostics/renderer.rb +240 -0
- data/lib/protostar/diagnostics/source.rb +153 -0
- data/lib/protostar/diagnostics.rb +117 -0
- data/lib/protostar/dsl.rb +367 -0
- data/lib/protostar/enum.rb +70 -0
- data/lib/protostar/errors.rb +23 -0
- data/lib/protostar/google/protobuf/any_pb.rb +90 -0
- data/lib/protostar/google/protobuf/api_pb.rb +199 -0
- data/lib/protostar/google/protobuf/compiler/plugin_pb.rb +168 -0
- data/lib/protostar/google/protobuf/descriptor_pb.rb +1239 -0
- data/lib/protostar/google/protobuf/duration_pb.rb +99 -0
- data/lib/protostar/google/protobuf/empty_pb.rb +36 -0
- data/lib/protostar/google/protobuf/field_mask_pb.rb +228 -0
- data/lib/protostar/google/protobuf/source_context_pb.rb +33 -0
- data/lib/protostar/google/protobuf/struct_pb.rb +91 -0
- data/lib/protostar/google/protobuf/timestamp_pb.rb +129 -0
- data/lib/protostar/google/protobuf/type_pb.rb +202 -0
- data/lib/protostar/google/protobuf/wrappers_pb.rb +125 -0
- data/lib/protostar/google_protobuf_shim.rb +24 -0
- data/lib/protostar/json/encoder.rb +340 -0
- data/lib/protostar/json/errors.rb +34 -0
- data/lib/protostar/json/parser.rb +437 -0
- data/lib/protostar/json/path.rb +39 -0
- data/lib/protostar/json.rb +95 -0
- data/lib/protostar/lint/command.rb +197 -0
- data/lib/protostar/lint/config.rb +98 -0
- data/lib/protostar/lint/context.rb +126 -0
- data/lib/protostar/lint/directives.rb +56 -0
- data/lib/protostar/lint/fixer.rb +165 -0
- data/lib/protostar/lint/naming.rb +54 -0
- data/lib/protostar/lint/rule.rb +120 -0
- data/lib/protostar/lint/rules/comment_enum.rb +31 -0
- data/lib/protostar/lint/rules/comment_enum_value.rb +32 -0
- data/lib/protostar/lint/rules/comment_field.rb +33 -0
- data/lib/protostar/lint/rules/comment_message.rb +31 -0
- data/lib/protostar/lint/rules/comment_rpc.rb +31 -0
- data/lib/protostar/lint/rules/comment_service.rb +31 -0
- data/lib/protostar/lint/rules/edition_recommended.rb +31 -0
- data/lib/protostar/lint/rules/enum_first_value_zero.rb +34 -0
- data/lib/protostar/lint/rules/enum_pascal_case.rb +25 -0
- data/lib/protostar/lint/rules/enum_value_prefix.rb +38 -0
- data/lib/protostar/lint/rules/enum_value_upper_snake_case.rb +28 -0
- data/lib/protostar/lint/rules/enum_zero_value_suffix.rb +41 -0
- data/lib/protostar/lint/rules/field_lower_snake_case.rb +34 -0
- data/lib/protostar/lint/rules/field_not_required.rb +39 -0
- data/lib/protostar/lint/rules/field_number_reuse_risk.rb +45 -0
- data/lib/protostar/lint/rules/field_numbers_hot_range.rb +44 -0
- data/lib/protostar/lint/rules/file_lower_snake_case.rb +27 -0
- data/lib/protostar/lint/rules/import_no_public.rb +29 -0
- data/lib/protostar/lint/rules/import_no_weak.rb +28 -0
- data/lib/protostar/lint/rules/import_used.rb +33 -0
- data/lib/protostar/lint/rules/message_pascal_case.rb +27 -0
- data/lib/protostar/lint/rules/no_deprecated_features.rb +50 -0
- data/lib/protostar/lint/rules/no_groups.rb +34 -0
- data/lib/protostar/lint/rules/oneof_lower_snake_case.rb +27 -0
- data/lib/protostar/lint/rules/package_defined.rb +28 -0
- data/lib/protostar/lint/rules/package_directory_match.rb +33 -0
- data/lib/protostar/lint/rules/package_lower_snake_case.rb +28 -0
- data/lib/protostar/lint/rules/package_same_directory.rb +39 -0
- data/lib/protostar/lint/rules/package_version_suffix.rb +31 -0
- data/lib/protostar/lint/rules/reserved_names_with_numbers.rb +44 -0
- data/lib/protostar/lint/rules/rpc_pascal_case.rb +25 -0
- data/lib/protostar/lint/rules/rpc_request_response_unique.rb +40 -0
- data/lib/protostar/lint/rules/rpc_request_standard_name.rb +32 -0
- data/lib/protostar/lint/rules/rpc_response_standard_name.rb +31 -0
- data/lib/protostar/lint/rules/service_pascal_case.rb +25 -0
- data/lib/protostar/lint/rules/service_suffix.rb +28 -0
- data/lib/protostar/lint/rules/syntax_specified.rb +35 -0
- data/lib/protostar/lint/runner.rb +135 -0
- data/lib/protostar/lint.rb +122 -0
- data/lib/protostar/message/accessors.rb +86 -0
- data/lib/protostar/message/coercion.rb +171 -0
- data/lib/protostar/message/field_info.rb +145 -0
- data/lib/protostar/message/reflection.rb +149 -0
- data/lib/protostar/message.rb +572 -0
- data/lib/protostar/plugin.rb +94 -0
- data/lib/protostar/schema/default_value.rb +113 -0
- data/lib/protostar/schema/descriptor.rb +41 -0
- data/lib/protostar/schema/draft/copy.rb +88 -0
- data/lib/protostar/schema/draft/resolution.rb +177 -0
- data/lib/protostar/schema/draft.rb +779 -0
- data/lib/protostar/schema/enum.rb +106 -0
- data/lib/protostar/schema/extension_range.rb +37 -0
- data/lib/protostar/schema/features.rb +153 -0
- data/lib/protostar/schema/field.rb +201 -0
- data/lib/protostar/schema/file.rb +217 -0
- data/lib/protostar/schema/freezer.rb +61 -0
- data/lib/protostar/schema/message.rb +104 -0
- data/lib/protostar/schema/oneof.rb +38 -0
- data/lib/protostar/schema/options.rb +367 -0
- data/lib/protostar/schema/pool.rb +179 -0
- data/lib/protostar/schema/proto.rb +576 -0
- data/lib/protostar/schema/resolver.rb +118 -0
- data/lib/protostar/schema/service.rb +89 -0
- data/lib/protostar/schema/source_info.rb +53 -0
- data/lib/protostar/schema.rb +65 -0
- data/lib/protostar/short_names/any.rb +7 -0
- data/lib/protostar/short_names/duration.rb +7 -0
- data/lib/protostar/short_names/empty.rb +7 -0
- data/lib/protostar/short_names/field_mask.rb +7 -0
- data/lib/protostar/short_names/struct.rb +9 -0
- data/lib/protostar/short_names/timestamp.rb +7 -0
- data/lib/protostar/short_names/wrappers.rb +15 -0
- data/lib/protostar/text/extensions.rb +114 -0
- data/lib/protostar/text/parser.rb +439 -0
- data/lib/protostar/text/printer.rb +304 -0
- data/lib/protostar/text/tokenizer.rb +116 -0
- data/lib/protostar/text.rb +67 -0
- data/lib/protostar/types.rb +152 -0
- data/lib/protostar/unknown_field.rb +58 -0
- data/lib/protostar/version.rb +5 -0
- data/lib/protostar/view/generator.rb +298 -0
- data/lib/protostar/view/map.rb +194 -0
- data/lib/protostar/view/repeated.rb +143 -0
- data/lib/protostar/view/scan.rb +183 -0
- data/lib/protostar/view.rb +283 -0
- data/lib/protostar/well_known/any.rb +52 -0
- data/lib/protostar/well_known/duration.rb +53 -0
- data/lib/protostar/well_known/list_value.rb +63 -0
- data/lib/protostar/well_known/struct.rb +55 -0
- data/lib/protostar/well_known/timestamp.rb +64 -0
- data/lib/protostar/well_known/value.rb +60 -0
- data/lib/protostar/well_known.rb +38 -0
- data/lib/protostar/wire/io_source.rb +40 -0
- data/lib/protostar/wire/reader.rb +254 -0
- data/lib/protostar/wire/writer.rb +201 -0
- data/lib/protostar/wire.rb +96 -0
- data/lib/protostar.rb +95 -0
- data/shims/google-protobuf/lib/google/protobuf/any_pb.rb +17 -0
- data/shims/google-protobuf/lib/google/protobuf/api_pb.rb +22 -0
- data/shims/google-protobuf/lib/google/protobuf/compiler/plugin_pb.rb +25 -0
- data/shims/google-protobuf/lib/google/protobuf/descriptor_pb.rb +72 -0
- data/shims/google-protobuf/lib/google/protobuf/descriptor_pool.rb +164 -0
- data/shims/google-protobuf/lib/google/protobuf/descriptors.rb +553 -0
- data/shims/google-protobuf/lib/google/protobuf/duration_pb.rb +17 -0
- data/shims/google-protobuf/lib/google/protobuf/empty_pb.rb +17 -0
- data/shims/google-protobuf/lib/google/protobuf/field_mask_pb.rb +17 -0
- data/shims/google-protobuf/lib/google/protobuf/internal/convert.rb +180 -0
- data/shims/google-protobuf/lib/google/protobuf/internal/extensions.rb +154 -0
- data/shims/google-protobuf/lib/google/protobuf/internal/json.rb +104 -0
- data/shims/google-protobuf/lib/google/protobuf/internal/well_known.rb +140 -0
- data/shims/google-protobuf/lib/google/protobuf/map.rb +216 -0
- data/shims/google-protobuf/lib/google/protobuf/message.rb +544 -0
- data/shims/google-protobuf/lib/google/protobuf/message_exts.rb +41 -0
- data/shims/google-protobuf/lib/google/protobuf/plugin_pb.rb +4 -0
- data/shims/google-protobuf/lib/google/protobuf/repeated_field.rb +366 -0
- data/shims/google-protobuf/lib/google/protobuf/source_context_pb.rb +17 -0
- data/shims/google-protobuf/lib/google/protobuf/struct_pb.rb +20 -0
- data/shims/google-protobuf/lib/google/protobuf/timestamp_pb.rb +17 -0
- data/shims/google-protobuf/lib/google/protobuf/type_pb.rb +27 -0
- data/shims/google-protobuf/lib/google/protobuf/well_known_types.rb +37 -0
- data/shims/google-protobuf/lib/google/protobuf/wrappers_pb.rb +25 -0
- data/shims/google-protobuf/lib/google/protobuf.rb +107 -0
- data/shims/google-protobuf/lib/google/protobuf_ffi.rb +5 -0
- data/shims/google-protobuf/lib/google/protobuf_native.rb +5 -0
- data/sig/protostar/google/protobuf/any_pb.rbs +77 -0
- data/sig/protostar/google/protobuf/api_pb.rbs +242 -0
- data/sig/protostar/google/protobuf/compiler/plugin_pb.rbs +264 -0
- data/sig/protostar/google/protobuf/descriptor_pb.rbs +2220 -0
- data/sig/protostar/google/protobuf/duration_pb.rbs +82 -0
- data/sig/protostar/google/protobuf/empty_pb.rbs +18 -0
- data/sig/protostar/google/protobuf/field_mask_pb.rbs +170 -0
- data/sig/protostar/google/protobuf/source_context_pb.rbs +24 -0
- data/sig/protostar/google/protobuf/struct_pb.rbs +135 -0
- data/sig/protostar/google/protobuf/timestamp_pb.rbs +102 -0
- data/sig/protostar/google/protobuf/type_pb.rbs +304 -0
- data/sig/protostar/google/protobuf/wrappers_pb.rbs +169 -0
- data/sig/protostar/lint.rbs +216 -0
- data/sig/protostar.rbs +326 -0
- metadata +366 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protostar
|
|
4
|
+
module Lint
|
|
5
|
+
module Rules
|
|
6
|
+
# FIELD_NUMBER_REUSE_RISK: A message with gaps in its field numbers that are not reserved
|
|
7
|
+
class FieldNumberReuseRisk < Rule
|
|
8
|
+
REASON = "A deleted field's number that is not reserved will be reused, and old data will decode as the new field."
|
|
9
|
+
EXPLANATION = <<~TEXT
|
|
10
|
+
When a field is deleted, its number is still in every message written before. If a later field takes the
|
|
11
|
+
number, old data decodes into it as if it were the new field: wrong values, or parse errors when the
|
|
12
|
+
types differ. `reserved` makes protoc refuse the number (and the name) for good.
|
|
13
|
+
|
|
14
|
+
message User {
|
|
15
|
+
string name = 1;
|
|
16
|
+
// email was 2
|
|
17
|
+
string phone = 3;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
Fix: `reserved 2; reserved "email";`. If the gap was never used, reserving it costs nothing either.
|
|
21
|
+
TEXT
|
|
22
|
+
|
|
23
|
+
def check
|
|
24
|
+
context.each(:message) do |node, message|
|
|
25
|
+
numbers = message.fields.map(&:number)
|
|
26
|
+
next if numbers.empty?
|
|
27
|
+
covered = (numbers.map { |n| n..n } + message.reserved_ranges + message.extension_ranges +
|
|
28
|
+
[19_000..19_999]).sort_by(&:first)
|
|
29
|
+
gaps = []
|
|
30
|
+
next_free = 1
|
|
31
|
+
covered.each do |r|
|
|
32
|
+
break if next_free >= numbers.max
|
|
33
|
+
gaps << (next_free..[r.first - 1, numbers.max - 1].min) if r.first > next_free
|
|
34
|
+
next_free = [next_free, r.last + 1].max
|
|
35
|
+
end
|
|
36
|
+
next if gaps.empty?
|
|
37
|
+
text = gaps.map { |r| (r.size == 1) ? r.first.to_s : "#{r.first} to #{r.last}" }.join(", ")
|
|
38
|
+
report(node, :name, "`#{message.name}` skips field #{(gaps.sum(&:size) == 1) ? "number" : "numbers"} #{text} without reserving them",
|
|
39
|
+
label: "gaps in the numbering", help: "if fields were deleted there, add `reserved #{text};` and reserve their names")
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protostar
|
|
4
|
+
module Lint
|
|
5
|
+
module Rules
|
|
6
|
+
# FIELD_NUMBERS_HOT_RANGE: A message using field numbers above 15 while numbers 1 to 15 are free
|
|
7
|
+
class FieldNumbersHotRange < Rule
|
|
8
|
+
REASON = "Field numbers 1 to 15 take one byte on the wire and 16 to 2047 two, so the low ones are best kept for the most common fields."
|
|
9
|
+
EXPLANATION = <<~TEXT
|
|
10
|
+
A field's tag is its number and wire type in a varint: 1 to 15 fit in one byte, 16 to 2047 need two. In a
|
|
11
|
+
message with many fields, the frequently set ones should have the low numbers. This rule only notices
|
|
12
|
+
when 1 to 15 have free numbers or deprecated fields while higher numbers are in use; whether the high
|
|
13
|
+
fields are the frequent ones is a judgement it leaves to you.
|
|
14
|
+
|
|
15
|
+
message Event {
|
|
16
|
+
string id = 1;
|
|
17
|
+
string payload = 16;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
Fix: give new frequent fields the free low numbers. Never renumber an existing field: that breaks the
|
|
21
|
+
wire format.
|
|
22
|
+
TEXT
|
|
23
|
+
|
|
24
|
+
def check
|
|
25
|
+
context.each(:message) do |node, message|
|
|
26
|
+
high = message.fields.select { |f| f.number > 15 }
|
|
27
|
+
next if high.empty?
|
|
28
|
+
used = message.fields.map(&:number)
|
|
29
|
+
covered = message.reserved_ranges + message.extension_ranges
|
|
30
|
+
free = (1..15).reject { |n| used.include?(n) || covered.any? { |r| r.cover?(n) } }
|
|
31
|
+
stale = message.fields.select { |f| f.number <= 15 && f.options.deprecated? }
|
|
32
|
+
next if free.empty? && stale.empty?
|
|
33
|
+
parts = []
|
|
34
|
+
parts << "#{numbers_text(free)} free" unless free.empty?
|
|
35
|
+
parts << "deprecated #{stale.map { |f| "`#{f.name}`" }.join(", ")}" unless stale.empty?
|
|
36
|
+
report(node, :name, "`#{message.name}` uses numbers above 15 with #{parts.join(" and ")} in the one-byte range",
|
|
37
|
+
label: "#{high.size} #{(high.size == 1) ? "field" : "fields"} above 15",
|
|
38
|
+
help: "give new, frequently set fields numbers from 1 to 15; do not renumber existing ones")
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protostar
|
|
4
|
+
module Lint
|
|
5
|
+
module Rules
|
|
6
|
+
# FILE_LOWER_SNAKE_CASE: A file name that is not lower_snake_case
|
|
7
|
+
class FileLowerSnakeCase < Rule
|
|
8
|
+
REASON = "Generated file and module names follow the .proto's name; lower_snake_case works on every file system and in every language."
|
|
9
|
+
EXPLANATION = <<~TEXT
|
|
10
|
+
Code generators derive file, module and class names from the `.proto` file's name. Mixed case breaks on
|
|
11
|
+
case-insensitive file systems and reads oddly as a Python module or Go file.
|
|
12
|
+
|
|
13
|
+
acme/UserProfile.proto
|
|
14
|
+
|
|
15
|
+
Fix: rename the file to `acme/user_profile.proto` and update the imports.
|
|
16
|
+
TEXT
|
|
17
|
+
|
|
18
|
+
def check
|
|
19
|
+
base = ::File.basename(context.name, ".proto")
|
|
20
|
+
return if Naming.lower_snake?(base)
|
|
21
|
+
add("file name `#{::File.basename(context.name)}` is not lower_snake_case", [],
|
|
22
|
+
help: "rename the file `#{Naming.lower_snake(base)}.proto`")
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protostar
|
|
4
|
+
module Lint
|
|
5
|
+
module Rules
|
|
6
|
+
# IMPORT_NO_PUBLIC: An `import public`
|
|
7
|
+
class ImportNoPublic < Rule
|
|
8
|
+
REASON = "`import public` makes a file re-export another's types, which hides real dependencies and makes moving types hard."
|
|
9
|
+
EXPLANATION = <<~TEXT
|
|
10
|
+
`import public "b.proto";` in a.proto lets every file that imports a.proto use b.proto's types without
|
|
11
|
+
importing it. It exists for moving types between files; as a habit it hides who depends on what, and not
|
|
12
|
+
every language supports it.
|
|
13
|
+
|
|
14
|
+
import public "acme/common.proto";
|
|
15
|
+
|
|
16
|
+
Fix: import the file directly wherever its types are used.
|
|
17
|
+
TEXT
|
|
18
|
+
|
|
19
|
+
def check
|
|
20
|
+
context.ast.public_dependency.each do |index|
|
|
21
|
+
name = context.ast.dependency[index]
|
|
22
|
+
add("import of `#{name}` is public", [context.span_at([3, index], label: "re-exports `#{name}`")].compact,
|
|
23
|
+
help: "import `#{name}` directly in the files that use it")
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protostar
|
|
4
|
+
module Lint
|
|
5
|
+
module Rules
|
|
6
|
+
# IMPORT_NO_WEAK: An `import weak`
|
|
7
|
+
class ImportNoWeak < Rule
|
|
8
|
+
REASON = "Weak imports are an internal Google feature that most implementations do not support."
|
|
9
|
+
EXPLANATION = <<~TEXT
|
|
10
|
+
`import weak` lets a file use types from a file that might not be linked in. Only some C++ and Java
|
|
11
|
+
builds honour it, and edition 2024 removed it in favour of `import option`.
|
|
12
|
+
|
|
13
|
+
import weak "acme/debug.proto";
|
|
14
|
+
|
|
15
|
+
Fix: use a plain import (or `import option` for files that only provide options, from edition 2024).
|
|
16
|
+
TEXT
|
|
17
|
+
|
|
18
|
+
def check
|
|
19
|
+
context.ast.weak_dependency.each do |index|
|
|
20
|
+
name = context.ast.dependency[index]
|
|
21
|
+
add("import of `#{name}` is weak", [context.span_at([3, index], label: "weak")].compact,
|
|
22
|
+
help: "use a plain `import`")
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protostar
|
|
4
|
+
module Lint
|
|
5
|
+
module Rules
|
|
6
|
+
# IMPORT_USED: An import nothing in the file uses
|
|
7
|
+
class ImportUsed < Rule
|
|
8
|
+
REASON = "An unused import is a dependency in the build graph that does nothing but slow it down and mislead readers."
|
|
9
|
+
EXPLANATION = <<~TEXT
|
|
10
|
+
protoc warns about these too; with this rule on, the warning is reported once, under this rule. An
|
|
11
|
+
import is used when a type, extension or option from it (or from what it imports publicly) is named in
|
|
12
|
+
the file.
|
|
13
|
+
|
|
14
|
+
import "google/protobuf/timestamp.proto";
|
|
15
|
+
message A { string id = 1; }
|
|
16
|
+
|
|
17
|
+
Fix: remove the import.
|
|
18
|
+
TEXT
|
|
19
|
+
CLAIMS = ["V0703"].freeze
|
|
20
|
+
|
|
21
|
+
def check
|
|
22
|
+
context.warnings.each do |warning|
|
|
23
|
+
next unless warning.code == "V0703"
|
|
24
|
+
name = warning.message[/\AImport (.*) is unused\.\z/, 1]
|
|
25
|
+
spans = warning.spans.map { |s| s.primary? ? s.with_label("unused") : s }
|
|
26
|
+
spans = [Diagnostics::Span.new(warning.line, warning.column, label: "unused")] if spans.empty? && warning.line
|
|
27
|
+
add("import `#{name}` is unused", spans, help: "remove the import")
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protostar
|
|
4
|
+
module Lint
|
|
5
|
+
module Rules
|
|
6
|
+
# MESSAGE_PASCAL_CASE: A message name that is not PascalCase
|
|
7
|
+
class MessagePascalCase < Rule
|
|
8
|
+
REASON = "Messages become classes; PascalCase is what every style guide and generator expects."
|
|
9
|
+
EXPLANATION = <<~TEXT
|
|
10
|
+
Message names turn into class names in generated code.
|
|
11
|
+
|
|
12
|
+
message user_profile {}
|
|
13
|
+
|
|
14
|
+
Fix: `message UserProfile {}`, and update the fields and files that refer to it.
|
|
15
|
+
TEXT
|
|
16
|
+
|
|
17
|
+
def check
|
|
18
|
+
context.each(:message) do |node, message|
|
|
19
|
+
next if Naming.pascal?(message.name)
|
|
20
|
+
report(node, :name, "message `#{message.name}` is not PascalCase", label: "not PascalCase",
|
|
21
|
+
help: "name it `#{Naming.pascal(message.name)}` and update what refers to it")
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protostar
|
|
4
|
+
module Lint
|
|
5
|
+
module Rules
|
|
6
|
+
# NO_DEPRECATED_FEATURES: A feature or option deprecated in the file's edition
|
|
7
|
+
class NoDeprecatedFeatures < Rule
|
|
8
|
+
REASON = "Deprecated features will be removed in a later edition; the warning says what replaces them."
|
|
9
|
+
EXPLANATION = <<~TEXT
|
|
10
|
+
protoc warns when a file uses a feature or option its edition deprecates, with the replacement in the
|
|
11
|
+
message. With this rule on, that warning is reported once, under this rule.
|
|
12
|
+
|
|
13
|
+
edition = "2024";
|
|
14
|
+
option features.(pb.cpp).legacy_closed_enum = true;
|
|
15
|
+
|
|
16
|
+
Fix: follow the deprecation message.
|
|
17
|
+
TEXT
|
|
18
|
+
CLAIMS = ["V0507"].freeze
|
|
19
|
+
|
|
20
|
+
def check
|
|
21
|
+
context.warnings.each do |warning|
|
|
22
|
+
next unless warning.code == "V0507"
|
|
23
|
+
name, edition, reason = warning.message.match(/\A(.*) has been deprecated in edition (\w+): (.*)\z/m)&.captures
|
|
24
|
+
next unless name
|
|
25
|
+
spans = option_span(name.split(".").last) ||
|
|
26
|
+
warning.spans.map { |s| s.primary? ? s.with_label("deprecated") : s }
|
|
27
|
+
add("`#{name}` is deprecated since edition #{edition}", spans, help: reason)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
# protoc reports against the element; the option that sets the
|
|
34
|
+
# feature is the more useful place to point
|
|
35
|
+
def option_span(feature)
|
|
36
|
+
pattern = /\bfeatures\b[^;=\]]*\b#{Regexp.escape(feature)}\b[^;,\]]*/
|
|
37
|
+
context.source.lines.each_with_index do |line, i|
|
|
38
|
+
match = line.match(pattern)
|
|
39
|
+
next unless match
|
|
40
|
+
before = line[0, match.begin(0)]
|
|
41
|
+
return nil if before.include?("\t")
|
|
42
|
+
from = before.bytesize + 1
|
|
43
|
+
return [Diagnostics::Span.new(i + 1, from, i + 1, from + match[0].rstrip.bytesize, label: "deprecated")]
|
|
44
|
+
end
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protostar
|
|
4
|
+
module Lint
|
|
5
|
+
module Rules
|
|
6
|
+
# NO_GROUPS: A proto2 `group` field
|
|
7
|
+
class NoGroups < Rule
|
|
8
|
+
REASON = "Groups are a proto2 relic most languages' tooling handles badly; a message field says the same thing."
|
|
9
|
+
EXPLANATION = <<~TEXT
|
|
10
|
+
A group declares a nested message and a field of that type at once, encoded with start and end tags
|
|
11
|
+
instead of a length. Most code generators and JSON mappings treat them as an oddity, proto3 does not
|
|
12
|
+
have them, and editions only keep the encoding (`features.message_encoding = DELIMITED`).
|
|
13
|
+
|
|
14
|
+
message SearchResponse {
|
|
15
|
+
repeated group Result = 1 {
|
|
16
|
+
optional string url = 2;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
Fix: in a new schema, declare `message Result` and a `repeated Result result = 1;` field. For data already
|
|
21
|
+
written, switching changes the wire format; move the file to editions and keep the field DELIMITED.
|
|
22
|
+
TEXT
|
|
23
|
+
|
|
24
|
+
def check
|
|
25
|
+
context.each(:field) do |node, field|
|
|
26
|
+
next unless node.type == :TYPE_GROUP
|
|
27
|
+
report(node, :type, "field `#{field.name}` is a group", label: "group",
|
|
28
|
+
help: "declare `message #{field.message_type.name}` and a field of that type")
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protostar
|
|
4
|
+
module Lint
|
|
5
|
+
module Rules
|
|
6
|
+
# ONEOF_LOWER_SNAKE_CASE: A oneof name that is not lower_snake_case
|
|
7
|
+
class OneofLowerSnakeCase < Rule
|
|
8
|
+
REASON = "Oneof names become accessors and enum cases in generated code, derived on the assumption they are lower_snake_case."
|
|
9
|
+
EXPLANATION = <<~TEXT
|
|
10
|
+
oneof PaymentMethod { string card = 1; string iban = 2; }
|
|
11
|
+
|
|
12
|
+
Fix: `oneof payment_method { ... }`. `--fix` renames it when nothing else in the file mentions it.
|
|
13
|
+
TEXT
|
|
14
|
+
|
|
15
|
+
def check
|
|
16
|
+
context.each(:oneof) do |node, oneof|
|
|
17
|
+
next if Naming.lower_snake?(oneof.name)
|
|
18
|
+
wanted = Naming.lower_snake(oneof.name)
|
|
19
|
+
span = context.span(node, :name)
|
|
20
|
+
report(node, :name, "oneof `#{oneof.name}` is not lower_snake_case", span: span, label: "not lower_snake_case",
|
|
21
|
+
help: "name it `#{wanted}`", fix: rename(span, oneof.name, wanted))
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protostar
|
|
4
|
+
module Lint
|
|
5
|
+
module Rules
|
|
6
|
+
# PACKAGE_DEFINED: A file without a `package`
|
|
7
|
+
class PackageDefined < Rule
|
|
8
|
+
REASON = "A file without a package puts every type it declares in the global namespace, where any other file can clash with it."
|
|
9
|
+
EXPLANATION = <<~TEXT
|
|
10
|
+
Type names are global within a program. Without a package, `message User` is just `User`, and the first
|
|
11
|
+
other schema with a `User` breaks the build, or worse, the link.
|
|
12
|
+
|
|
13
|
+
syntax = "proto3";
|
|
14
|
+
message User {}
|
|
15
|
+
|
|
16
|
+
Fix: add `package acme.users.v1;` after the syntax line, matching the file's directory.
|
|
17
|
+
TEXT
|
|
18
|
+
|
|
19
|
+
def check
|
|
20
|
+
return unless context.file.package.empty?
|
|
21
|
+
span = context.span_at(Context::PATHS[:syntax]) || context.span_at(Context::PATHS[:edition])
|
|
22
|
+
add("file `#{context.name}` has no package", span ? [span.with_label("no `package` follows")] : [],
|
|
23
|
+
help: "add a `package` statement, e.g. `package acme.v1;`")
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protostar
|
|
4
|
+
module Lint
|
|
5
|
+
module Rules
|
|
6
|
+
# PACKAGE_DIRECTORY_MATCH: A file whose directory does not match its package
|
|
7
|
+
class PackageDirectoryMatch < Rule
|
|
8
|
+
REASON = "When `acme.users.v1` lives in `acme/users/v1/`, a type's file can be found from its name, by people and by tools."
|
|
9
|
+
EXPLANATION = <<~TEXT
|
|
10
|
+
Import paths and packages are separate namespaces; keeping them aligned is what lets you find
|
|
11
|
+
`acme.users.v1.User` in `acme/users/v1/`. The check looks at where the file is on disk, so it does not
|
|
12
|
+
depend on the include path.
|
|
13
|
+
|
|
14
|
+
// proto/users.proto
|
|
15
|
+
package acme.users.v1;
|
|
16
|
+
|
|
17
|
+
Fix: move the file to `proto/acme/users/v1/users.proto` (the include path being `proto`).
|
|
18
|
+
TEXT
|
|
19
|
+
|
|
20
|
+
def check
|
|
21
|
+
package = context.file.package
|
|
22
|
+
return if package.empty?
|
|
23
|
+
wanted = package.tr(".", "/")
|
|
24
|
+
dir = ::File.dirname(context.disk_path || context.name)
|
|
25
|
+
return if dir == wanted || dir.end_with?("/#{wanted}")
|
|
26
|
+
add("package `#{package}` is not in a `#{wanted}` directory",
|
|
27
|
+
[context.span_at(Context::PATHS[:package], label: "expects `#{wanted}/`")].compact,
|
|
28
|
+
help: "move the file into a `#{wanted}/` directory")
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protostar
|
|
4
|
+
module Lint
|
|
5
|
+
module Rules
|
|
6
|
+
# PACKAGE_LOWER_SNAKE_CASE: A package name that is not lower_snake_case
|
|
7
|
+
class PackageLowerSnakeCase < Rule
|
|
8
|
+
REASON = "Packages become namespaces in every language; lower_snake_case components map cleanly onto all of them."
|
|
9
|
+
EXPLANATION = <<~TEXT
|
|
10
|
+
`package Acme.UserService;` turns into namespaces, modules and directories, some case-sensitive and some
|
|
11
|
+
not.
|
|
12
|
+
|
|
13
|
+
package Acme.Users;
|
|
14
|
+
|
|
15
|
+
Fix: `package acme.users;`. Every file that names a type with the package has to change too.
|
|
16
|
+
TEXT
|
|
17
|
+
|
|
18
|
+
def check
|
|
19
|
+
package = context.file.package
|
|
20
|
+
return if package.empty? || package.split(".").all? { |part| Naming.lower_snake?(part) }
|
|
21
|
+
wanted = package.split(".").map { |part| Naming.lower_snake(part) }.join(".")
|
|
22
|
+
add("package `#{package}` is not lower_snake_case",
|
|
23
|
+
[context.span_at(Context::PATHS[:package], label: "not lower_snake_case")].compact, help: "use `#{wanted}`")
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protostar
|
|
4
|
+
module Lint
|
|
5
|
+
module Rules
|
|
6
|
+
# PACKAGE_SAME_DIRECTORY: Files in one directory that declare different packages
|
|
7
|
+
class PackageSameDirectory < Rule
|
|
8
|
+
REASON = "One package per directory keeps the package-to-directory mapping one to one."
|
|
9
|
+
EXPLANATION = <<~TEXT
|
|
10
|
+
Two packages in one directory mean the directory says nothing about where a type lives, and many
|
|
11
|
+
generators (Go's in particular) cannot put both in one output package.
|
|
12
|
+
|
|
13
|
+
// acme/a.proto
|
|
14
|
+
package acme.a;
|
|
15
|
+
// acme/b.proto
|
|
16
|
+
package acme.b;
|
|
17
|
+
|
|
18
|
+
Fix: move one of the files, or give them the same package.
|
|
19
|
+
TEXT
|
|
20
|
+
|
|
21
|
+
def check
|
|
22
|
+
dir = directory(context)
|
|
23
|
+
peers = context.all.select { |other| directory(other) == dir }
|
|
24
|
+
packages = peers.map { |other| other.file.package }.uniq
|
|
25
|
+
return if packages.size < 2 || context.file.package == packages.first
|
|
26
|
+
add("files in `#{dir}` declare packages `#{packages.join("`, `")}`",
|
|
27
|
+
[context.span_at(Context::PATHS[:package], label: "differs from `#{packages.first}`")].compact,
|
|
28
|
+
help: "keep one package per directory")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def directory(context)
|
|
34
|
+
::File.dirname(context.disk_path || context.name)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protostar
|
|
4
|
+
module Lint
|
|
5
|
+
module Rules
|
|
6
|
+
# PACKAGE_VERSION_SUFFIX: A package without a version as its last component
|
|
7
|
+
class PackageVersionSuffix < Rule
|
|
8
|
+
REASON = "A version in the package (`acme.users.v1`) lets a breaking change live next to the old API instead of replacing it."
|
|
9
|
+
EXPLANATION = <<~TEXT
|
|
10
|
+
Once clients depend on a package, breaking changes have to go in a new one. Naming the first `v1` makes
|
|
11
|
+
`v2` the obvious next step. Accepted suffixes: `v1`, `v1beta1`, `v1alpha`, `v1test`, `v1p1beta1` and the
|
|
12
|
+
like.
|
|
13
|
+
|
|
14
|
+
package acme.users;
|
|
15
|
+
|
|
16
|
+
Fix: `package acme.users.v1;`.
|
|
17
|
+
TEXT
|
|
18
|
+
|
|
19
|
+
VERSION = /\Av\d+(?:(?:alpha|beta)\d*|test\w*|p\d+(?:alpha|beta)\d*)?\z/
|
|
20
|
+
|
|
21
|
+
def check
|
|
22
|
+
package = context.file.package
|
|
23
|
+
return if package.empty? || package.split(".").last.match?(VERSION)
|
|
24
|
+
add("package `#{package}` has no version suffix",
|
|
25
|
+
[context.span_at(Context::PATHS[:package], label: "no version")].compact,
|
|
26
|
+
help: "add one, e.g. `#{package}.v1`")
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protostar
|
|
4
|
+
module Lint
|
|
5
|
+
module Rules
|
|
6
|
+
# RESERVED_NAMES_WITH_NUMBERS: A message or enum that reserves numbers but no names, or names but no numbers
|
|
7
|
+
class ReservedNamesWithNumbers < Rule
|
|
8
|
+
REASON = "A deleted field needs both its number and its name reserved: the number for the binary format, the name for JSON and text."
|
|
9
|
+
EXPLANATION = <<~TEXT
|
|
10
|
+
The binary format identifies a field by number, JSON and the text format by name. Reserving only the
|
|
11
|
+
number lets a new field take the old name, and old JSON then fills it; reserving only the name lets a new
|
|
12
|
+
field take the number, and old binary data fills it.
|
|
13
|
+
|
|
14
|
+
message User {
|
|
15
|
+
reserved 2;
|
|
16
|
+
string name = 1;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
Fix: reserve both, `reserved 2; reserved "email";` (`reserved email;` in editions).
|
|
20
|
+
TEXT
|
|
21
|
+
|
|
22
|
+
def check
|
|
23
|
+
context.each(:message) { |node, message| check_element(node, message, "fields", 9, 10) }
|
|
24
|
+
context.each(:enum) { |node, enum| check_element(node, enum, "values", 4, 5) }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def check_element(node, element, what, range_field, name_field)
|
|
30
|
+
numbers = node.reserved_range
|
|
31
|
+
names = node.reserved_name
|
|
32
|
+
if !numbers.empty? && names.empty?
|
|
33
|
+
report(numbers[0], :number, "`#{element.name}` reserves #{what} by number but not by name",
|
|
34
|
+
label: "number only", help: "reserve the deleted #{what}' names too")
|
|
35
|
+
elsif numbers.empty? && !names.empty?
|
|
36
|
+
report(node, :name, "`#{element.name}` reserves #{what} by name but not by number",
|
|
37
|
+
span: context.span_at(node.path + [name_field]), label: "name only",
|
|
38
|
+
help: "reserve the deleted #{what}' numbers too")
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protostar
|
|
4
|
+
module Lint
|
|
5
|
+
module Rules
|
|
6
|
+
# RPC_PASCAL_CASE: An rpc name that is not PascalCase
|
|
7
|
+
class RpcPascalCase < Rule
|
|
8
|
+
REASON = "RPCs become methods in every language's stubs, converted from PascalCase."
|
|
9
|
+
EXPLANATION = <<~TEXT
|
|
10
|
+
rpc get_user(GetUserRequest) returns (GetUserResponse);
|
|
11
|
+
|
|
12
|
+
Fix: `rpc GetUser(...)`.
|
|
13
|
+
TEXT
|
|
14
|
+
|
|
15
|
+
def check
|
|
16
|
+
context.each(:method) do |node, rpc|
|
|
17
|
+
next if Naming.pascal?(rpc.name)
|
|
18
|
+
report(node, :name, "rpc `#{rpc.name}` is not PascalCase", label: "not PascalCase",
|
|
19
|
+
help: "name it `#{Naming.pascal(rpc.name)}`")
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protostar
|
|
4
|
+
module Lint
|
|
5
|
+
module Rules
|
|
6
|
+
# RPC_REQUEST_RESPONSE_UNIQUE: A message used as the request or response of more than one rpc
|
|
7
|
+
class RpcRequestResponseUnique < Rule
|
|
8
|
+
REASON = "Requests and responses shared between RPCs will need to diverge, and then cannot."
|
|
9
|
+
EXPLANATION = <<~TEXT
|
|
10
|
+
Two RPCs that take the same request are tied together: a field one of them needs appears in the other's
|
|
11
|
+
API too. The same goes for responses, and for a message used as one RPC's request and another's response.
|
|
12
|
+
|
|
13
|
+
rpc GetUser(UserRequest) returns (User);
|
|
14
|
+
rpc DeleteUser(UserRequest) returns (User);
|
|
15
|
+
|
|
16
|
+
Fix: give each RPC its own `<Rpc>Request` and `<Rpc>Response`.
|
|
17
|
+
TEXT
|
|
18
|
+
|
|
19
|
+
def check
|
|
20
|
+
first = {}
|
|
21
|
+
context.each(:service) do |service_node, service|
|
|
22
|
+
service.rpcs.each_with_index do |rpc, i|
|
|
23
|
+
node = service_node.rpcs[i]
|
|
24
|
+
[[rpc.input_type, :input_type, "request"], [rpc.output_type, :output_type, "response"]].each do |type, kind, role|
|
|
25
|
+
if (earlier = first[type.full_name])
|
|
26
|
+
other_node, other_kind, other_rpc, other_role = earlier
|
|
27
|
+
report(node, kind, "`#{type.name}` is the #{role} of `#{rpc.name}` and already the #{other_role} of `#{other_rpc}`",
|
|
28
|
+
label: "used again", related: [[other_node, other_kind, "first used here"]],
|
|
29
|
+
help: "give `#{rpc.name}` a `#{Naming.pascal(rpc.name)}#{role.capitalize}` of its own")
|
|
30
|
+
else
|
|
31
|
+
first[type.full_name] = [node, kind, rpc.name, role]
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protostar
|
|
4
|
+
module Lint
|
|
5
|
+
module Rules
|
|
6
|
+
# RPC_REQUEST_STANDARD_NAME: An rpc whose request is not `<Rpc>Request`
|
|
7
|
+
class RpcRequestStandardName < Rule
|
|
8
|
+
REASON = "A request message of its own per RPC can grow fields without touching any other RPC."
|
|
9
|
+
EXPLANATION = <<~TEXT
|
|
10
|
+
An RPC that takes `User` or `google.protobuf.Empty` cannot add a parameter later without changing that
|
|
11
|
+
shared type for everyone. `GetUserRequest` (or `UserServiceGetUserRequest`) belongs to `GetUser` alone.
|
|
12
|
+
|
|
13
|
+
rpc GetUser(UserId) returns (User);
|
|
14
|
+
|
|
15
|
+
Fix: `rpc GetUser(GetUserRequest) returns (GetUserResponse);`.
|
|
16
|
+
TEXT
|
|
17
|
+
|
|
18
|
+
def check
|
|
19
|
+
context.each(:service) do |service_node, service|
|
|
20
|
+
service.rpcs.each_with_index do |rpc, i|
|
|
21
|
+
base = Naming.pascal(rpc.name)
|
|
22
|
+
wanted = ["#{base}Request", "#{service.name}#{base}Request"]
|
|
23
|
+
next if wanted.include?(rpc.input_type.name)
|
|
24
|
+
report(service_node.rpcs[i], :input_type, "request of `#{rpc.name}` is `#{rpc.input_type.name}`, not `#{wanted[0]}`",
|
|
25
|
+
label: "not `#{wanted[0]}`", help: "take a `#{wanted[0]}` message of its own")
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|