moku6 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +131 -0
- data/exe/moku6 +6 -0
- data/lib/moku6/catalog.rb +23 -0
- data/lib/moku6/cli.rb +118 -0
- data/lib/moku6/config.rb +61 -0
- data/lib/moku6/differ.rb +74 -0
- data/lib/moku6/envelope_schema.rb +56 -0
- data/lib/moku6/event.rb +54 -0
- data/lib/moku6/generate.rb +95 -0
- data/lib/moku6/generators/base_generator.rb +31 -0
- data/lib/moku6/generators/bigquery_generator.rb +29 -0
- data/lib/moku6/generators/cloud_events_generator.rb +47 -0
- data/lib/moku6/generators/docs_generator.rb +49 -0
- data/lib/moku6/generators/event_catalog_generator.rb +57 -0
- data/lib/moku6/generators/example_generator.rb +66 -0
- data/lib/moku6/generators/json_schema_generator.rb +23 -0
- data/lib/moku6/generators/open_api_generator.rb +60 -0
- data/lib/moku6/generators/outbox_generator.rb +96 -0
- data/lib/moku6/generators/rails_generator.rb +84 -0
- data/lib/moku6/generators/ruby_generator.rb +27 -0
- data/lib/moku6/generators/typescript_generator.rb +59 -0
- data/lib/moku6/generators.rb +29 -0
- data/lib/moku6/initializer.rb +44 -0
- data/lib/moku6/linter.rb +60 -0
- data/lib/moku6/loader.rb +31 -0
- data/lib/moku6/offense.rb +13 -0
- data/lib/moku6/reporter.rb +71 -0
- data/lib/moku6/result.rb +23 -0
- data/lib/moku6/rules/action_naming_rule.rb +18 -0
- data/lib/moku6/rules/base_rule.rb +37 -0
- data/lib/moku6/rules/example_consistency_rule.rb +53 -0
- data/lib/moku6/rules/label_description_rule.rb +21 -0
- data/lib/moku6/rules/pii_field_name_heuristic_rule.rb +45 -0
- data/lib/moku6/rules/privacy_masking_rule.rb +21 -0
- data/lib/moku6/rules/retention_rule.rb +19 -0
- data/lib/moku6/rules/schema_rule.rb +31 -0
- data/lib/moku6/rules/uniqueness_rule.rb +22 -0
- data/lib/moku6/rules/visibility_rule.rb +27 -0
- data/lib/moku6/version.rb +6 -0
- data/lib/moku6.rb +55 -0
- data/schemas/audit-event.schema.json +85 -0
- data/sig/generated/moku6/catalog.rbs +22 -0
- data/sig/generated/moku6/config.rbs +43 -0
- data/sig/generated/moku6/differ.rbs +28 -0
- data/sig/generated/moku6/envelope_schema.rbs +19 -0
- data/sig/generated/moku6/event.rbs +51 -0
- data/sig/generated/moku6/generators/base_generator.rbs +22 -0
- data/sig/generated/moku6/generators/bigquery_generator.rbs +12 -0
- data/sig/generated/moku6/generators/cloud_events_generator.rbs +19 -0
- data/sig/generated/moku6/generators/docs_generator.rbs +18 -0
- data/sig/generated/moku6/generators/event_catalog_generator.rbs +16 -0
- data/sig/generated/moku6/generators/example_generator.rbs +23 -0
- data/sig/generated/moku6/generators/json_schema_generator.rbs +12 -0
- data/sig/generated/moku6/generators/open_api_generator.rbs +20 -0
- data/sig/generated/moku6/generators/outbox_generator.rbs +23 -0
- data/sig/generated/moku6/generators/rails_generator.rbs +23 -0
- data/sig/generated/moku6/generators/ruby_generator.rbs +15 -0
- data/sig/generated/moku6/generators/typescript_generator.rbs +20 -0
- data/sig/generated/moku6/generators.rbs +13 -0
- data/sig/generated/moku6/initializer.rbs +23 -0
- data/sig/generated/moku6/linter.rbs +31 -0
- data/sig/generated/moku6/loader.rbs +15 -0
- data/sig/generated/moku6/reporter.rbs +30 -0
- data/sig/generated/moku6/result.rbs +22 -0
- data/sig/generated/moku6/rules/action_naming_rule.rbs +10 -0
- data/sig/generated/moku6/rules/base_rule.rbs +23 -0
- data/sig/generated/moku6/rules/example_consistency_rule.rbs +18 -0
- data/sig/generated/moku6/rules/label_description_rule.rbs +15 -0
- data/sig/generated/moku6/rules/pii_field_name_heuristic_rule.rbs +21 -0
- data/sig/generated/moku6/rules/privacy_masking_rule.rbs +10 -0
- data/sig/generated/moku6/rules/retention_rule.rbs +10 -0
- data/sig/generated/moku6/rules/schema_rule.rbs +15 -0
- data/sig/generated/moku6/rules/uniqueness_rule.rbs +11 -0
- data/sig/generated/moku6/rules/visibility_rule.rbs +10 -0
- data/sig/generated/moku6/version.rbs +5 -0
- data/sig/generated/moku6.rbs +9 -0
- data/sig/manual/dependencies.rbs +13 -0
- data/sig/manual/offense.rbs +13 -0
- data/templates/init/.moku6.yml +18 -0
- data/templates/init/catalog/employee.updated.yaml +36 -0
- metadata +141 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: cecdef76990b4c77ed9961c59f114e6729bfe2f459bbf150d5c04f9717169ea4
|
|
4
|
+
data.tar.gz: 144a332287dcd00a9e02461ad6c569b4b75483bcc8466d63b6f241521ac34929
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 77dec0db7f88e103190e89b745a10f8ca78d170ae9bd5aa285b3c87261e40f8e9e48b84d012e48fe10b09dec21fbbf76d651e02ec3ea5915d2fc822e7acaca37
|
|
7
|
+
data.tar.gz: 5662ce82ce050b334f1b97d1bc43fc5750d103035331a771db60c30e793d0d0bfcbe998da5e3f2917fc6d052629f4dda8b9d961e09225b9f7da85688e52f00f4
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yudai Takada
|
|
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
|
|
13
|
+
all 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
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# Moku6
|
|
2
|
+
|
|
3
|
+
Define, lint, and document business-level audit events.
|
|
4
|
+
|
|
5
|
+
`moku6` is not another audit log storage library.
|
|
6
|
+
It helps teams define what should be recorded as audit events,
|
|
7
|
+
how each event should be shaped, which fields are required,
|
|
8
|
+
whether personal data must be hidden, and whether the event can be shown to customers.
|
|
9
|
+
|
|
10
|
+
It is designed for SaaS teams that need consistent audit logs across multiple products.
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
Install the gem and add to the application's Gemfile by executing:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
bundle add moku6
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
If bundler is not being used to manage dependencies, install the gem by executing:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
gem install moku6
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Usage
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
moku6 init # Generate the catalog/ and .moku6.yml scaffold
|
|
30
|
+
moku6 lint # Lint audit event definitions
|
|
31
|
+
moku6 generate docs
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Commands
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
moku6 init # Generate the scaffold (catalog/ and config)
|
|
38
|
+
moku6 lint # Lint definitions
|
|
39
|
+
moku6 lint --format json # Output results as JSON
|
|
40
|
+
moku6 lint --strict # Treat warnings as errors
|
|
41
|
+
moku6 diff OLD NEW # Detect breaking changes between two catalogs
|
|
42
|
+
moku6 diff OLD NEW --format markdown --out report.md # Review-friendly diff report
|
|
43
|
+
moku6 generate docs # Markdown overview
|
|
44
|
+
moku6 generate schema # Runtime JSON Schema
|
|
45
|
+
moku6 generate typescript # TypeScript types
|
|
46
|
+
moku6 generate ruby # Ruby constants
|
|
47
|
+
moku6 generate bigquery # BigQuery DDL
|
|
48
|
+
moku6 generate cloudevents # CloudEvents JSON Schema
|
|
49
|
+
moku6 generate eventcatalog # EventCatalog event docs
|
|
50
|
+
moku6 generate openapi # OpenAPI 3.1 document
|
|
51
|
+
moku6 generate rails # Ruby/Rails emitter SDK
|
|
52
|
+
moku6 generate outbox # Sample Rails transactional outbox
|
|
53
|
+
moku6 generate examples # Sample events
|
|
54
|
+
moku6 generate all # All core artifacts
|
|
55
|
+
moku6 version # Show the version
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### Common options
|
|
59
|
+
|
|
60
|
+
| Option | Default | Description |
|
|
61
|
+
|---|---|---|
|
|
62
|
+
| `--catalog DIR` | `catalog` | Definition directory |
|
|
63
|
+
| `--config FILE` | `.moku6.yml` | Config file |
|
|
64
|
+
| `--out PATH` | (per artifact) | Output path (file or directory) |
|
|
65
|
+
| `--format text\|json` | `text` | Output format for `lint` |
|
|
66
|
+
|
|
67
|
+
### Exit codes
|
|
68
|
+
|
|
69
|
+
| Code | Meaning |
|
|
70
|
+
|---|---|
|
|
71
|
+
| `0` | Success (no lint errors / generation succeeded) |
|
|
72
|
+
| `1` | Lint detected an error |
|
|
73
|
+
| `2` | Usage error (invalid arguments, missing file/directory, YAML parse failure) |
|
|
74
|
+
|
|
75
|
+
### Audit event definition example
|
|
76
|
+
|
|
77
|
+
```yaml
|
|
78
|
+
action: employee.updated
|
|
79
|
+
label: Employee updated
|
|
80
|
+
description: Recorded when an employee's basic information is updated
|
|
81
|
+
category: employee
|
|
82
|
+
required: true
|
|
83
|
+
actor: { required: true }
|
|
84
|
+
target: { type: employee, required: true }
|
|
85
|
+
fields:
|
|
86
|
+
employee_id: { type: string, required: true, description: ID of the updated employee }
|
|
87
|
+
changed_fields: { type: array, required: true, description: List of changed field names }
|
|
88
|
+
privacy:
|
|
89
|
+
contains_personal_data: true
|
|
90
|
+
masked_fields: [before.email, after.email]
|
|
91
|
+
visibility: { customer_visible: true, internal_only: false }
|
|
92
|
+
retention: { years: 5 }
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### GitHub Actions example for consumers
|
|
96
|
+
|
|
97
|
+
An example of linting in the CI of each product repository that defines audit events.
|
|
98
|
+
|
|
99
|
+
```yaml
|
|
100
|
+
name: moku6
|
|
101
|
+
on:
|
|
102
|
+
pull_request:
|
|
103
|
+
jobs:
|
|
104
|
+
lint-audit-events:
|
|
105
|
+
runs-on: ubuntu-latest
|
|
106
|
+
steps:
|
|
107
|
+
- uses: actions/checkout@v4
|
|
108
|
+
- uses: ruby/setup-ruby@v1
|
|
109
|
+
with:
|
|
110
|
+
ruby-version: "3.3"
|
|
111
|
+
- name: Install moku6
|
|
112
|
+
run: gem install moku6
|
|
113
|
+
- name: Lint audit event catalog
|
|
114
|
+
run: moku6 lint
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
> For Bundler-managed projects, add `gem "moku6"` to your `Gemfile` and run `bundle exec moku6 lint`.
|
|
118
|
+
|
|
119
|
+
## Development
|
|
120
|
+
|
|
121
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
|
122
|
+
|
|
123
|
+
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
|
124
|
+
|
|
125
|
+
## Contributing
|
|
126
|
+
|
|
127
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/ydah/moku6.
|
|
128
|
+
|
|
129
|
+
## License
|
|
130
|
+
|
|
131
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/exe/moku6
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Moku6
|
|
5
|
+
class Catalog
|
|
6
|
+
attr_reader :events #: Array[Event]
|
|
7
|
+
|
|
8
|
+
#: (Array[Event] events) -> void
|
|
9
|
+
def initialize(events) = @events = events
|
|
10
|
+
|
|
11
|
+
#: () -> Array[String?]
|
|
12
|
+
def actions = events.map(&:action)
|
|
13
|
+
|
|
14
|
+
#: () -> Array[Event]
|
|
15
|
+
def sorted = events.sort_by { |e| e.action.to_s }
|
|
16
|
+
|
|
17
|
+
#: (String? action) -> Event?
|
|
18
|
+
def find(action) = events.find { |e| e.action == action }
|
|
19
|
+
|
|
20
|
+
#: () -> bool
|
|
21
|
+
def empty? = events.empty?
|
|
22
|
+
end
|
|
23
|
+
end
|
data/lib/moku6/cli.rb
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
module Moku6
|
|
6
|
+
class CLI
|
|
7
|
+
BANNER = <<~TEXT
|
|
8
|
+
Usage: moku6 COMMAND [options]
|
|
9
|
+
|
|
10
|
+
Commands:
|
|
11
|
+
version Show the version
|
|
12
|
+
init Generate a catalog scaffold
|
|
13
|
+
lint Lint audit event definitions
|
|
14
|
+
diff OLD NEW Detect breaking changes between two catalog directories
|
|
15
|
+
generate SUB Generate output artifacts (see: moku6 generate --help)
|
|
16
|
+
|
|
17
|
+
Global options:
|
|
18
|
+
--catalog DIR Catalog directory (default: catalog)
|
|
19
|
+
--config FILE Config file (default: .moku6.yml)
|
|
20
|
+
TEXT
|
|
21
|
+
|
|
22
|
+
def self.start(argv) = new.start(argv)
|
|
23
|
+
|
|
24
|
+
def start(argv)
|
|
25
|
+
argv = Array(argv).dup
|
|
26
|
+
command = argv.shift
|
|
27
|
+
case command
|
|
28
|
+
when "version" then cmd_version
|
|
29
|
+
when "init" then cmd_init(argv)
|
|
30
|
+
when "lint" then cmd_lint(argv)
|
|
31
|
+
when "diff" then cmd_diff(argv)
|
|
32
|
+
when "generate" then Generate.new.start(argv)
|
|
33
|
+
when nil, "help", "-h", "--help" then puts BANNER
|
|
34
|
+
else
|
|
35
|
+
warn "Unknown command: #{command}"
|
|
36
|
+
warn BANNER
|
|
37
|
+
exit 2
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def cmd_version
|
|
44
|
+
puts "moku6 #{Moku6::VERSION}"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def cmd_init(argv)
|
|
48
|
+
options = {}
|
|
49
|
+
parse(argv, "init") { |o| global_options(o, options) }
|
|
50
|
+
Initializer.new(options).run
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def cmd_lint(argv)
|
|
54
|
+
options = {format: "text"}
|
|
55
|
+
parse(argv, "lint [options]") do |o|
|
|
56
|
+
global_options(o, options)
|
|
57
|
+
o.on("--format FORMAT", %w[text json], "Output format (text, json)") { |v| options[:format] = v }
|
|
58
|
+
o.on("--strict", "Treat warnings as errors (exit 1)") { options[:strict] = true }
|
|
59
|
+
end
|
|
60
|
+
config = Config.load(options)
|
|
61
|
+
catalog = Loader.new(config.catalog_dir).load
|
|
62
|
+
if catalog.empty?
|
|
63
|
+
warn "Warning: no event definitions found in catalog: #{config.catalog_dir}"
|
|
64
|
+
end
|
|
65
|
+
result = Linter.new(catalog, config).run
|
|
66
|
+
Reporter.for(options[:format]).report(result)
|
|
67
|
+
failed = result.errors? || (config.strict? && result.warnings.any?)
|
|
68
|
+
exit(failed ? 1 : 0)
|
|
69
|
+
rescue Moku6::UsageError => e
|
|
70
|
+
warn "Error: #{e.message}"
|
|
71
|
+
exit 2
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def cmd_diff(argv)
|
|
75
|
+
options = {format: "text"}
|
|
76
|
+
rest = parse(argv, "diff OLD NEW [options]") do |o|
|
|
77
|
+
global_options(o, options)
|
|
78
|
+
o.on("--format FORMAT", %w[text json markdown], "Output format (text, json, markdown)") { |v| options[:format] = v }
|
|
79
|
+
o.on("--out PATH", "Write the report to a file instead of stdout") { |v| options[:out] = v }
|
|
80
|
+
end
|
|
81
|
+
old_dir, new_dir = rest
|
|
82
|
+
unless old_dir && new_dir
|
|
83
|
+
warn "Error: diff requires OLD and NEW catalog directories"
|
|
84
|
+
exit 2
|
|
85
|
+
end
|
|
86
|
+
old_catalog = Loader.new(old_dir).load
|
|
87
|
+
new_catalog = Loader.new(new_dir).load
|
|
88
|
+
result = Differ.new(old_catalog, new_catalog).run
|
|
89
|
+
if options[:out] && options[:format] == "markdown"
|
|
90
|
+
require "fileutils"
|
|
91
|
+
FileUtils.mkdir_p(File.dirname(options[:out]))
|
|
92
|
+
File.write(options[:out], Reporter::Markdown.render(result))
|
|
93
|
+
puts "Wrote report: #{options[:out]}"
|
|
94
|
+
else
|
|
95
|
+
Reporter.for(options[:format]).report(result)
|
|
96
|
+
end
|
|
97
|
+
exit(result.errors? ? 1 : 0)
|
|
98
|
+
rescue Moku6::UsageError => e
|
|
99
|
+
warn "Error: #{e.message}"
|
|
100
|
+
exit 2
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def global_options(o, options)
|
|
104
|
+
o.on("--catalog DIR", "Catalog directory (default: catalog)") { |v| options[:catalog] = v }
|
|
105
|
+
o.on("--config FILE", "Config file (default: .moku6.yml)") { |v| options[:config] = v }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def parse(argv, usage)
|
|
109
|
+
parser = OptionParser.new
|
|
110
|
+
parser.banner = "Usage: moku6 #{usage}"
|
|
111
|
+
yield parser
|
|
112
|
+
parser.parse(argv)
|
|
113
|
+
rescue OptionParser::ParseError => e
|
|
114
|
+
warn "Error: #{e.message}"
|
|
115
|
+
exit 2
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
data/lib/moku6/config.rb
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
module Moku6
|
|
7
|
+
class Config
|
|
8
|
+
DEFAULTS = {
|
|
9
|
+
"catalog_dir" => "catalog",
|
|
10
|
+
"output_dir" => "generated",
|
|
11
|
+
"naming" => {"pattern" => '^[a-z0-9_]+(\.[a-z0-9_]+)+$'},
|
|
12
|
+
"rules" => {"strict" => false, "warn_pii_field_names" => true}
|
|
13
|
+
}.freeze #: Hash[String, untyped]
|
|
14
|
+
|
|
15
|
+
DEFAULT_CONFIG_PATH = ".moku6.yml" #: String
|
|
16
|
+
|
|
17
|
+
#: (?Hash[Symbol, untyped] options) -> Config
|
|
18
|
+
def self.load(options = {})
|
|
19
|
+
path = options[:config] || DEFAULT_CONFIG_PATH
|
|
20
|
+
file = File.exist?(path) ? (YAML.safe_load_file(path) || {}) : {}
|
|
21
|
+
new(deep_merge(DEFAULTS, file), options)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
#: (Hash[String, untyped] base, Hash[String, untyped] override) -> Hash[String, untyped]
|
|
25
|
+
def self.deep_merge(base, override)
|
|
26
|
+
base.merge(override) do |_key, b, o|
|
|
27
|
+
(b.is_a?(Hash) && o.is_a?(Hash)) ? deep_merge(b, o) : o
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
attr_reader :data #: Hash[String, untyped]
|
|
32
|
+
attr_reader :options #: Hash[Symbol, untyped]
|
|
33
|
+
|
|
34
|
+
#: (Hash[String, untyped] data, ?Hash[Symbol, untyped] options) -> void
|
|
35
|
+
def initialize(data, options = {})
|
|
36
|
+
@data = data
|
|
37
|
+
@options = options
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
#: () -> String?
|
|
41
|
+
def catalog_dir = options[:catalog] || data["catalog_dir"]
|
|
42
|
+
|
|
43
|
+
#: () -> String?
|
|
44
|
+
def output_dir = data["output_dir"]
|
|
45
|
+
|
|
46
|
+
#: () -> String?
|
|
47
|
+
def naming_pattern = data.dig("naming", "pattern")
|
|
48
|
+
|
|
49
|
+
#: () -> bool
|
|
50
|
+
def strict? = options.fetch(:strict) { !!data.dig("rules", "strict") }
|
|
51
|
+
|
|
52
|
+
#: () -> bool
|
|
53
|
+
def warn_pii_field_names? = !!data.dig("rules", "warn_pii_field_names")
|
|
54
|
+
|
|
55
|
+
#: () -> String
|
|
56
|
+
def examples_dir = data["examples_dir"] || "examples/valid"
|
|
57
|
+
|
|
58
|
+
#: (String | Symbol name) -> String?
|
|
59
|
+
def generator_out(name) = data.dig("generators", name.to_s, "out")
|
|
60
|
+
end
|
|
61
|
+
end
|
data/lib/moku6/differ.rb
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Moku6
|
|
5
|
+
# Detects breaking changes between two catalogs (design sections 13 / 16).
|
|
6
|
+
# Breaking changes (error): action removed, required field added, field type changed.
|
|
7
|
+
class Differ
|
|
8
|
+
# @rbs @old: Catalog
|
|
9
|
+
# @rbs @new: Catalog
|
|
10
|
+
|
|
11
|
+
#: (Catalog old_catalog, Catalog new_catalog) -> void
|
|
12
|
+
def initialize(old_catalog, new_catalog)
|
|
13
|
+
@old = old_catalog
|
|
14
|
+
@new = new_catalog
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
#: () -> Result
|
|
18
|
+
def run
|
|
19
|
+
offenses = removed_actions
|
|
20
|
+
@new.events.each do |new_event|
|
|
21
|
+
old_event = @old.find(new_event.action)
|
|
22
|
+
next unless old_event
|
|
23
|
+
|
|
24
|
+
offenses.concat(field_changes(old_event, new_event))
|
|
25
|
+
end
|
|
26
|
+
Result.new(offenses)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
#: () -> Array[Offense]
|
|
32
|
+
def removed_actions
|
|
33
|
+
new_actions = @new.actions
|
|
34
|
+
@old.events.reject { |e| new_actions.include?(e.action) }.map do |e|
|
|
35
|
+
breaking(e, "action '#{e.action}' was removed (breaking change).", "diff_action_removed")
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
#: (Event old_event, Event new_event) -> Array[Offense]
|
|
40
|
+
def field_changes(old_event, new_event)
|
|
41
|
+
old_fields = old_event.fields
|
|
42
|
+
offenses = []
|
|
43
|
+
new_event.fields.each do |name, nf|
|
|
44
|
+
of = old_fields[name]
|
|
45
|
+
if of.nil?
|
|
46
|
+
if nf["required"]
|
|
47
|
+
offenses << breaking(new_event,
|
|
48
|
+
"required field '#{name}' was added (breaking change).",
|
|
49
|
+
"diff_required_field_added")
|
|
50
|
+
end
|
|
51
|
+
next
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
if !of["required"] && nf["required"]
|
|
55
|
+
offenses << breaking(new_event,
|
|
56
|
+
"field '#{name}' became required (breaking change).",
|
|
57
|
+
"diff_required_field_added")
|
|
58
|
+
end
|
|
59
|
+
if of["type"] != nf["type"]
|
|
60
|
+
offenses << breaking(new_event,
|
|
61
|
+
"field '#{name}' type changed from #{of["type"]} to #{nf["type"]} (breaking change).",
|
|
62
|
+
"diff_field_type_changed")
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
offenses
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
#: (Event event, String message, String rule) -> Offense
|
|
69
|
+
def breaking(event, message, rule)
|
|
70
|
+
Offense.new(rule: rule, severity: :error, action: event.action,
|
|
71
|
+
file: event.source_path, message: message)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Moku6
|
|
5
|
+
# Builds the runtime event (envelope) validation JSON Schema from a definition.
|
|
6
|
+
# Shared by JsonSchemaGenerator and ExampleConsistencyRule (see design sections 8 / 12.2).
|
|
7
|
+
module EnvelopeSchema
|
|
8
|
+
TYPE_MAP = {
|
|
9
|
+
"string" => {"type" => "string"},
|
|
10
|
+
"integer" => {"type" => "integer"},
|
|
11
|
+
"number" => {"type" => "number"},
|
|
12
|
+
"boolean" => {"type" => "boolean"},
|
|
13
|
+
"object" => {"type" => "object"},
|
|
14
|
+
"timestamp" => {"type" => "string", "format" => "date-time"}
|
|
15
|
+
}.freeze #: Hash[String, Hash[String, String]]
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
#: (Event event) -> Hash[String, untyped]
|
|
20
|
+
def for(event)
|
|
21
|
+
{
|
|
22
|
+
"$schema" => "https://json-schema.org/draft/2020-12/schema",
|
|
23
|
+
"title" => event.action,
|
|
24
|
+
"type" => "object",
|
|
25
|
+
"required" => %w[event_id action occurred_at actor target metadata],
|
|
26
|
+
"properties" => {
|
|
27
|
+
"action" => {"const" => event.action},
|
|
28
|
+
"event_id" => {"type" => "string"},
|
|
29
|
+
"occurred_at" => {"type" => "string", "format" => "date-time"},
|
|
30
|
+
"actor" => {"type" => "object"},
|
|
31
|
+
"target" => {"type" => "object"},
|
|
32
|
+
"metadata" => metadata_schema(event)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# The schema for the `metadata` object (the definition's fields).
|
|
38
|
+
#: (Event event) -> Hash[String, untyped]
|
|
39
|
+
def metadata_schema(event)
|
|
40
|
+
required_meta = event.fields.select { |_, f| f["required"] }.keys
|
|
41
|
+
{
|
|
42
|
+
"type" => "object",
|
|
43
|
+
"required" => required_meta,
|
|
44
|
+
"properties" => event.fields.transform_values { |f| field_type(f) }
|
|
45
|
+
}
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
#: (Hash[String, untyped] f) -> Hash[String, untyped]
|
|
49
|
+
def field_type(f)
|
|
50
|
+
return {"type" => "array", "items" => {}} if f["type"] == "array"
|
|
51
|
+
|
|
52
|
+
# dup so each occurrence is an independent object (avoids YAML anchors/aliases).
|
|
53
|
+
TYPE_MAP.fetch(f["type"], {"type" => "string"}).dup
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
data/lib/moku6/event.rb
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Moku6
|
|
5
|
+
class Event
|
|
6
|
+
attr_reader :data #: Hash[String, untyped]
|
|
7
|
+
attr_reader :source_path #: String
|
|
8
|
+
|
|
9
|
+
#: (Hash[String, untyped]? data, source_path: String) -> void
|
|
10
|
+
def initialize(data, source_path:)
|
|
11
|
+
@data = data || {}
|
|
12
|
+
@source_path = source_path
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
#: () -> String?
|
|
16
|
+
def action = data["action"]
|
|
17
|
+
|
|
18
|
+
#: () -> String?
|
|
19
|
+
def label = data["label"]
|
|
20
|
+
|
|
21
|
+
#: () -> String?
|
|
22
|
+
def description = data["description"]
|
|
23
|
+
|
|
24
|
+
#: () -> String?
|
|
25
|
+
def category = data["category"]
|
|
26
|
+
|
|
27
|
+
#: () -> bool
|
|
28
|
+
def required? = !!data["required"]
|
|
29
|
+
|
|
30
|
+
#: () -> untyped
|
|
31
|
+
def actor = data["actor"]
|
|
32
|
+
|
|
33
|
+
#: () -> untyped
|
|
34
|
+
def target = data["target"]
|
|
35
|
+
|
|
36
|
+
#: () -> Hash[String, untyped]
|
|
37
|
+
def fields = data["fields"] || {}
|
|
38
|
+
|
|
39
|
+
#: () -> untyped
|
|
40
|
+
def privacy = data["privacy"]
|
|
41
|
+
|
|
42
|
+
#: () -> untyped
|
|
43
|
+
def visibility = data["visibility"]
|
|
44
|
+
|
|
45
|
+
#: () -> untyped
|
|
46
|
+
def retention = data["retention"]
|
|
47
|
+
|
|
48
|
+
#: () -> Array[untyped]
|
|
49
|
+
def examples = data["examples"] || []
|
|
50
|
+
|
|
51
|
+
#: () -> Hash[String, untyped]
|
|
52
|
+
def to_h = data
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
module Moku6
|
|
6
|
+
class Generate
|
|
7
|
+
# subcommand => [generator name, default output path]
|
|
8
|
+
GENERATORS = {
|
|
9
|
+
"docs" => [:docs, "generated/docs/audit-events.md"],
|
|
10
|
+
"schema" => [:json_schema, "generated/schema"],
|
|
11
|
+
"typescript" => [:typescript, "generated/typescript/audit-events.ts"],
|
|
12
|
+
"ruby" => [:ruby, "generated/ruby/audit_events.rb"],
|
|
13
|
+
"bigquery" => [:bigquery, "generated/bigquery/audit_events.sql"],
|
|
14
|
+
"cloudevents" => [:cloudevents, "generated/cloudevents"],
|
|
15
|
+
"eventcatalog" => [:eventcatalog, "generated/eventcatalog"],
|
|
16
|
+
"openapi" => [:openapi, "generated/openapi/audit-events.yaml"],
|
|
17
|
+
"rails" => [:rails, "generated/rails/audit_events.rb"],
|
|
18
|
+
"outbox" => [:outbox, "generated/outbox"],
|
|
19
|
+
"examples" => [:example, "examples/generated"]
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
ALL = %w[docs schema typescript ruby bigquery examples].freeze
|
|
23
|
+
|
|
24
|
+
BANNER = <<~TEXT
|
|
25
|
+
Usage: moku6 generate SUBCOMMAND [options]
|
|
26
|
+
|
|
27
|
+
Subcommands:
|
|
28
|
+
docs Generate Markdown documentation
|
|
29
|
+
schema Generate runtime JSON Schema
|
|
30
|
+
typescript Generate TypeScript types
|
|
31
|
+
ruby Generate Ruby constants
|
|
32
|
+
bigquery Generate BigQuery DDL
|
|
33
|
+
cloudevents Generate CloudEvents JSON Schema
|
|
34
|
+
eventcatalog Generate EventCatalog event docs
|
|
35
|
+
openapi Generate an OpenAPI 3.1 document
|
|
36
|
+
rails Generate a Ruby/Rails emitter SDK
|
|
37
|
+
outbox Generate a sample Rails outbox
|
|
38
|
+
examples Generate sample events
|
|
39
|
+
all Generate all artifacts (#{ALL.join(", ")})
|
|
40
|
+
|
|
41
|
+
Options:
|
|
42
|
+
--out PATH Output path
|
|
43
|
+
--catalog DIR Catalog directory (default: catalog)
|
|
44
|
+
--config FILE Config file (default: .moku6.yml)
|
|
45
|
+
TEXT
|
|
46
|
+
|
|
47
|
+
def start(argv)
|
|
48
|
+
argv = argv.dup
|
|
49
|
+
sub = argv.shift
|
|
50
|
+
case sub
|
|
51
|
+
when nil, "help", "-h", "--help"
|
|
52
|
+
puts BANNER
|
|
53
|
+
when "all"
|
|
54
|
+
options = parse_options(argv, "generate all")
|
|
55
|
+
ALL.each { |name| run(name, options) }
|
|
56
|
+
else
|
|
57
|
+
unless GENERATORS.key?(sub)
|
|
58
|
+
warn "Unknown generate subcommand: #{sub}"
|
|
59
|
+
warn BANNER
|
|
60
|
+
exit 2
|
|
61
|
+
end
|
|
62
|
+
options = parse_options(argv, "generate #{sub}")
|
|
63
|
+
run(sub, options)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def run(sub, options)
|
|
70
|
+
name, default_out = GENERATORS.fetch(sub)
|
|
71
|
+
config = Config.load(options)
|
|
72
|
+
catalog = Loader.new(config.catalog_dir).load
|
|
73
|
+
out = options[:out] || config.generator_out(name) || default_out
|
|
74
|
+
Generators.build(name, catalog, config).write(out)
|
|
75
|
+
puts "Generated: #{out}"
|
|
76
|
+
rescue Moku6::UsageError => e
|
|
77
|
+
warn "Error: #{e.message}"
|
|
78
|
+
exit 2
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def parse_options(argv, usage)
|
|
82
|
+
options = {}
|
|
83
|
+
parser = OptionParser.new
|
|
84
|
+
parser.banner = "Usage: moku6 #{usage} [options]"
|
|
85
|
+
parser.on("--out PATH", "Output path") { |v| options[:out] = v }
|
|
86
|
+
parser.on("--catalog DIR", "Catalog directory (default: catalog)") { |v| options[:catalog] = v }
|
|
87
|
+
parser.on("--config FILE", "Config file (default: .moku6.yml)") { |v| options[:config] = v }
|
|
88
|
+
parser.parse(argv)
|
|
89
|
+
options
|
|
90
|
+
rescue OptionParser::ParseError => e
|
|
91
|
+
warn "Error: #{e.message}"
|
|
92
|
+
exit 2
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Moku6
|
|
7
|
+
module Generators
|
|
8
|
+
class BaseGenerator
|
|
9
|
+
AUTOGEN_NOTE = "AUTO-GENERATED by moku6. DO NOT EDIT." #: String
|
|
10
|
+
|
|
11
|
+
# @rbs @catalog: Catalog
|
|
12
|
+
# @rbs @config: Config
|
|
13
|
+
|
|
14
|
+
#: (Catalog catalog, Config config) -> void
|
|
15
|
+
def initialize(catalog, config)
|
|
16
|
+
@catalog = catalog
|
|
17
|
+
@config = config
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
#: () -> String
|
|
21
|
+
def render = raise NotImplementedError
|
|
22
|
+
|
|
23
|
+
#: (String path) -> String
|
|
24
|
+
def write(path)
|
|
25
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
26
|
+
File.write(path, render)
|
|
27
|
+
path
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|