rails-hyperdrive 0.4.0 → 0.5.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 +4 -4
- data/CHANGELOG.md +72 -1
- data/README.md +33 -7
- data/lib/generators/hyperdrive/sync_runner.rb +17 -2
- data/lib/rails/hyperdrive/auto_install.rb +3 -1
- data/lib/rails/hyperdrive/bundler_artifact_discovery.rb +172 -82
- data/lib/rails/hyperdrive/canonical_skill_render.rb +105 -0
- data/lib/rails/hyperdrive/gem_manifest.rb +175 -0
- data/lib/rails/hyperdrive/install_pipeline.rb +27 -2
- data/lib/rails/hyperdrive/lock_file.rb +15 -0
- data/lib/rails/hyperdrive/skill_tasks.rb +27 -0
- data/lib/rails/hyperdrive/skill_template.rb +25 -0
- data/lib/rails/hyperdrive/stack_profile.rb +20 -1
- data/lib/rails/hyperdrive/version.rb +1 -1
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 11be45047a967ade290022f1aa91cd023bf6085e0c84b4f6c08807d9b29f446b
|
|
4
|
+
data.tar.gz: 68205915ef162cc1006685361cb14e1690dcd82b666fbab12a54fdf7d5f26e2a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 59852c94a50743b1666ab4c8d973b3a34d5178158e57c7b7e20c98e203c53dca22ac6ef505880f0b5b013b5972f9c2aa66b53ec2b1646eab2b6fef487bd4447a
|
|
7
|
+
data.tar.gz: 37165dc674f138b5f29bd408917f0d4d2e5847079f2f346fcc99d4341d2984d01eea3a228bfb434a0351d8c32115762bfbc5937f296a338e511726be4552c3a4
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,76 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.5.0] - 2026-08-15
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Gem-root manifest: a companion gem now declares artifact gating in a
|
|
15
|
+
`hyperdrive.yml` at its root (or at the path named by a new
|
|
16
|
+
`rails_hyperdrive_manifest` gemspec metadata key; `..` segments or a blank
|
|
17
|
+
value fall back to the conventional path). Top-level `gem:`/`versions:` are
|
|
18
|
+
gem-wide defaults; `skills:` entries (keyed by skill-dir relpath from the
|
|
19
|
+
skills root) and `guidelines:` entries (keyed by filename) override them per
|
|
20
|
+
key, and per-file `conditional:` gating nests inside `skills:` entries. All
|
|
21
|
+
keys optional; malformed gating fails open (warn + install ungated, never
|
|
22
|
+
skip, never raise), and an entry keying no shipped artifact warns — the
|
|
23
|
+
staleness signal for gating detached from content. Shipping a manifest (file
|
|
24
|
+
or metadata key) is also a companion opt-in signal, so a skills.sh-format
|
|
25
|
+
skill repo integrates with zero modification to its skill files.
|
|
26
|
+
|
|
27
|
+
- Template/content pairing for skills, so one companion repo can serve
|
|
28
|
+
`npx skills` / git-clone consumers and rails-hyperdrive at once without
|
|
29
|
+
giving up bundle-conditioned content. A skill directory holding a static
|
|
30
|
+
`SKILL.md` (the universal face: generated definition plus all supporting
|
|
31
|
+
files) pairs with a `SKILL.md.erb` master at the same relative path under a
|
|
32
|
+
templates root — declared via the new `rails_hyperdrive_skill_templates_dir`
|
|
33
|
+
gemspec metadata key, defaulting to the convention path
|
|
34
|
+
`lib/<gem_name>/hyperdrive/skills`. The pair discovers as one skill:
|
|
35
|
+
hyperdrive renders the template against the app's bundle and installs the
|
|
36
|
+
supporting files from the content directory, never reading the static
|
|
37
|
+
`SKILL.md`. Unpaired layouts — every existing companion — behave exactly as
|
|
38
|
+
before.
|
|
39
|
+
- Canonical-render rake tasks for companion repos:
|
|
40
|
+
`require "rails/hyperdrive/skill_tasks"` in the Rakefile provides
|
|
41
|
+
`rake hyperdrive:skills:render` (generate each template's static `SKILL.md`
|
|
42
|
+
with the fail-open canonical binding — every gem present, `gem_version`
|
|
43
|
+
`nil`; the generated face carries only the skills.sh frontmatter, with the
|
|
44
|
+
installer-only `gem:`/`versions:`/`conditional:` keys stripped, as they are
|
|
45
|
+
on install) and `rake hyperdrive:skills:check` (fail listing stale generated
|
|
46
|
+
files — the CI freshness gate). Rails-free; rails-hyperdrive as a
|
|
47
|
+
development dependency suffices.
|
|
48
|
+
- A gem's top-level `skills/` directory is now scanned as a default skills
|
|
49
|
+
root for opted-in companions (convention-path artifacts, a
|
|
50
|
+
`rails_hyperdrive_skills_dir`, `rails_hyperdrive_skill_templates_dir`, or
|
|
51
|
+
`rails_hyperdrive_targets` metadata key, or a lockfile `enabled:` entry are
|
|
52
|
+
the opt-in signals). Roots are deduplicated
|
|
53
|
+
by expanded path, so a companion already declaring
|
|
54
|
+
`rails_hyperdrive_skills_dir: "skills"` sees identical results.
|
|
55
|
+
- A hand-editable `enabled:` list in `.hyperdrive/lock.yml` (gem names,
|
|
56
|
+
mirroring `disabled:`): naming a gem there treats it as an opted-in
|
|
57
|
+
companion, so its top-level `skills/` content installs through the normal
|
|
58
|
+
pipeline. `disabled:` still wins for individual artifacts.
|
|
59
|
+
- `hyperdrive:init` and `hyperdrive:sync` now surface bundled gems that ship
|
|
60
|
+
skills.sh-style `skills/*/SKILL.md` content without opting in as companions
|
|
61
|
+
— report-only, with a pointer to the `enabled:` list; nothing is installed
|
|
62
|
+
until the user opts in.
|
|
63
|
+
|
|
64
|
+
### Changed
|
|
65
|
+
|
|
66
|
+
- The artifact frontmatter contract is relaxed to the skills.sh base
|
|
67
|
+
contract: only `name` and `description` are required. `gem:` and
|
|
68
|
+
`versions:` are now optional narrowing keys — a missing `gem:` means
|
|
69
|
+
universal (`"*"`), a missing `versions:` means unconstrained. Artifacts
|
|
70
|
+
declaring all four fields behave exactly as before.
|
|
71
|
+
- **Breaking:** gating moved entirely to the gem-root manifest. The
|
|
72
|
+
frontmatter `gem:`/`versions:`/`conditional:` keys are no longer read —
|
|
73
|
+
they are ordinary unknown keys, silently ignored and installed verbatim —
|
|
74
|
+
and installer-key stripping is removed: skill bodies now install, and
|
|
75
|
+
render canonically, byte-identical to their shipped (or ERB-rendered)
|
|
76
|
+
content, frontmatter included. Guideline frontmatter is still stripped on
|
|
77
|
+
install. A companion gating via frontmatter must move those keys into
|
|
78
|
+
`hyperdrive.yml`.
|
|
79
|
+
|
|
10
80
|
## [0.4.0] - 2026-08-08
|
|
11
81
|
|
|
12
82
|
### Added
|
|
@@ -353,7 +423,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
353
423
|
- `hyperdrive:init` generator that installs architecture skills and auto-discovers
|
|
354
424
|
per-gem skills.
|
|
355
425
|
|
|
356
|
-
[Unreleased]: https://github.com/rails-hyperdrive/rails-hyperdrive/compare/v0.
|
|
426
|
+
[Unreleased]: https://github.com/rails-hyperdrive/rails-hyperdrive/compare/v0.5.0...HEAD
|
|
427
|
+
[0.5.0]: https://github.com/rails-hyperdrive/rails-hyperdrive/releases/tag/v0.5.0
|
|
357
428
|
[0.4.0]: https://github.com/rails-hyperdrive/rails-hyperdrive/releases/tag/v0.4.0
|
|
358
429
|
[0.3.0]: https://github.com/rails-hyperdrive/rails-hyperdrive/releases/tag/v0.3.0
|
|
359
430
|
[0.2.0]: https://github.com/rails-hyperdrive/rails-hyperdrive/releases/tag/v0.2.0
|
data/README.md
CHANGED
|
@@ -121,7 +121,7 @@ CLAUDE.md # user-owned; ONE injected line: @.claude
|
|
|
121
121
|
index.md # managed aggregator: @guidelines/<name>.md
|
|
122
122
|
guidelines/<name>.md # companion-shipped, frontmatter stripped
|
|
123
123
|
.claude/skills/<name>/
|
|
124
|
-
SKILL.md # companion-shipped,
|
|
124
|
+
SKILL.md # companion-shipped, installed verbatim (frontmatter included)
|
|
125
125
|
<supporting files> # optional companion-shipped extras, installed as shipped (*.md.erb rendered)
|
|
126
126
|
.hyperdrive/lock.yml # git-tracked manifest (source gem, version, content hash)
|
|
127
127
|
```
|
|
@@ -152,6 +152,17 @@ The list is yours to edit; the generator only reads it and carries it forward. D
|
|
|
152
152
|
|
|
153
153
|
To skip installed content wholesale instead, pass `--skip-content` to `hyperdrive:init`.
|
|
154
154
|
|
|
155
|
+
### Opting into a gem's bundled skills
|
|
156
|
+
|
|
157
|
+
Ordinary gems (not built as hyperdrive companions) sometimes ship a top-level `skills/` directory of [skills.sh](https://skills.sh)-style skills. Those are never installed automatically — `hyperdrive:init` and `hyperdrive:sync` only report them, e.g. `gem 'foo' ships 2 skills.sh skill(s)`. To install them, name the gem in the `enabled:` list in `.hyperdrive/lock.yml` and re-run `hyperdrive:sync`:
|
|
158
|
+
|
|
159
|
+
```yaml
|
|
160
|
+
enabled:
|
|
161
|
+
- foo
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
An enabled gem is treated as a companion from then on: its skills install through the normal pipeline (including on `bundle install`), and `disabled:` still wins for any individual artifact. The list is hand-edited like `disabled:` and survives every rewrite of the lockfile.
|
|
165
|
+
|
|
155
166
|
---
|
|
156
167
|
|
|
157
168
|
## Safety
|
|
@@ -162,29 +173,44 @@ Rails Hyperdrive is **dev-only**, enforced in depth: the engine refuses to handl
|
|
|
162
173
|
|
|
163
174
|
## Build a companion gem
|
|
164
175
|
|
|
165
|
-
Ship markdown
|
|
176
|
+
Ship markdown, declare what it targets, publish. That's the whole contract:
|
|
166
177
|
|
|
167
178
|
```
|
|
168
|
-
|
|
179
|
+
skills/<name>/SKILL.md # skill (dir-per-skill, may ship supporting files)
|
|
169
180
|
lib/<gem_name>/hyperdrive/guidelines/<name>.md # guideline (flat file)
|
|
170
181
|
```
|
|
171
182
|
|
|
183
|
+
Top-level `skills/` is the recommended home for skill content — it is the tool-agnostic face of your gem, readable by skills.sh and plain git-clone consumers as well as hyperdrive. `lib/<gem_name>/hyperdrive/` is hyperdrive-specific machinery: guidelines (no skills.sh analogue), ERB skill templates (`SKILL.md.erb` — raw ERB must not reach generic consumers, so keep it out of `skills/`), and legacy static skills (still scanned for back-compat).
|
|
184
|
+
|
|
185
|
+
Frontmatter is pure [skills.sh](https://skills.sh) — only `name` and `description` are read, so a skill repo's content integrates without modification:
|
|
186
|
+
|
|
172
187
|
```yaml
|
|
173
188
|
---
|
|
174
189
|
name: jobs-sidekiq # kebab-case; determines the install path
|
|
175
190
|
description: Background job conventions for Sidekiq.
|
|
176
|
-
gem: sidekiq # TARGET gem(s), resolved + version-matched in the bundle
|
|
177
|
-
versions: ">= 7.0, < 9.0" # Gem::Requirement matched against the target gem
|
|
178
191
|
---
|
|
179
192
|
```
|
|
180
193
|
|
|
181
|
-
|
|
194
|
+
Gating — which bundles an artifact installs into — lives in a `hyperdrive.yml` manifest at the gem root (or at the path named by a `rails_hyperdrive_manifest` gemspec metadata key), never in the content. Every key is optional; no manifest (or an empty one) means everything installs universally:
|
|
195
|
+
|
|
196
|
+
```yaml
|
|
197
|
+
gem: sidekiq # gem-wide default: TARGET gem(s), resolved + version-matched in the bundle
|
|
198
|
+
versions: ">= 7.0, < 9.0" # gem-wide default: Gem::Requirement matched against the target gem
|
|
199
|
+
skills: # per-skill overrides, keyed by skill dir relative to its skills root
|
|
200
|
+
jobs-sidekiq:
|
|
201
|
+
versions: ">= 8.0"
|
|
202
|
+
guidelines: # per-guideline overrides, keyed by filename
|
|
203
|
+
jobs.md:
|
|
204
|
+
gem: sidekiq
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
Shipping a `hyperdrive.yml` (or declaring `rails_hyperdrive_manifest`) opts your gem in as a companion. Also declare your targets in gemspec metadata — it opts your gem in too, and it is the pre-install targeting signal (how `hyperdrive:discover` suggests you before anyone installs you):
|
|
182
208
|
|
|
183
209
|
```ruby
|
|
184
210
|
spec.metadata["rails_hyperdrive_targets"] = "sidekiq"
|
|
185
211
|
```
|
|
186
212
|
|
|
187
|
-
The full contract — multi-target artifacts, multi-file skills, per-file gem gating, ERB-templated content, collision and dedup rules — lives in [docs/COMPANION_GEMS.md](docs/COMPANION_GEMS.md).
|
|
213
|
+
The full contract — multi-target artifacts, multi-file skills, per-file gem gating, ERB-templated content, the template/content paired layout that also serves `npx skills` and git-clone consumers, collision and dedup rules — lives in [docs/COMPANION_GEMS.md](docs/COMPANION_GEMS.md).
|
|
188
214
|
|
|
189
215
|
---
|
|
190
216
|
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
require "thor"
|
|
2
2
|
require "rails/hyperdrive"
|
|
3
3
|
require "rails/hyperdrive/bundler_artifact_discovery"
|
|
4
|
+
require "rails/hyperdrive/install_layout"
|
|
4
5
|
require "rails/hyperdrive/install_pipeline"
|
|
6
|
+
require "rails/hyperdrive/lock_file"
|
|
5
7
|
require "generators/hyperdrive/install_summary"
|
|
6
8
|
|
|
7
9
|
module Rails
|
|
@@ -29,7 +31,9 @@ module Rails
|
|
|
29
31
|
end
|
|
30
32
|
|
|
31
33
|
def discover_artifacts(skip: false)
|
|
32
|
-
@artifacts ||= skip ? [] : ::Rails::Hyperdrive::BundlerArtifactDiscovery.discover(
|
|
34
|
+
@artifacts ||= skip ? [] : ::Rails::Hyperdrive::BundlerArtifactDiscovery.discover(
|
|
35
|
+
warnings: warnings, enabled_gems: enabled_gems, notices: notices
|
|
36
|
+
)
|
|
33
37
|
end
|
|
34
38
|
|
|
35
39
|
def install(mode:)
|
|
@@ -38,7 +42,8 @@ module Rails
|
|
|
38
42
|
shell: @shell,
|
|
39
43
|
artifacts: discover_artifacts,
|
|
40
44
|
mode: mode,
|
|
41
|
-
warnings: warnings
|
|
45
|
+
warnings: warnings,
|
|
46
|
+
notices: notices
|
|
42
47
|
)
|
|
43
48
|
@pipeline.call
|
|
44
49
|
end
|
|
@@ -59,6 +64,16 @@ module Rails
|
|
|
59
64
|
@warnings ||= []
|
|
60
65
|
end
|
|
61
66
|
|
|
67
|
+
def notices
|
|
68
|
+
@notices ||= []
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def enabled_gems
|
|
72
|
+
@enabled_gems ||= ::Rails::Hyperdrive::LockFile.load(
|
|
73
|
+
File.join(root, ::Rails::Hyperdrive::InstallLayout::LOCK_PATH)
|
|
74
|
+
).enabled_gems
|
|
75
|
+
end
|
|
76
|
+
|
|
62
77
|
def lock_entries
|
|
63
78
|
entries = []
|
|
64
79
|
@pipeline&.lock&.each_entry { |e| entries << e }
|
|
@@ -4,6 +4,7 @@ require "rails/hyperdrive/bundler_artifact_discovery"
|
|
|
4
4
|
require "rails/hyperdrive/install_layout"
|
|
5
5
|
require "rails/hyperdrive/install_pipeline"
|
|
6
6
|
require "rails/hyperdrive/install_shell"
|
|
7
|
+
require "rails/hyperdrive/lock_file"
|
|
7
8
|
|
|
8
9
|
module Rails
|
|
9
10
|
module Hyperdrive
|
|
@@ -46,7 +47,8 @@ module Rails
|
|
|
46
47
|
return skip(:not_development) unless development?(env)
|
|
47
48
|
return skip(:not_initialized) unless File.exist?(File.join(root, InstallLayout::LOCK_PATH))
|
|
48
49
|
|
|
49
|
-
|
|
50
|
+
enabled = LockFile.load(File.join(root, InstallLayout::LOCK_PATH)).enabled_gems
|
|
51
|
+
artifacts = BundlerArtifactDiscovery.discover(enabled_gems: enabled)
|
|
50
52
|
status = ArtifactStatus.compare(root: root, artifacts: artifacts)
|
|
51
53
|
|
|
52
54
|
installed = []
|
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
require "yaml"
|
|
2
2
|
require "bundler"
|
|
3
|
+
require "rails/hyperdrive/gem_manifest"
|
|
3
4
|
require "rails/hyperdrive/skill_template"
|
|
4
5
|
|
|
5
6
|
module Rails
|
|
6
7
|
module Hyperdrive
|
|
7
8
|
module BundlerArtifactDiscovery
|
|
8
9
|
SKILL_FILE_NAMES = ["SKILL.md", "SKILL.md.erb"].freeze
|
|
9
|
-
INSTALLER_KEY = /\A(?:gem|versions|conditional):/.freeze
|
|
10
10
|
|
|
11
11
|
Artifact = Struct.new(
|
|
12
12
|
:name, :description, :target_gem, :versions, :artifact_type,
|
|
13
13
|
:source_gem, :path, :body, :spec_version, :support_files,
|
|
14
|
-
:source_root,
|
|
14
|
+
:source_root, :support_root,
|
|
15
15
|
keyword_init: true
|
|
16
16
|
) do
|
|
17
17
|
def skill?
|
|
@@ -34,17 +34,30 @@ module Rails
|
|
|
34
34
|
module_function
|
|
35
35
|
|
|
36
36
|
# Non-fatal problems are appended to `warnings` and the artifact is
|
|
37
|
-
# dropped; discovery never raises.
|
|
38
|
-
|
|
37
|
+
# dropped; discovery never raises. A gem that has not opted in as a
|
|
38
|
+
# companion is never scanned — its skills.sh content is only reported
|
|
39
|
+
# through `notices`.
|
|
40
|
+
def discover(specs: nil, warnings: [], enabled_gems: [], notices: [])
|
|
39
41
|
specs ||= safe_bundler_specs
|
|
42
|
+
enabled = Array(enabled_gems).map(&:to_s)
|
|
40
43
|
resolved = specs.each_with_object({}) { |s, h| h[s.name.to_s] = s.version }
|
|
41
44
|
|
|
42
45
|
candidates = []
|
|
43
46
|
specs.each do |spec|
|
|
44
|
-
|
|
45
|
-
|
|
47
|
+
unless opted_in?(spec, enabled_gems: enabled)
|
|
48
|
+
notice_skills_sh_content(spec, notices: notices)
|
|
49
|
+
next
|
|
50
|
+
end
|
|
51
|
+
manifest = GemManifest.load(spec, warnings: warnings)
|
|
52
|
+
seen = { skill: [], guideline: [] }
|
|
53
|
+
each_artifact_path(spec, warnings: warnings) do |path, type, support_root, key|
|
|
54
|
+
seen[type] << key
|
|
55
|
+
gate = type == :skill ? manifest.skill_gate(key) : manifest.guideline_gate(key)
|
|
56
|
+
artifact = parse(path, source_spec: spec, type: type, resolved: resolved,
|
|
57
|
+
warnings: warnings, support_root: support_root, gate: gate)
|
|
46
58
|
candidates << artifact if artifact
|
|
47
59
|
end
|
|
60
|
+
warn_unknown_manifest_keys(manifest, spec, seen, warnings)
|
|
48
61
|
end
|
|
49
62
|
|
|
50
63
|
# Collapse same-name variants within one source gem (highest
|
|
@@ -55,29 +68,137 @@ module Rails
|
|
|
55
68
|
end
|
|
56
69
|
end
|
|
57
70
|
|
|
71
|
+
# Yields each candidate with its manifest join key: a skill's relpath
|
|
72
|
+
# from its skills root, a guideline's filename. Keys are collected before
|
|
73
|
+
# parsing, so a candidate later dropped (e.g. an ERB render failure)
|
|
74
|
+
# still counts as known to the manifest.
|
|
58
75
|
def each_artifact_path(spec, warnings: [])
|
|
59
|
-
skill_paths(spec, warnings: warnings).each { |
|
|
60
|
-
guideline_paths(spec).each { |p| yield p, :guideline }
|
|
76
|
+
skill_paths(spec, warnings: warnings).each { |path, support_root, rel| yield path, :skill, support_root, rel }
|
|
77
|
+
guideline_paths(spec).each { |p| yield p, :guideline, nil, File.basename(p) }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The staleness signal for gating detached from content: an entry
|
|
81
|
+
# matching nothing means a renamed or removed skill dir/guideline.
|
|
82
|
+
def warn_unknown_manifest_keys(manifest, spec, seen, warnings)
|
|
83
|
+
(manifest.skill_keys - seen[:skill]).each do |key|
|
|
84
|
+
warnings << "#{spec.name}: manifest skills entry '#{key}' names no shipped skill directory"
|
|
85
|
+
end
|
|
86
|
+
(manifest.guideline_keys - seen[:guideline]).each do |key|
|
|
87
|
+
warnings << "#{spec.name}: manifest guidelines entry '#{key}' names no shipped guideline"
|
|
88
|
+
end
|
|
61
89
|
end
|
|
62
90
|
|
|
63
91
|
def skill_paths(spec, warnings: [])
|
|
64
|
-
roots = [
|
|
92
|
+
roots = [
|
|
93
|
+
File.join(spec.full_gem_path, "lib", spec.name, "hyperdrive", "skills"),
|
|
94
|
+
File.join(spec.full_gem_path, "skills")
|
|
95
|
+
]
|
|
65
96
|
if (override = skills_dir_override(spec))
|
|
66
97
|
roots << File.join(spec.full_gem_path, override)
|
|
67
98
|
end
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
99
|
+
|
|
100
|
+
candidates = []
|
|
101
|
+
seen = {}
|
|
102
|
+
roots.each do |root|
|
|
103
|
+
Dir.glob(File.join(root, "**", "{SKILL.md,SKILL.md.erb}"))
|
|
104
|
+
.group_by { |p| File.dirname(p) }.each do |dir, group|
|
|
105
|
+
next if seen[File.expand_path(dir)]
|
|
106
|
+
seen[File.expand_path(dir)] = true
|
|
107
|
+
candidates << {
|
|
108
|
+
dir: dir,
|
|
109
|
+
rel: dir.delete_prefix(root).delete_prefix("/"),
|
|
110
|
+
path: resolve_same_dir_tie(dir, group, warnings)
|
|
111
|
+
}
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
pair_with_templates(candidates, spec, warnings: warnings)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def resolve_same_dir_tie(dir, group, warnings)
|
|
119
|
+
return group.first unless group.size > 1
|
|
120
|
+
warnings << "skip #{File.join(dir, "SKILL.md.erb")}: SKILL.md in the same directory takes precedence"
|
|
121
|
+
group.find { |p| File.basename(p) == "SKILL.md" }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# A dir holding a static SKILL.md pairs with <templates root>/<same
|
|
125
|
+
# relative path>/SKILL.md.erb in a distinct dir: the rendered template is
|
|
126
|
+
# the definition, the static dir the support root. The static SKILL.md is
|
|
127
|
+
# never parsed — falling back to it when the template fails to render
|
|
128
|
+
# would silently un-condition the skill.
|
|
129
|
+
def pair_with_templates(candidates, spec, warnings:)
|
|
130
|
+
templates_root = File.join(spec.full_gem_path, skill_templates_dir(spec))
|
|
131
|
+
|
|
132
|
+
pairs = {}
|
|
133
|
+
candidates.each do |cand|
|
|
134
|
+
next unless File.basename(cand[:path]) == "SKILL.md"
|
|
135
|
+
template_dir = File.expand_path(File.join(templates_root, cand[:rel]))
|
|
136
|
+
next if template_dir == File.expand_path(cand[:dir])
|
|
137
|
+
template = File.join(template_dir, "SKILL.md.erb")
|
|
138
|
+
next unless File.file?(template)
|
|
139
|
+
warn_template_extras(template_dir, warnings)
|
|
140
|
+
pairs[cand[:dir]] = { template: template, template_dir: template_dir }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
consumed = pairs.values.map { |p| p[:template_dir] }
|
|
144
|
+
candidates.filter_map do |cand|
|
|
145
|
+
if (pair = pairs[cand[:dir]])
|
|
146
|
+
[pair[:template], cand[:dir], cand[:rel]]
|
|
147
|
+
elsif consumed.include?(File.expand_path(cand[:dir]))
|
|
148
|
+
nil
|
|
149
|
+
else
|
|
150
|
+
[cand[:path], File.dirname(cand[:path]), cand[:rel]]
|
|
151
|
+
end
|
|
73
152
|
end
|
|
74
153
|
end
|
|
75
154
|
|
|
155
|
+
# The content dir is the single source of truth for supporting files.
|
|
156
|
+
def warn_template_extras(template_dir, warnings)
|
|
157
|
+
extras = Dir.glob(File.join(template_dir, "**", "*"))
|
|
158
|
+
.select { |f| File.file?(f) && f != File.join(template_dir, "SKILL.md.erb") }
|
|
159
|
+
return if extras.empty?
|
|
160
|
+
warnings << "#{template_dir}: ignoring #{extras.size} file(s) besides SKILL.md.erb; " \
|
|
161
|
+
"supporting files ship in the paired content directory"
|
|
162
|
+
end
|
|
163
|
+
|
|
76
164
|
def guideline_paths(spec)
|
|
77
165
|
root = File.join(spec.full_gem_path, "lib", spec.name, "hyperdrive", "guidelines")
|
|
78
166
|
Dir.glob(File.join(root, "*.md"))
|
|
79
167
|
end
|
|
80
168
|
|
|
169
|
+
# Many gemspecs package files via `git ls-files`, so a contributor-facing
|
|
170
|
+
# skills/ dir ships by accident — package contents don't signal consumer
|
|
171
|
+
# intent. Only an explicit signal makes a gem's content installable.
|
|
172
|
+
def opted_in?(spec, enabled_gems:)
|
|
173
|
+
return true if enabled_gems.include?(spec.name.to_s)
|
|
174
|
+
return true if metadata_present?(spec, "rails_hyperdrive_skills_dir")
|
|
175
|
+
return true if metadata_present?(spec, "rails_hyperdrive_skill_templates_dir")
|
|
176
|
+
return true if metadata_present?(spec, "rails_hyperdrive_targets")
|
|
177
|
+
return true if GemManifest.opt_in?(spec)
|
|
178
|
+
|
|
179
|
+
convention_root = File.join(spec.full_gem_path, "lib", spec.name, "hyperdrive")
|
|
180
|
+
Dir.glob(File.join(convention_root, "skills", "**", "{SKILL.md,SKILL.md.erb}")).any? ||
|
|
181
|
+
Dir.glob(File.join(convention_root, "guidelines", "*.md")).any?
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def metadata_present?(spec, key)
|
|
185
|
+
return false unless spec.respond_to?(:metadata)
|
|
186
|
+
raw = spec.metadata && spec.metadata[key]
|
|
187
|
+
!raw.to_s.strip.empty?
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Report-only, glob-only (no file reads): SKILL.md presence is the
|
|
191
|
+
# signal, and SKILL.md.erb is excluded — raw ERB is not skills.sh
|
|
192
|
+
# content. Parse problems surface as warnings once the gem is enabled.
|
|
193
|
+
def notice_skills_sh_content(spec, notices:)
|
|
194
|
+
found = Dir.glob(File.join(spec.full_gem_path, "skills", "**", "SKILL.md"))
|
|
195
|
+
return if found.empty?
|
|
196
|
+
|
|
197
|
+
count = found.map { |p| File.dirname(p) }.uniq.size
|
|
198
|
+
notices << "gem '#{spec.name}' ships #{count} skills.sh skill(s); add \"#{spec.name}\" to enabled: " \
|
|
199
|
+
"in .hyperdrive/lock.yml and re-run bin/rails hyperdrive:sync to install them"
|
|
200
|
+
end
|
|
201
|
+
|
|
81
202
|
# ".." segments are rejected to prevent escaping the gem root.
|
|
82
203
|
def skills_dir_override(spec)
|
|
83
204
|
return nil unless spec.respond_to?(:metadata)
|
|
@@ -87,7 +208,19 @@ module Rails
|
|
|
87
208
|
raw.to_s
|
|
88
209
|
end
|
|
89
210
|
|
|
90
|
-
def
|
|
211
|
+
def skill_templates_dir(spec)
|
|
212
|
+
default = File.join("lib", spec.name, "hyperdrive", "skills")
|
|
213
|
+
return default unless spec.respond_to?(:metadata)
|
|
214
|
+
raw = spec.metadata && spec.metadata["rails_hyperdrive_skill_templates_dir"]
|
|
215
|
+
return default if raw.nil? || raw.to_s.strip.empty?
|
|
216
|
+
return default if raw.to_s.split(%r{[/\\]}).include?("..")
|
|
217
|
+
raw.to_s
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Frontmatter's schema is exactly name and description; any other key is
|
|
221
|
+
# unknown to the parser and rides untouched in the installed body.
|
|
222
|
+
def parse(path, source_spec:, type:, resolved:, warnings:, gate:, support_root: nil)
|
|
223
|
+
support_root ||= File.dirname(path)
|
|
91
224
|
body = File.read(path)
|
|
92
225
|
if erb_template?(path)
|
|
93
226
|
begin
|
|
@@ -103,25 +236,20 @@ module Rails
|
|
|
103
236
|
return nil
|
|
104
237
|
end
|
|
105
238
|
|
|
106
|
-
|
|
239
|
+
# Date is permitted because unknown frontmatter keys are user content;
|
|
240
|
+
# a bare date value must not fail the parse.
|
|
241
|
+
meta = YAML.safe_load(frontmatter, permitted_classes: [Symbol, Date]) || {}
|
|
107
242
|
name = meta["name"]
|
|
108
243
|
description = meta["description"]
|
|
109
|
-
versions = meta["versions"]
|
|
110
|
-
|
|
111
|
-
unless name && description && meta["gem"] && versions
|
|
112
|
-
warnings << "skip #{path}: missing a required field (name, description, gem, versions)"
|
|
113
|
-
return nil
|
|
114
|
-
end
|
|
115
244
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
warnings << "skip #{name} (from #{source_spec.name}): gem: must name a gem, a comma-separated list, or a YAML list"
|
|
245
|
+
unless name && description
|
|
246
|
+
warnings << "skip #{path}: missing a required field (name, description)"
|
|
119
247
|
return nil
|
|
120
248
|
end
|
|
121
249
|
|
|
122
|
-
matched = match_targets(targets, versions, resolved)
|
|
250
|
+
matched = match_targets(gate.targets, gate.versions, resolved)
|
|
123
251
|
if matched.empty?
|
|
124
|
-
warnings << "skip #{name} (from #{source_spec.name}): #{no_match_reason(targets, versions, resolved)}"
|
|
252
|
+
warnings << "skip #{name} (from #{source_spec.name}): #{no_match_reason(gate.targets, gate.versions, resolved)}"
|
|
125
253
|
return nil
|
|
126
254
|
end
|
|
127
255
|
|
|
@@ -129,17 +257,18 @@ module Rails
|
|
|
129
257
|
name: name.to_s,
|
|
130
258
|
description: description.to_s,
|
|
131
259
|
target_gem: matched,
|
|
132
|
-
versions: versions,
|
|
260
|
+
versions: gate.versions,
|
|
133
261
|
artifact_type: type,
|
|
134
262
|
source_gem: source_spec.name.to_s,
|
|
135
263
|
path: path,
|
|
136
264
|
body: body,
|
|
137
265
|
spec_version: source_spec.version.to_s,
|
|
138
266
|
source_root: source_spec.full_gem_path.to_s,
|
|
267
|
+
support_root: support_root,
|
|
139
268
|
support_files:
|
|
140
269
|
if type == :skill
|
|
141
270
|
conditioned_support_files(
|
|
142
|
-
|
|
271
|
+
support_root, gate.conditional,
|
|
143
272
|
resolved: resolved, warnings: warnings,
|
|
144
273
|
label: "#{name} (from #{source_spec.name})"
|
|
145
274
|
)
|
|
@@ -147,7 +276,7 @@ module Rails
|
|
|
147
276
|
[]
|
|
148
277
|
end
|
|
149
278
|
)
|
|
150
|
-
rescue Psych::
|
|
279
|
+
rescue Psych::Exception
|
|
151
280
|
warnings << "skip #{path}: malformed YAML frontmatter"
|
|
152
281
|
nil
|
|
153
282
|
end
|
|
@@ -185,7 +314,7 @@ module Rails
|
|
|
185
314
|
shipped = files.map { |f| f[:path] }
|
|
186
315
|
conditions.each_key do |key|
|
|
187
316
|
if SKILL_FILE_NAMES.include?(key)
|
|
188
|
-
warnings << "#{label}: conditional key '#{key}' ignored; the
|
|
317
|
+
warnings << "#{label}: conditional key '#{key}' ignored; the entry's own gem:/versions: gate the whole skill"
|
|
189
318
|
elsif !shipped.include?(key)
|
|
190
319
|
warnings << "#{label}: conditional key '#{key}' names no shipped supporting file"
|
|
191
320
|
end
|
|
@@ -203,14 +332,14 @@ module Rails
|
|
|
203
332
|
return true
|
|
204
333
|
end
|
|
205
334
|
|
|
206
|
-
targets = entry["gem"] && parse_targets(entry["gem"])
|
|
335
|
+
targets = entry["gem"] && GemManifest.parse_targets(entry["gem"])
|
|
207
336
|
if targets.nil? || targets.empty?
|
|
208
337
|
warnings << "#{label}: conditional entry for '#{key}' needs gem: naming a gem, a comma-separated list, or a YAML list; installing the file"
|
|
209
338
|
return true
|
|
210
339
|
end
|
|
211
340
|
|
|
212
341
|
versions = entry["versions"]
|
|
213
|
-
if malformed_requirements?(versions)
|
|
342
|
+
if GemManifest.malformed_requirements?(versions)
|
|
214
343
|
warnings << "#{label}: conditional entry for '#{key}' has an unparsable versions: requirement; installing the file"
|
|
215
344
|
return true
|
|
216
345
|
end
|
|
@@ -218,20 +347,6 @@ module Rails
|
|
|
218
347
|
match_targets(targets, versions, resolved).any?
|
|
219
348
|
end
|
|
220
349
|
|
|
221
|
-
# versions: is optional in a conditional entry; nil means unconstrained.
|
|
222
|
-
def malformed_requirements?(versions)
|
|
223
|
-
requirements = versions.is_a?(Hash) ? versions.values : [versions]
|
|
224
|
-
requirements.compact.any? do |req|
|
|
225
|
-
parts = Array(req).flat_map { |s| s.is_a?(String) ? s.split(",").map(&:strip) : s }
|
|
226
|
-
begin
|
|
227
|
-
Gem::Requirement.new(*parts)
|
|
228
|
-
false
|
|
229
|
-
rescue ArgumentError
|
|
230
|
-
true
|
|
231
|
-
end
|
|
232
|
-
end
|
|
233
|
-
end
|
|
234
|
-
|
|
235
350
|
def render_support_templates(files, skill_dir, resolved:, warnings:)
|
|
236
351
|
plain_paths = files.map { |f| f[:path] }
|
|
237
352
|
files.filter_map do |file|
|
|
@@ -256,36 +371,15 @@ module Rails
|
|
|
256
371
|
path.end_with?(".md.erb")
|
|
257
372
|
end
|
|
258
373
|
|
|
374
|
+
# Guideline frontmatter is stripped because the installed file is
|
|
375
|
+
# @-included eagerly into agent context, where it would be inert noise.
|
|
259
376
|
def install_ready_body(artifact)
|
|
260
|
-
return
|
|
377
|
+
return artifact.body if artifact.skill?
|
|
261
378
|
|
|
262
379
|
_frontmatter, rest = split_frontmatter(artifact.body)
|
|
263
380
|
(rest || artifact.body).sub(/\A\n+/, "")
|
|
264
381
|
end
|
|
265
382
|
|
|
266
|
-
# gem:/versions:/conditional: are install-time inputs with no reader
|
|
267
|
-
# after install, and conditional: keys name *shipped* paths that gating
|
|
268
|
-
# and ERB retargeting can leave pointing at files absent from disk — so
|
|
269
|
-
# the installed frontmatter carries neither.
|
|
270
|
-
def strip_installer_keys(body)
|
|
271
|
-
frontmatter, rest = split_frontmatter(body)
|
|
272
|
-
return body unless frontmatter
|
|
273
|
-
|
|
274
|
-
kept = []
|
|
275
|
-
skipping = false
|
|
276
|
-
frontmatter.lines.each do |line|
|
|
277
|
-
if line =~ INSTALLER_KEY
|
|
278
|
-
skipping = true
|
|
279
|
-
elsif skipping && (line.start_with?(" ", "\t") || line.strip.empty?)
|
|
280
|
-
# continuation of a stripped key's block
|
|
281
|
-
else
|
|
282
|
-
skipping = false
|
|
283
|
-
kept << line
|
|
284
|
-
end
|
|
285
|
-
end
|
|
286
|
-
"---\n#{kept.join}---\n#{rest}"
|
|
287
|
-
end
|
|
288
|
-
|
|
289
383
|
def split_frontmatter(body)
|
|
290
384
|
lines = body.lines
|
|
291
385
|
return [nil, body] unless lines.first&.strip == "---"
|
|
@@ -297,12 +391,6 @@ module Rails
|
|
|
297
391
|
[lines[1...absolute_closing].join, lines[(absolute_closing + 1)..].join]
|
|
298
392
|
end
|
|
299
393
|
|
|
300
|
-
def parse_targets(raw)
|
|
301
|
-
entries = raw.is_a?(Array) ? raw : [raw]
|
|
302
|
-
return nil if entries.any? { |e| e.nil? || e.is_a?(Array) || e.is_a?(Hash) }
|
|
303
|
-
entries.flat_map { |e| e.to_s.split(",") }.map(&:strip).reject(&:empty?)
|
|
304
|
-
end
|
|
305
|
-
|
|
306
394
|
def match_targets(targets, versions, resolved)
|
|
307
395
|
return ["*"] if targets.include?("*")
|
|
308
396
|
|
|
@@ -334,13 +422,15 @@ module Rails
|
|
|
334
422
|
[]
|
|
335
423
|
end
|
|
336
424
|
|
|
337
|
-
private_class_method :each_artifact_path, :
|
|
338
|
-
:
|
|
425
|
+
private_class_method :each_artifact_path, :warn_unknown_manifest_keys, :skill_paths,
|
|
426
|
+
:guideline_paths,
|
|
427
|
+
:resolve_same_dir_tie, :pair_with_templates, :warn_template_extras,
|
|
428
|
+
:opted_in?, :metadata_present?, :notice_skills_sh_content,
|
|
429
|
+
:skills_dir_override, :skill_templates_dir, :parse, :support_files_for,
|
|
339
430
|
:conditioned_support_files, :apply_conditional_filter,
|
|
340
|
-
:conditional_satisfied?,
|
|
431
|
+
:conditional_satisfied?,
|
|
341
432
|
:render_support_templates, :erb_template?,
|
|
342
|
-
:
|
|
343
|
-
:split_frontmatter, :parse_targets, :match_targets,
|
|
433
|
+
:match_targets,
|
|
344
434
|
:version_satisfied?, :no_match_reason, :safe_bundler_specs
|
|
345
435
|
end
|
|
346
436
|
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
require "yaml"
|
|
3
|
+
require "rails/hyperdrive/bundler_artifact_discovery"
|
|
4
|
+
require "rails/hyperdrive/skill_template"
|
|
5
|
+
|
|
6
|
+
module Rails
|
|
7
|
+
module Hyperdrive
|
|
8
|
+
# Renders each SKILL.md.erb template in a companion gem repo to the static
|
|
9
|
+
# SKILL.md it pairs with, using the canonical fail-open binding.
|
|
10
|
+
# Companion-repo dev tooling: problems raise.
|
|
11
|
+
module CanonicalSkillRender
|
|
12
|
+
Error = Class.new(StandardError)
|
|
13
|
+
|
|
14
|
+
Rendered = Struct.new(:template, :dest, :body, keyword_init: true)
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
def write(gemspec: nil, dir: Dir.pwd)
|
|
19
|
+
render_all(gemspec: gemspec, dir: dir).each do |r|
|
|
20
|
+
FileUtils.mkdir_p(File.dirname(r.dest))
|
|
21
|
+
File.write(r.dest, r.body)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def stale(gemspec: nil, dir: Dir.pwd)
|
|
26
|
+
render_all(gemspec: gemspec, dir: dir).reject do |r|
|
|
27
|
+
File.file?(r.dest) && File.read(r.dest) == r.body
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def render_all(gemspec: nil, dir: Dir.pwd)
|
|
32
|
+
spec_path = resolve_gemspec_path(gemspec, dir)
|
|
33
|
+
spec = Gem::Specification.load(spec_path)
|
|
34
|
+
raise Error, "could not load gemspec #{spec_path}" unless spec
|
|
35
|
+
|
|
36
|
+
gem_root = File.dirname(File.expand_path(spec_path))
|
|
37
|
+
skills_root = File.join(gem_root, metadata_dir(spec, "rails_hyperdrive_skills_dir"))
|
|
38
|
+
templates_root = File.join(gem_root, metadata_dir(spec, "rails_hyperdrive_skill_templates_dir"))
|
|
39
|
+
|
|
40
|
+
Dir.glob(File.join(templates_root, "**", "SKILL.md.erb")).sort.map do |template|
|
|
41
|
+
rel = File.dirname(template).delete_prefix(templates_root).delete_prefix("/")
|
|
42
|
+
dest_dir = File.join(skills_root, rel)
|
|
43
|
+
# A SKILL.md written beside the SKILL.md.erb would take precedence
|
|
44
|
+
# over it at discovery time, silently demoting the skill to its
|
|
45
|
+
# static face.
|
|
46
|
+
if File.expand_path(dest_dir) == File.expand_path(File.dirname(template))
|
|
47
|
+
raise Error, "#{template}: content dir equals template dir; set " \
|
|
48
|
+
"spec.metadata[\"rails_hyperdrive_skills_dir\"] to a separate root"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
body = render_template(template)
|
|
52
|
+
validate_output!(body, template)
|
|
53
|
+
Rendered.new(template: template, dest: File.join(dest_dir, "SKILL.md"), body: body)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def resolve_gemspec_path(explicit, dir)
|
|
58
|
+
if explicit && !explicit.to_s.strip.empty?
|
|
59
|
+
path = File.expand_path(explicit.to_s, dir)
|
|
60
|
+
raise Error, "gemspec not found: #{path}" unless File.file?(path)
|
|
61
|
+
return path
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
found = Dir.glob(File.join(dir, "*.gemspec"))
|
|
65
|
+
case found.size
|
|
66
|
+
when 1 then File.expand_path(found.first)
|
|
67
|
+
when 0 then raise Error, "no .gemspec found in #{dir}; pass an explicit path, " \
|
|
68
|
+
"e.g. rake \"hyperdrive:skills:render[path/to/name.gemspec]\""
|
|
69
|
+
else raise Error, "multiple gemspecs found in #{dir} " \
|
|
70
|
+
"(#{found.map { |f| File.basename(f) }.join(", ")}); pass an explicit path"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def metadata_dir(spec, key)
|
|
75
|
+
default = File.join("lib", spec.name, "hyperdrive", "skills")
|
|
76
|
+
raw = spec.metadata && spec.metadata[key]
|
|
77
|
+
return default if raw.nil? || raw.to_s.strip.empty?
|
|
78
|
+
raise Error, "gemspec metadata #{key} must not contain '..' segments" if
|
|
79
|
+
raw.to_s.split(%r{[/\\]}).include?("..")
|
|
80
|
+
raw.to_s
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def render_template(template)
|
|
84
|
+
SkillTemplate.render_canonical(File.read(template))
|
|
85
|
+
rescue SyntaxError, StandardError => e
|
|
86
|
+
raise Error, "#{template}: canonical render failed (#{e.message})"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# A static face with unusable frontmatter would ship a broken skill to
|
|
90
|
+
# consumers that read it directly.
|
|
91
|
+
def validate_output!(body, template)
|
|
92
|
+
frontmatter, = BundlerArtifactDiscovery.split_frontmatter(body)
|
|
93
|
+
raise Error, "#{template}: rendered output has no YAML frontmatter" unless frontmatter
|
|
94
|
+
|
|
95
|
+
meta = YAML.safe_load(frontmatter, permitted_classes: [Symbol]) || {}
|
|
96
|
+
return if meta["name"] && meta["description"]
|
|
97
|
+
raise Error, "#{template}: rendered frontmatter lacks name: or description:"
|
|
98
|
+
rescue Psych::SyntaxError
|
|
99
|
+
raise Error, "#{template}: rendered frontmatter is not parseable YAML"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private_class_method :resolve_gemspec_path, :metadata_dir, :render_template, :validate_output!
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
require "yaml"
|
|
2
|
+
|
|
3
|
+
module Rails
|
|
4
|
+
module Hyperdrive
|
|
5
|
+
# A companion gem's root manifest (hyperdrive.yml) declares artifact
|
|
6
|
+
# gating: top-level gem:/versions: defaults for the whole gem,
|
|
7
|
+
# per-skill entries keyed by the skill dir's relative path from its skills
|
|
8
|
+
# root, per-guideline entries keyed by filename. Fail-open at every level:
|
|
9
|
+
# malformed input warns and resolves to an ungated install — an artifact is
|
|
10
|
+
# never skipped because its gating could not be read, and nothing raises.
|
|
11
|
+
class GemManifest
|
|
12
|
+
FILE_NAME = "hyperdrive.yml"
|
|
13
|
+
METADATA_KEY = "rails_hyperdrive_manifest"
|
|
14
|
+
|
|
15
|
+
UNGATED = ["*"].freeze
|
|
16
|
+
|
|
17
|
+
Gate = Struct.new(:targets, :versions, :conditional, keyword_init: true)
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
def load(spec, warnings: [])
|
|
21
|
+
new(spec, warnings: warnings)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# The metadata key counts even when its value is unusable — declaring
|
|
25
|
+
# it at all signals companion intent.
|
|
26
|
+
def opt_in?(spec)
|
|
27
|
+
return true if File.file?(File.join(spec.full_gem_path, FILE_NAME))
|
|
28
|
+
return false unless spec.respond_to?(:metadata)
|
|
29
|
+
|
|
30
|
+
raw = spec.metadata && spec.metadata[METADATA_KEY]
|
|
31
|
+
!raw.to_s.strip.empty?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def parse_targets(raw)
|
|
35
|
+
entries = raw.is_a?(Array) ? raw : [raw]
|
|
36
|
+
return nil if entries.any? { |e| e.nil? || e.is_a?(Array) || e.is_a?(Hash) }
|
|
37
|
+
entries.flat_map { |e| e.to_s.split(",") }.map(&:strip).reject(&:empty?)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# versions: is optional everywhere; nil means unconstrained.
|
|
41
|
+
def malformed_requirements?(versions)
|
|
42
|
+
requirements = versions.is_a?(Hash) ? versions.values : [versions]
|
|
43
|
+
requirements.compact.any? do |req|
|
|
44
|
+
parts = Array(req).flat_map { |s| s.is_a?(String) ? s.split(",").map(&:strip) : s }
|
|
45
|
+
begin
|
|
46
|
+
Gem::Requirement.new(*parts)
|
|
47
|
+
false
|
|
48
|
+
rescue ArgumentError
|
|
49
|
+
true
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def initialize(spec, warnings:)
|
|
56
|
+
@spec = spec
|
|
57
|
+
@warnings = warnings
|
|
58
|
+
root = load_root
|
|
59
|
+
@skills = section(root, "skills", "skill relpath")
|
|
60
|
+
@guidelines = section(root, "guidelines", "guideline filename")
|
|
61
|
+
@default_targets, @default_versions = defaults(root)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def skill_gate(rel)
|
|
65
|
+
return default_gate unless @skills.key?(rel)
|
|
66
|
+
entry_gate(@skills[rel], rel, with_conditional: true)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def guideline_gate(filename)
|
|
70
|
+
return default_gate unless @guidelines.key?(filename)
|
|
71
|
+
entry_gate(@guidelines[filename], filename, with_conditional: false)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def skill_keys
|
|
75
|
+
@skills.keys
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def guideline_keys
|
|
79
|
+
@guidelines.keys
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def report(message)
|
|
85
|
+
@warnings << "#{@spec.name}: #{message}"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# ".." segments are rejected (silent fallback to the conventional path)
|
|
89
|
+
# to prevent escaping the gem root.
|
|
90
|
+
def manifest_relpath
|
|
91
|
+
return FILE_NAME unless @spec.respond_to?(:metadata)
|
|
92
|
+
raw = @spec.metadata && @spec.metadata[METADATA_KEY]
|
|
93
|
+
return FILE_NAME if raw.nil? || raw.to_s.strip.empty?
|
|
94
|
+
return FILE_NAME if raw.to_s.split(%r{[/\\]}).include?("..")
|
|
95
|
+
raw.to_s
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# An empty file is a valid manifest (nothing gated); only content that
|
|
99
|
+
# cannot be read as a YAML map warns.
|
|
100
|
+
def load_root
|
|
101
|
+
path = File.join(@spec.full_gem_path, manifest_relpath)
|
|
102
|
+
return {} unless File.file?(path)
|
|
103
|
+
|
|
104
|
+
data = YAML.safe_load(File.read(path), permitted_classes: [Symbol])
|
|
105
|
+
return {} if data.nil?
|
|
106
|
+
unless data.is_a?(Hash)
|
|
107
|
+
report("ignoring manifest #{manifest_relpath}: root must be a YAML map")
|
|
108
|
+
return {}
|
|
109
|
+
end
|
|
110
|
+
data.transform_keys(&:to_s)
|
|
111
|
+
rescue Psych::SyntaxError
|
|
112
|
+
report("ignoring manifest #{manifest_relpath}: malformed YAML")
|
|
113
|
+
{}
|
|
114
|
+
rescue StandardError => e
|
|
115
|
+
report("ignoring manifest #{manifest_relpath}: unreadable (#{e.message})")
|
|
116
|
+
{}
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def section(root, key, key_label)
|
|
120
|
+
value = root[key]
|
|
121
|
+
return {} if value.nil?
|
|
122
|
+
unless value.is_a?(Hash)
|
|
123
|
+
report("manifest #{key}: must be a map of #{key_label} to {gem:, versions:}; ignoring the section")
|
|
124
|
+
return {}
|
|
125
|
+
end
|
|
126
|
+
value.transform_keys(&:to_s)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def defaults(root)
|
|
130
|
+
targets = root.key?("gem") ? self.class.parse_targets(root["gem"]) : nil
|
|
131
|
+
bad_targets = root.key?("gem") && (targets.nil? || targets.empty?)
|
|
132
|
+
if bad_targets || self.class.malformed_requirements?(root["versions"])
|
|
133
|
+
report("manifest top-level gem:/versions: defaults are unusable; ignoring them")
|
|
134
|
+
return [nil, nil]
|
|
135
|
+
end
|
|
136
|
+
[targets, root["versions"]]
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def default_gate
|
|
140
|
+
Gate.new(targets: @default_targets || UNGATED, versions: @default_versions, conditional: nil)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def ungated
|
|
144
|
+
Gate.new(targets: UNGATED, versions: nil, conditional: nil)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# A malformed entry drops the gem-wide defaults too: gating that cannot
|
|
148
|
+
# be read must not skip the artifact, so it installs ungated.
|
|
149
|
+
def entry_gate(entry, key, with_conditional:)
|
|
150
|
+
unless entry.is_a?(Hash)
|
|
151
|
+
report("manifest entry for '#{key}' must be a map with gem:/versions:; installing ungated")
|
|
152
|
+
return ungated
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
entry = entry.transform_keys(&:to_s)
|
|
156
|
+
targets = entry.key?("gem") ? self.class.parse_targets(entry["gem"]) : nil
|
|
157
|
+
if entry.key?("gem") && (targets.nil? || targets.empty?)
|
|
158
|
+
report("manifest entry for '#{key}': gem: must name a gem, a comma-separated list, " \
|
|
159
|
+
"or a YAML list; installing ungated")
|
|
160
|
+
return ungated
|
|
161
|
+
end
|
|
162
|
+
if self.class.malformed_requirements?(entry["versions"])
|
|
163
|
+
report("manifest entry for '#{key}' has an unparsable versions: requirement; installing ungated")
|
|
164
|
+
return ungated
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
Gate.new(
|
|
168
|
+
targets: targets || @default_targets || UNGATED,
|
|
169
|
+
versions: entry.key?("versions") ? entry["versions"] : @default_versions,
|
|
170
|
+
conditional: with_conditional ? entry["conditional"] : nil
|
|
171
|
+
)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
@@ -26,7 +26,7 @@ module Rails
|
|
|
26
26
|
Result = Struct.new(:installed, :updated, :unchanged, :skipped, :orphaned, :removed, :merged, :sidecars,
|
|
27
27
|
keyword_init: true)
|
|
28
28
|
|
|
29
|
-
def initialize(root:, shell:, artifacts:, mode: :preserve, warnings: [])
|
|
29
|
+
def initialize(root:, shell:, artifacts:, mode: :preserve, warnings: [], notices: [])
|
|
30
30
|
raise ArgumentError, "unknown mode #{mode.inspect}" unless MODES.include?(mode)
|
|
31
31
|
|
|
32
32
|
@root = File.expand_path(root.to_s)
|
|
@@ -34,6 +34,7 @@ module Rails
|
|
|
34
34
|
@artifacts = artifacts
|
|
35
35
|
@mode = mode
|
|
36
36
|
@warnings = warnings
|
|
37
|
+
@notices = notices
|
|
37
38
|
@result = Result.new(
|
|
38
39
|
installed: [], updated: [], unchanged: [], skipped: [], orphaned: [], removed: [], merged: [], sidecars: []
|
|
39
40
|
)
|
|
@@ -54,6 +55,7 @@ module Rails
|
|
|
54
55
|
write_claude_md(guidelines)
|
|
55
56
|
write_lock
|
|
56
57
|
print_warnings
|
|
58
|
+
print_notices
|
|
57
59
|
print_footprint(guidelines: eager_guidelines)
|
|
58
60
|
warn_if_destinations_gitignored
|
|
59
61
|
@result
|
|
@@ -115,6 +117,7 @@ module Rails
|
|
|
115
117
|
def install_skills
|
|
116
118
|
@plan.select { |e| e.type == :skill }.each do |entry|
|
|
117
119
|
skill_relpath = source_relpath_for(entry.artifact)
|
|
120
|
+
support_base = support_relpath_base(entry.artifact, skill_relpath)
|
|
118
121
|
install_file(entry: entry, type: :skill, install_ready_body: entry.install_ready_body,
|
|
119
122
|
source_relpath: skill_relpath, final_name: entry.final_name)
|
|
120
123
|
entry.support_files.each do |file|
|
|
@@ -125,7 +128,7 @@ module Rails
|
|
|
125
128
|
source_gem: entry.source_gem,
|
|
126
129
|
version: entry.version,
|
|
127
130
|
artifact_kind: "skill_support",
|
|
128
|
-
source_relpath:
|
|
131
|
+
source_relpath: support_base && File.join(support_base, file[:path])
|
|
129
132
|
)
|
|
130
133
|
end
|
|
131
134
|
end
|
|
@@ -155,6 +158,22 @@ module Rails
|
|
|
155
158
|
rel.end_with?(".md.erb") ? rel.delete_suffix(".erb") : rel
|
|
156
159
|
end
|
|
157
160
|
|
|
161
|
+
# Supporting files live under the artifact's support root, which for a
|
|
162
|
+
# template-paired skill is not the definition file's directory.
|
|
163
|
+
def support_relpath_base(artifact, skill_relpath)
|
|
164
|
+
support_root = artifact.support_root
|
|
165
|
+
return skill_relpath && File.dirname(skill_relpath) if support_root.nil? || support_root.to_s.empty?
|
|
166
|
+
|
|
167
|
+
root = artifact.source_root
|
|
168
|
+
return nil if root.nil? || root.to_s.empty?
|
|
169
|
+
|
|
170
|
+
prefix = "#{File.expand_path(root.to_s)}/"
|
|
171
|
+
expanded = File.expand_path(support_root.to_s)
|
|
172
|
+
return nil unless expanded.start_with?(prefix)
|
|
173
|
+
|
|
174
|
+
expanded.delete_prefix(prefix)
|
|
175
|
+
end
|
|
176
|
+
|
|
158
177
|
def install_file(dest: nil, type:, install_ready_body:, entry: nil, source_gem: nil, version: nil,
|
|
159
178
|
artifact_kind: nil, source_relpath: nil, final_name: nil)
|
|
160
179
|
write = {
|
|
@@ -524,6 +543,12 @@ module Rails
|
|
|
524
543
|
@warnings.each { |w| @shell.say " - #{w}" }
|
|
525
544
|
end
|
|
526
545
|
|
|
546
|
+
def print_notices
|
|
547
|
+
return if additive? || Array(@notices).empty?
|
|
548
|
+
@shell.say ""
|
|
549
|
+
@notices.each { |n| @shell.say_status :info, n, :blue }
|
|
550
|
+
end
|
|
551
|
+
|
|
527
552
|
def print_footprint(guidelines:)
|
|
528
553
|
return if additive?
|
|
529
554
|
|
|
@@ -33,6 +33,7 @@ module Rails
|
|
|
33
33
|
@files = {} # path(String) => Entry
|
|
34
34
|
@document = {} # raw parsed YAML, kept so unknown keys survive
|
|
35
35
|
@disabled = empty_disabled
|
|
36
|
+
@enabled = []
|
|
36
37
|
end
|
|
37
38
|
|
|
38
39
|
def read
|
|
@@ -45,6 +46,7 @@ module Rails
|
|
|
45
46
|
claude_md = data["claude_md"]
|
|
46
47
|
@claude_md_state = claude_md["state"] if claude_md.is_a?(Hash)
|
|
47
48
|
@disabled = parse_disabled(data["disabled"])
|
|
49
|
+
@enabled = parse_enabled(data["enabled"])
|
|
48
50
|
Array(data["files"]).each do |raw|
|
|
49
51
|
next unless raw.is_a?(Hash)
|
|
50
52
|
entry = build_entry(raw)
|
|
@@ -71,11 +73,17 @@ module Rails
|
|
|
71
73
|
Array(@disabled[type.to_sym]).include?(name.to_s)
|
|
72
74
|
end
|
|
73
75
|
|
|
76
|
+
# Hand-editable list of gems the app opts into as companions.
|
|
77
|
+
def enabled_gems
|
|
78
|
+
@enabled
|
|
79
|
+
end
|
|
80
|
+
|
|
74
81
|
# Adopt the state that is not derived from installed content, so
|
|
75
82
|
# rewriting the file preserves it.
|
|
76
83
|
def carry_settings(other)
|
|
77
84
|
@document = other.document.dup
|
|
78
85
|
@disabled = other.disabled_lists.dup
|
|
86
|
+
@enabled = other.enabled_gems.dup
|
|
79
87
|
self
|
|
80
88
|
end
|
|
81
89
|
|
|
@@ -103,6 +111,7 @@ module Rails
|
|
|
103
111
|
"version" => SCHEMA_VERSION,
|
|
104
112
|
"claude_md" => carried.merge("state" => @claude_md_state),
|
|
105
113
|
"disabled" => DISABLED_KEYS.each_with_object({}) { |(type, key), h| h[key] = @disabled[type] },
|
|
114
|
+
"enabled" => @enabled,
|
|
106
115
|
"files" => @files.values.sort_by(&:path).map { |e| serialize_entry(e) }
|
|
107
116
|
)
|
|
108
117
|
# A nil state means no import line is being managed. Recording one anyway
|
|
@@ -136,6 +145,12 @@ module Rails
|
|
|
136
145
|
end
|
|
137
146
|
end
|
|
138
147
|
|
|
148
|
+
def parse_enabled(raw)
|
|
149
|
+
return [] unless raw.is_a?(Array)
|
|
150
|
+
|
|
151
|
+
raw.map { |name| name.to_s.strip }.reject(&:empty?).uniq
|
|
152
|
+
end
|
|
153
|
+
|
|
139
154
|
def build_entry(raw)
|
|
140
155
|
source_gem, source_version = split_source(raw["source"])
|
|
141
156
|
Entry.new(
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Rake tasks for companion gem repos: add `require "rails/hyperdrive/skill_tasks"`
|
|
2
|
+
# to the Rakefile. Both tasks take an optional gemspec-path argument, e.g.
|
|
3
|
+
# rake "hyperdrive:skills:render[path/to/name.gemspec]".
|
|
4
|
+
require "rake"
|
|
5
|
+
require "rails/hyperdrive/canonical_skill_render"
|
|
6
|
+
|
|
7
|
+
namespace :hyperdrive do
|
|
8
|
+
namespace :skills do
|
|
9
|
+
desc "Render each SKILL.md.erb template to its canonical static SKILL.md"
|
|
10
|
+
task :render, [:gemspec] do |_t, args|
|
|
11
|
+
written = Rails::Hyperdrive::CanonicalSkillRender.write(gemspec: args[:gemspec])
|
|
12
|
+
written.each { |r| puts "render #{r.dest}" }
|
|
13
|
+
puts "no skill templates found" if written.empty?
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
desc "Fail when a canonical SKILL.md is stale relative to its template"
|
|
17
|
+
task :check, [:gemspec] do |_t, args|
|
|
18
|
+
stale = Rails::Hyperdrive::CanonicalSkillRender.stale(gemspec: args[:gemspec])
|
|
19
|
+
if stale.empty?
|
|
20
|
+
puts "canonical skill files up to date"
|
|
21
|
+
else
|
|
22
|
+
stale.each { |r| warn "stale: #{r.dest}" }
|
|
23
|
+
abort "#{stale.size} canonical skill file(s) stale; run `rake hyperdrive:skills:render`"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -31,6 +31,27 @@ module Rails
|
|
|
31
31
|
end
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
+
# Fail-open binding for the canonical render: every gem reads as present
|
|
35
|
+
# at any requested version, so the output includes every conditional
|
|
36
|
+
# branch. There is no bundle to resolve against, so gem_version is nil.
|
|
37
|
+
class CanonicalContext
|
|
38
|
+
def gem?(_name, _requirement = nil)
|
|
39
|
+
true
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def any_gem?(*)
|
|
43
|
+
true
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def gem_version(_name)
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def template_binding
|
|
51
|
+
binding
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
34
55
|
module_function
|
|
35
56
|
|
|
36
57
|
# Errors (ERB SyntaxError included) propagate; the caller decides how to
|
|
@@ -39,6 +60,10 @@ module Rails
|
|
|
39
60
|
ERB.new(source, trim_mode: "-").result(Context.new(resolved).template_binding)
|
|
40
61
|
end
|
|
41
62
|
|
|
63
|
+
def render_canonical(source)
|
|
64
|
+
ERB.new(source, trim_mode: "-").result(CanonicalContext.new.template_binding)
|
|
65
|
+
end
|
|
66
|
+
|
|
42
67
|
# Gem::Requirement.new does not parse a single comma-separated string, so
|
|
43
68
|
# such strings are split into separate constraints first.
|
|
44
69
|
def version_matches?(requirement_str, version)
|
|
@@ -130,7 +130,8 @@ module Rails
|
|
|
130
130
|
# Bundler is absent or refuses to resolve.
|
|
131
131
|
def gem_skills_info
|
|
132
132
|
require "rails/hyperdrive/bundler_artifact_discovery"
|
|
133
|
-
::Rails::Hyperdrive::BundlerArtifactDiscovery.discover
|
|
133
|
+
discovered = ::Rails::Hyperdrive::BundlerArtifactDiscovery.discover(enabled_gems: enabled_gems)
|
|
134
|
+
discovered.select(&:skill?).map do |skill|
|
|
134
135
|
{
|
|
135
136
|
name: skill.name,
|
|
136
137
|
gem: skill.target_gem,
|
|
@@ -143,6 +144,24 @@ module Rails
|
|
|
143
144
|
rescue StandardError
|
|
144
145
|
[]
|
|
145
146
|
end
|
|
147
|
+
|
|
148
|
+
def enabled_gems
|
|
149
|
+
require "rails/hyperdrive/install_layout"
|
|
150
|
+
require "rails/hyperdrive/lock_file"
|
|
151
|
+
root =
|
|
152
|
+
if @app_root
|
|
153
|
+
@app_root.to_s
|
|
154
|
+
elsif defined?(::Rails) && ::Rails.respond_to?(:root) && ::Rails.root
|
|
155
|
+
::Rails.root.to_s
|
|
156
|
+
end
|
|
157
|
+
return [] unless root
|
|
158
|
+
|
|
159
|
+
::Rails::Hyperdrive::LockFile.load(
|
|
160
|
+
File.join(root, ::Rails::Hyperdrive::InstallLayout::LOCK_PATH)
|
|
161
|
+
).enabled_gems
|
|
162
|
+
rescue StandardError
|
|
163
|
+
[]
|
|
164
|
+
end
|
|
146
165
|
end
|
|
147
166
|
end
|
|
148
167
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rails-hyperdrive
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Bakaface
|
|
@@ -100,12 +100,14 @@ files:
|
|
|
100
100
|
- lib/rails/hyperdrive/artifact_status.rb
|
|
101
101
|
- lib/rails/hyperdrive/auto_install.rb
|
|
102
102
|
- lib/rails/hyperdrive/bundler_artifact_discovery.rb
|
|
103
|
+
- lib/rails/hyperdrive/canonical_skill_render.rb
|
|
103
104
|
- lib/rails/hyperdrive/claude_md_import.rb
|
|
104
105
|
- lib/rails/hyperdrive/companion_discovery.rb
|
|
105
106
|
- lib/rails/hyperdrive/console_executor.rb
|
|
106
107
|
- lib/rails/hyperdrive/drift_verdict.rb
|
|
107
108
|
- lib/rails/hyperdrive/eager_footprint.rb
|
|
108
109
|
- lib/rails/hyperdrive/engine.rb
|
|
110
|
+
- lib/rails/hyperdrive/gem_manifest.rb
|
|
109
111
|
- lib/rails/hyperdrive/index_document.rb
|
|
110
112
|
- lib/rails/hyperdrive/install_layout.rb
|
|
111
113
|
- lib/rails/hyperdrive/install_pipeline.rb
|
|
@@ -116,6 +118,7 @@ files:
|
|
|
116
118
|
- lib/rails/hyperdrive/resources/skill.rb
|
|
117
119
|
- lib/rails/hyperdrive/resources/stack_profile.rb
|
|
118
120
|
- lib/rails/hyperdrive/safety/rack_middleware.rb
|
|
121
|
+
- lib/rails/hyperdrive/skill_tasks.rb
|
|
119
122
|
- lib/rails/hyperdrive/skill_template.rb
|
|
120
123
|
- lib/rails/hyperdrive/sql_safety.rb
|
|
121
124
|
- lib/rails/hyperdrive/stack_profile.rb
|