robot_lab-discovery 0.2.6
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/.envrc +1 -0
- data/.github/workflows/deploy-github-pages.yml +52 -0
- data/.loki +10 -0
- data/.quality/reek_baseline.txt +2 -0
- data/.rubocop.yml +1 -0
- data/CHANGELOG.md +33 -0
- data/CLAUDE.md +74 -0
- data/LICENSE.txt +21 -0
- data/README.md +182 -0
- data/Rakefile +132 -0
- data/docs/api_reference.md +164 -0
- data/docs/getting_started.md +114 -0
- data/docs/how_it_works.md +131 -0
- data/docs/index.md +58 -0
- data/examples/01_basic_usage.rb +154 -0
- data/examples/common.rb +9 -0
- data/lib/robot_lab/discovery/advertiser.rb +45 -0
- data/lib/robot_lab/discovery/browser.rb +69 -0
- data/lib/robot_lab/discovery/constants.rb +7 -0
- data/lib/robot_lab/discovery/result.rb +9 -0
- data/lib/robot_lab/discovery/txt_record.rb +70 -0
- data/lib/robot_lab/discovery/version.rb +7 -0
- data/lib/robot_lab/discovery.rb +30 -0
- data/mkdocs.yml +117 -0
- data/sig/robot_lab/discovery.rbs +6 -0
- metadata +84 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 24e1bc6292612b2ea663e6263851de0dedff0d271e16e8f2d19880c6b434e0e4
|
|
4
|
+
data.tar.gz: 7980129dba8853c6f5ce97f0792362a5d64aad6685e23a2b650cb98d35334bf6
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: d0055e91603ceae005545f8fe8707f33ddfb8f8031631b840b73befa6960d2cfa8bb8975d6733ea3380879f5230d87063ca62f6f4465bd8f93ba03258ad9b35f
|
|
7
|
+
data.tar.gz: d5a0eadd8e1ae6285654468f6a3be30e5903fce881799295e3fcf199e99670339d1a64430ebd2c4140bfebdd03422465cedc3e425e35aaa0ad847b5d4326fdfb
|
data/.envrc
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export RR=`pwd`
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
name: Deploy Documentation to GitHub Pages
|
|
2
|
+
on:
|
|
3
|
+
push:
|
|
4
|
+
branches:
|
|
5
|
+
- main
|
|
6
|
+
- develop
|
|
7
|
+
paths:
|
|
8
|
+
- "docs/**"
|
|
9
|
+
- "mkdocs.yml"
|
|
10
|
+
- ".github/workflows/deploy-github-pages.yml"
|
|
11
|
+
workflow_dispatch:
|
|
12
|
+
|
|
13
|
+
permissions:
|
|
14
|
+
contents: write
|
|
15
|
+
pages: write
|
|
16
|
+
id-token: write
|
|
17
|
+
|
|
18
|
+
jobs:
|
|
19
|
+
deploy:
|
|
20
|
+
runs-on: ubuntu-latest
|
|
21
|
+
steps:
|
|
22
|
+
- name: Checkout code
|
|
23
|
+
uses: actions/checkout@v4
|
|
24
|
+
with:
|
|
25
|
+
fetch-depth: 0
|
|
26
|
+
|
|
27
|
+
- name: Setup Python
|
|
28
|
+
uses: actions/setup-python@v5
|
|
29
|
+
with:
|
|
30
|
+
python-version: 3.x
|
|
31
|
+
|
|
32
|
+
- name: Install dependencies
|
|
33
|
+
run: |
|
|
34
|
+
pip install mkdocs
|
|
35
|
+
pip install mkdocs-material
|
|
36
|
+
pip install mkdocs-macros-plugin
|
|
37
|
+
pip install mike
|
|
38
|
+
|
|
39
|
+
- name: Configure Git
|
|
40
|
+
run: |
|
|
41
|
+
git config --local user.email "action@github.com"
|
|
42
|
+
git config --local user.name "GitHub Action"
|
|
43
|
+
|
|
44
|
+
- name: Build MkDocs site
|
|
45
|
+
run: mkdocs build
|
|
46
|
+
|
|
47
|
+
- name: Deploy to GitHub Pages
|
|
48
|
+
uses: peaceiris/actions-gh-pages@v4
|
|
49
|
+
with:
|
|
50
|
+
github_token: ${{ secrets.GITHUB_TOKEN }}
|
|
51
|
+
publish_dir: ./site
|
|
52
|
+
keep_files: true
|
data/.loki
ADDED
data/.rubocop.yml
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
inherit_from: ../.rubocop-base.yml
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
## [Unreleased]
|
|
2
|
+
|
|
3
|
+
## [0.1.0] - 2026-05-28
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- `RobotLab::Discovery::Advertiser` — registers a robot as a `_robot-lab._tcp.local.`
|
|
8
|
+
mDNS service in a background thread. Accepts a free-form `capabilities:` array;
|
|
9
|
+
each capability is encoded in the TXT record and registered as a DNS-SD subtype
|
|
10
|
+
(`_research._sub._robot-lab._tcp.local.` etc.) for targeted browsing.
|
|
11
|
+
Supports `start`, `stop`, and `started?`. Sends an mDNS goodbye packet (TTL=0)
|
|
12
|
+
on `stop` to remove the service from peers' caches immediately.
|
|
13
|
+
- `RobotLab::Discovery::Browser` — browses `_robot-lab._tcp.local.` via
|
|
14
|
+
`ZeroConf.browse`, parses SRV and TXT records, and returns `Result` objects.
|
|
15
|
+
Provides:
|
|
16
|
+
- `browse(timeout:)` — all robots on the LAN
|
|
17
|
+
- `find(name, timeout:)` — single robot by instance name
|
|
18
|
+
- `find_by_capability(capability, timeout:)` — robots advertising a given
|
|
19
|
+
capability, browsed via the corresponding DNS-SD subtype
|
|
20
|
+
- `list_capabilities(timeout:)` — sorted, deduplicated list of all capability
|
|
21
|
+
terms currently advertised on the LAN
|
|
22
|
+
- `RobotLab::Discovery::Result` — immutable `Data`-based value object with
|
|
23
|
+
`name`, `hostname`, `port`, `path`, `capabilities`, and a `url` helper method.
|
|
24
|
+
- `RobotLab::Discovery::TxtRecord` — encodes and decodes DNS TXT record arrays.
|
|
25
|
+
Fields: `path=`, `rl_version=`, `capabilities=` (comma-separated).
|
|
26
|
+
- `RobotLab::Discovery.dns_label` — normalises a free-form capability string
|
|
27
|
+
into a valid DNS label (downcased, non-alphanumeric runs collapsed to hyphens).
|
|
28
|
+
- `RobotLab::Discovery` module with `browse`, `find`, `find_by_capability`, and
|
|
29
|
+
`list_capabilities` convenience class methods. Self-registers as a RobotLab
|
|
30
|
+
extension when the core gem is present.
|
|
31
|
+
- `examples/01_basic_usage.rb` — end-to-end demo covering all six operations:
|
|
32
|
+
register with capabilities, browse all, find by name, find by capability,
|
|
33
|
+
list capability inventory, and unregister.
|
data/CLAUDE.md
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
4
|
+
|
|
5
|
+
## What This Gem Does
|
|
6
|
+
|
|
7
|
+
`robot_lab-discovery` provides zero-configuration mDNS/DNS-SD robot discovery on local networks. Robots advertise themselves with a name, port, path, and optional capabilities; browsers discover them by name or capability without any central registry.
|
|
8
|
+
|
|
9
|
+
Uses the `zeroconf` gem. Service type is `_robot-lab._tcp.local.`.
|
|
10
|
+
|
|
11
|
+
## Commands
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
bundle exec rake test # Run full test suite (SpecReporter output)
|
|
15
|
+
ruby -Ilib:test test/<file> # Run a single test file
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Architecture
|
|
19
|
+
|
|
20
|
+
All source lives under `lib/robot_lab/discovery/`.
|
|
21
|
+
|
|
22
|
+
**`Discovery`** (`discovery.rb`) — Top-level module with delegation methods:
|
|
23
|
+
- `browse(timeout:)` — returns all visible robots as `Array<Result>`
|
|
24
|
+
- `find(name, timeout:)` — polls until the named robot appears or timeout
|
|
25
|
+
- `find_by_capability(cap, timeout:)` — browses the capability-specific DNS subtype
|
|
26
|
+
- `list_capabilities(timeout:)` — returns sorted unique capability strings across all visible robots
|
|
27
|
+
- `dns_label(capability)` — normalises a capability string to a valid DNS label (`"Web Search"` → `"web-search"`)
|
|
28
|
+
|
|
29
|
+
**`Advertiser`** (`discovery/advertiser.rb`) — Announces a robot over mDNS. `start` spawns a background thread running the zeroconf service; `stop` tears it down. Each capability is registered as a DNS subtype (`_capability-label._sub._robot-lab._tcp.local.`) enabling capability-filtered browsing.
|
|
30
|
+
|
|
31
|
+
**`Browser`** (`discovery/browser.rb`) — Wraps `ZeroConf.browse`. Parses raw DNS responses (SRV + TXT records) into `Result` structs, deduplicating by name. `parse_response` is private — do not call directly.
|
|
32
|
+
|
|
33
|
+
**`TxtRecord`** (`discovery/txt_record.rb`) — Encodes/decodes the DNS TXT payload using short wire keys to conserve UDP packet space:
|
|
34
|
+
- `p` = path, `v` = rl_version, `c` = comma-joined capabilities
|
|
35
|
+
- `encode` validates each string ≤ 255 bytes and total wire size ≤ 1300 bytes
|
|
36
|
+
- `decode` returns `{ path:, rl_version:, capabilities: [] }`
|
|
37
|
+
|
|
38
|
+
**`Result`** (`discovery/result.rb`) — `Data.define(:name, :hostname, :port, :path, :capabilities)`. Has a `url` convenience method (`"http://hostname:port/path"`).
|
|
39
|
+
|
|
40
|
+
**`Constants`** (`discovery/constants.rb`) — `SERVICE_TYPE = "_robot-lab._tcp.local."`.
|
|
41
|
+
|
|
42
|
+
## Usage Example
|
|
43
|
+
|
|
44
|
+
```ruby
|
|
45
|
+
require 'robot_lab/discovery'
|
|
46
|
+
|
|
47
|
+
# Advertise
|
|
48
|
+
adv = RobotLab::Discovery::Advertiser.new(
|
|
49
|
+
name: "analyst", port: 9292, path: "/a2a",
|
|
50
|
+
capabilities: ["data analysis", "web search"]
|
|
51
|
+
)
|
|
52
|
+
adv.start
|
|
53
|
+
|
|
54
|
+
# Browse
|
|
55
|
+
robots = RobotLab::Discovery.browse(timeout: 3)
|
|
56
|
+
robots.each { |r| puts "#{r.name} at #{r.url}" }
|
|
57
|
+
|
|
58
|
+
# Find by capability
|
|
59
|
+
RobotLab::Discovery.find_by_capability("web search", timeout: 3)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Key Constraints
|
|
63
|
+
|
|
64
|
+
- mDNS is LAN-only — works within a single subnet, not across routers.
|
|
65
|
+
- `browse` may return stale results if a robot crashes without calling `stop` — mDNS TTLs eventually expire.
|
|
66
|
+
- Capability strings in TXT records are comma-separated in a single field capped at 253 bytes of content. Many short capabilities are fine; very long strings will raise `Discovery::Error`.
|
|
67
|
+
- `find` polls by calling `browse` in a loop — it is blocking and should not be called on the main thread in production.
|
|
68
|
+
|
|
69
|
+
## Testing
|
|
70
|
+
|
|
71
|
+
- Minitest with SimpleCov (branch coverage tracked, no minimum threshold enforced yet)
|
|
72
|
+
- Output via `Minitest::Reporters::SpecReporter`
|
|
73
|
+
- Tests mock zeroconf interactions — no actual mDNS traffic is sent
|
|
74
|
+
- Coverage baseline: ~47% line / ~3% branch — test suite is thin relative to implementation
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dewayne VanHoozer
|
|
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,182 @@
|
|
|
1
|
+
# robot_lab-discovery
|
|
2
|
+
|
|
3
|
+
Zero-configuration mDNS/DNS-SD robot discovery for [RobotLab](https://github.com/MadBomber/robot_lab) on local networks.
|
|
4
|
+
|
|
5
|
+
Robots advertise themselves via multicast DNS (mDNS, RFC 6762) and find each other without any central registry or hardcoded URLs. Augments `robot_lab-a2a` but has no dependency on it — any transport can use the discovered URL.
|
|
6
|
+
|
|
7
|
+
## How it works
|
|
8
|
+
|
|
9
|
+
Each robot registers as a `_robot-lab._tcp.local.` mDNS service. Its HTTP path, gem version, and capability taxonomy are stored in the TXT record. Each capability is also registered as a DNS-SD subtype, enabling clients to browse by capability type without enumerating all robots.
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
Advertiser Browser
|
|
13
|
+
────────────────────────────────────── ────────────────────────────────────────────
|
|
14
|
+
ZeroConf::Service ZeroConf.browse("_robot-lab._tcp.local.")
|
|
15
|
+
instance: "headline" → parse SRV + TXT
|
|
16
|
+
host: my-server.local → Result(name, hostname, port, path,
|
|
17
|
+
port: 9292 capabilities, url)
|
|
18
|
+
TXT: path=/headline
|
|
19
|
+
capabilities=writing,research
|
|
20
|
+
subtypes: _writing._sub._robot-lab._tcp.local.
|
|
21
|
+
_research._sub._robot-lab._tcp.local.
|
|
22
|
+
|
|
23
|
+
ZeroConf.browse("_research._sub._robot-lab._tcp.local.")
|
|
24
|
+
→ only research robots
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Installation
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
bundle add robot_lab-discovery
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Or add to your Gemfile:
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
gem "robot_lab-discovery"
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### System prerequisites
|
|
40
|
+
|
|
41
|
+
None beyond Ruby >= 3.2. The `zeroconf` gem is pure Ruby and implements mDNS itself using standard UDP multicast sockets — it does not wrap Bonjour, Avahi, or any system mDNS daemon. A multicast-capable network interface is all that is required, which is standard on any LAN.
|
|
42
|
+
|
|
43
|
+
## Usage
|
|
44
|
+
|
|
45
|
+
### Advertising a robot
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
require "robot_lab/discovery"
|
|
49
|
+
|
|
50
|
+
adv = RobotLab::Discovery::Advertiser.new(
|
|
51
|
+
name: "headline", # mDNS instance name — no dots allowed
|
|
52
|
+
port: 9292,
|
|
53
|
+
path: "/headline", # stored in TXT record
|
|
54
|
+
hostname: Socket.gethostname,
|
|
55
|
+
capabilities: ["writing", "research"] # free-form taxonomy terms
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
adv.start # begins advertising in a background thread
|
|
59
|
+
# ... server runs ...
|
|
60
|
+
adv.stop # sends mDNS goodbye packet; removes from peers' caches
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Capability strings are free-form. They are normalised into valid DNS labels
|
|
64
|
+
(`"Web Search"` → `"web-search"`) and registered as DNS-SD subtypes, so peers
|
|
65
|
+
can browse for them directly.
|
|
66
|
+
|
|
67
|
+
### Discovering robots
|
|
68
|
+
|
|
69
|
+
```ruby
|
|
70
|
+
require "robot_lab/discovery"
|
|
71
|
+
|
|
72
|
+
# All robots on the LAN
|
|
73
|
+
results = RobotLab::Discovery.browse(timeout: 3)
|
|
74
|
+
results.each { |r| puts "#{r.name}: #{r.url} caps=#{r.capabilities.join(", ")}" }
|
|
75
|
+
|
|
76
|
+
# Find one robot by name
|
|
77
|
+
result = RobotLab::Discovery.find("headline", timeout: 5)
|
|
78
|
+
result.url # => "http://my-server.local:9292/headline"
|
|
79
|
+
result.capabilities # => ["writing", "research"]
|
|
80
|
+
|
|
81
|
+
# Find all robots offering a specific capability
|
|
82
|
+
researchers = RobotLab::Discovery.find_by_capability("research", timeout: 3)
|
|
83
|
+
|
|
84
|
+
# List every capability type advertised on the LAN right now
|
|
85
|
+
RobotLab::Discovery.list_capabilities(timeout: 3)
|
|
86
|
+
# => ["analysis", "coding", "research", "writing"]
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Connecting after discovery
|
|
90
|
+
|
|
91
|
+
`Result#url` returns a plain URL string. Wire it to whatever client you use:
|
|
92
|
+
|
|
93
|
+
```ruby
|
|
94
|
+
result = RobotLab::Discovery.find("headline")
|
|
95
|
+
|
|
96
|
+
# With robot_lab-a2a:
|
|
97
|
+
client = A2A.client(url: result.url)
|
|
98
|
+
client.send_task(message: A2A::Models::Message.user("Summarise today's news"))
|
|
99
|
+
|
|
100
|
+
# With Net::HTTP directly:
|
|
101
|
+
Net::HTTP.get(URI(result.url))
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Result fields
|
|
105
|
+
|
|
106
|
+
| Field | Type | Description |
|
|
107
|
+
|----------------|---------------|-------------|
|
|
108
|
+
| `name` | String | mDNS instance name (e.g. `"headline"`) |
|
|
109
|
+
| `hostname` | String | Fully-qualified `.local` hostname |
|
|
110
|
+
| `port` | Integer | Port the robot server listens on |
|
|
111
|
+
| `path` | String | HTTP path to the robot (e.g. `"/headline"`) |
|
|
112
|
+
| `capabilities` | Array<String> | Capability taxonomy terms advertised by this robot |
|
|
113
|
+
| `url` | String | `"http://#{hostname}:#{port}#{path}"` |
|
|
114
|
+
|
|
115
|
+
## Capability taxonomy
|
|
116
|
+
|
|
117
|
+
Capabilities are free-form strings — there is no enforced registry. Use short,
|
|
118
|
+
lowercase, hyphen-separated terms that describe what the robot does, not how it
|
|
119
|
+
works. Examples:
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
research analysis writing coding
|
|
123
|
+
vision memory tool-use orchestration
|
|
124
|
+
web-search summarisation classification
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
A robot may advertise any number of capabilities. Clients browse by capability
|
|
128
|
+
using `find_by_capability`, which targets the DNS-SD subtype directly and avoids
|
|
129
|
+
scanning all robots on the network.
|
|
130
|
+
|
|
131
|
+
`dns_label` is available for normalising arbitrary strings:
|
|
132
|
+
|
|
133
|
+
```ruby
|
|
134
|
+
RobotLab::Discovery.dns_label("Web Search / NLP") # => "web-search-nlp"
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## Examples
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
bundle exec ruby examples/01_basic_usage.rb
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Covers all six operations: register with capabilities, browse all, find by name,
|
|
144
|
+
find by capability, list capability inventory, and unregister.
|
|
145
|
+
|
|
146
|
+
## Architecture
|
|
147
|
+
|
|
148
|
+
```
|
|
149
|
+
lib/robot_lab/discovery/
|
|
150
|
+
constants.rb # SERVICE_TYPE = "_robot-lab._tcp.local."
|
|
151
|
+
txt_record.rb # encode/decode TXT record arrays (path, rl_version, capabilities)
|
|
152
|
+
result.rb # immutable Data value object (name, hostname, port, path, capabilities)
|
|
153
|
+
advertiser.rb # wraps ZeroConf::Service, runs in a thread, registers subtypes
|
|
154
|
+
browser.rb # wraps ZeroConf.browse, parses DNS responses
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
`RobotLab::Discovery` module exposes `browse`, `find`, `find_by_capability`, and
|
|
158
|
+
`list_capabilities` as class methods. If `RobotLab` is loaded, the gem
|
|
159
|
+
self-registers as an extension via `RobotLab.register_extension(:discovery, ...)`.
|
|
160
|
+
|
|
161
|
+
## Relationship to other robot_lab gems
|
|
162
|
+
|
|
163
|
+
| Gem | Concern |
|
|
164
|
+
|-----|---------|
|
|
165
|
+
| `robot_lab` | Core robots, networks, memory |
|
|
166
|
+
| `robot_lab-a2a` | A2A HTTP+SSE transport protocol |
|
|
167
|
+
| `robot_lab-discovery` | **Where** are the robots and **what can they do** (mDNS addressing + capability taxonomy) |
|
|
168
|
+
|
|
169
|
+
Discovery is intentionally transport-agnostic. It finds a URL; you decide what
|
|
170
|
+
protocol to speak to it.
|
|
171
|
+
|
|
172
|
+
## Development
|
|
173
|
+
|
|
174
|
+
```bash
|
|
175
|
+
bundle install
|
|
176
|
+
bundle exec rake test # run the test suite
|
|
177
|
+
bin/console # IRB with the gem loaded
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
## License
|
|
181
|
+
|
|
182
|
+
MIT — see [LICENSE.txt](LICENSE.txt).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'bundler/gem_tasks'
|
|
4
|
+
require 'rake/testtask'
|
|
5
|
+
|
|
6
|
+
Rake::TestTask.new(:test) do |t|
|
|
7
|
+
t.libs << 'test'
|
|
8
|
+
t.libs << 'lib'
|
|
9
|
+
t.test_files = FileList['test/**/*_test.rb', 'test/**/test_*.rb'].exclude('**/*_helper.rb')
|
|
10
|
+
t.verbose = true
|
|
11
|
+
t.ruby_opts << '-rtest_helper'
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
task default: :test
|
|
15
|
+
|
|
16
|
+
desc 'Run tests with verbose output'
|
|
17
|
+
task :test_verbose do
|
|
18
|
+
ENV['TESTOPTS'] = '--verbose'
|
|
19
|
+
Rake::Task[:test].invoke
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
desc 'Run a single test file'
|
|
23
|
+
task :test_file, [:file] do |_t, args|
|
|
24
|
+
ruby "test/#{args[:file]}"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
desc 'Check code complexity with Flog (warn >=20, fail >=50)'
|
|
28
|
+
task :flog_check do
|
|
29
|
+
require 'flog'
|
|
30
|
+
|
|
31
|
+
method_warn = 20.0
|
|
32
|
+
method_fail = 50.0
|
|
33
|
+
|
|
34
|
+
flogger = Flog.new(all: true)
|
|
35
|
+
flogger.flog(*Dir.glob('lib/**/*.rb'))
|
|
36
|
+
|
|
37
|
+
warnings = []
|
|
38
|
+
failures = []
|
|
39
|
+
|
|
40
|
+
flogger.each_by_score do |method, score|
|
|
41
|
+
next if method.end_with?('#none')
|
|
42
|
+
|
|
43
|
+
if score > method_fail
|
|
44
|
+
failures << "#{format('%.1f', score)}: #{method}"
|
|
45
|
+
elsif score > method_warn
|
|
46
|
+
warnings << "#{format('%.1f', score)}: #{method}"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
unless warnings.empty?
|
|
51
|
+
puts "\nFlog warnings (#{method_warn}–#{method_fail}) — target for future refactoring:"
|
|
52
|
+
warnings.each { |v| puts " #{v}" }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
if failures.empty?
|
|
56
|
+
puts "\nFlog: no methods exceed the failure threshold (>=#{method_fail})"
|
|
57
|
+
else
|
|
58
|
+
puts "\nFlog failures (>=#{method_fail}) — must be refactored:"
|
|
59
|
+
failures.each { |v| puts " #{v}" }
|
|
60
|
+
abort "\nFlog quality gate failed: #{failures.size} method(s) exceed #{method_fail}"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
desc 'Check for structural code duplication with Flay (mass >= 50)'
|
|
65
|
+
task :flay_check do
|
|
66
|
+
require 'flay'
|
|
67
|
+
|
|
68
|
+
mass_threshold = 50
|
|
69
|
+
|
|
70
|
+
flay = Flay.new({ mass: mass_threshold, diff: false, verbose: false, summary: false, timeout: 60 })
|
|
71
|
+
flay.process(*Dir.glob('lib/**/*.rb'))
|
|
72
|
+
flay.analyze
|
|
73
|
+
|
|
74
|
+
if flay.hashes.empty?
|
|
75
|
+
puts "\nFlay: no structural duplication detected (mass >= #{mass_threshold})"
|
|
76
|
+
else
|
|
77
|
+
puts "\nFlay found structural duplication (mass >= #{mass_threshold}):"
|
|
78
|
+
flay.report
|
|
79
|
+
abort "\nFlay quality gate failed: #{flay.hashes.length} pattern(s) detected"
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
desc 'Run all quality checks: tests (with coverage), RuboCop, Flog, and Flay'
|
|
84
|
+
task :quality do
|
|
85
|
+
gates = [
|
|
86
|
+
['Tests + Coverage', 'bundle exec rake test'],
|
|
87
|
+
['RuboCop', 'bundle exec rubocop'],
|
|
88
|
+
['Flog Complexity', 'bundle exec rake flog_check'],
|
|
89
|
+
['Flay Duplication', 'bundle exec rake flay_check']
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
results = gates.map do |label, command|
|
|
93
|
+
puts "\n#{'=' * 60}"
|
|
94
|
+
puts "Quality Gate: #{label}"
|
|
95
|
+
puts '=' * 60
|
|
96
|
+
[label, system(command) ? :pass : :fail]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
green = ->(s) { "\e[32m#{s}\e[0m" }
|
|
100
|
+
red = ->(s) { "\e[31m#{s}\e[0m" }
|
|
101
|
+
width = results.map { |label, _| label.length }.max
|
|
102
|
+
|
|
103
|
+
puts "\n#{'=' * 60}"
|
|
104
|
+
puts 'Quality Gate Summary'
|
|
105
|
+
puts '=' * 60
|
|
106
|
+
results.each do |label, status|
|
|
107
|
+
badge = status == :pass ? green.call('PASS') : red.call('FAIL')
|
|
108
|
+
puts " [#{badge}] #{label.ljust(width)}"
|
|
109
|
+
end
|
|
110
|
+
puts '-' * 60
|
|
111
|
+
|
|
112
|
+
passed = results.count { |_, s| s == :pass }
|
|
113
|
+
failed = results.count { |_, s| s == :fail }
|
|
114
|
+
tally = "#{passed} passed, #{failed} failed"
|
|
115
|
+
puts " #{failed.zero? ? green.call(tally) : red.call(tally)}"
|
|
116
|
+
puts '=' * 60
|
|
117
|
+
|
|
118
|
+
abort "\n#{red.call('Quality gate failed.')}" unless failed.zero?
|
|
119
|
+
puts "\n#{green.call('All quality gates passed.')}"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
namespace :docs do
|
|
123
|
+
desc 'Build MkDocs documentation'
|
|
124
|
+
task :build do
|
|
125
|
+
sh 'mkdocs build'
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
desc 'Serve MkDocs documentation locally on http://localhost:8000'
|
|
129
|
+
task :serve do
|
|
130
|
+
sh 'mkdocs serve'
|
|
131
|
+
end
|
|
132
|
+
end
|