hexwright 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.
- package/LICENSE +21 -0
- package/README.md +339 -0
- package/dist/chunk-ZXMDA7VB.js +16 -0
- package/dist/cli.js +2223 -0
- package/dist/client.js +339 -0
- package/dist/main-CUYP5EVX.js +2506 -0
- package/package.json +69 -0
- package/profiles/hexagonal-kotlin.yml +80 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Gyutae Ha
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
# hexwright
|
|
2
|
+
|
|
3
|
+
Design graph for **hexagonal-architecture** codebases. Extracts the type graph
|
|
4
|
+
from source, checks **ports-and-adapters** boundaries, and renders exactly what a
|
|
5
|
+
branch changed — structurally, not as a text diff.
|
|
6
|
+
|
|
7
|
+
*(`hex` here is the hexagon, not hexadecimal.)*
|
|
8
|
+
|
|
9
|
+
## Why
|
|
10
|
+
|
|
11
|
+
A coding agent produces structure faster than anyone can review it, and the
|
|
12
|
+
review is where the risk now sits. A text diff answers *which lines moved*. It
|
|
13
|
+
does not answer *did a controller start reaching into another context's
|
|
14
|
+
aggregate* — which is the question that decides whether the change was right.
|
|
15
|
+
|
|
16
|
+
That question is structural, so hexwright answers it structurally, in both
|
|
17
|
+
directions:
|
|
18
|
+
|
|
19
|
+
- **A human confirms in seconds, by looking.** The delta renders as one picture:
|
|
20
|
+
what the branch added, what it modified, and every relation it introduced. Not
|
|
21
|
+
a claim about the code — a fact drawn from it. Cheap enough to attach to every
|
|
22
|
+
PR, and cheap enough to attach to a *design*, before any logic exists, because
|
|
23
|
+
the graph reads declarations from the working tree rather than commits.
|
|
24
|
+
- **The agent checks itself first.** Boundary verdicts are computed over the
|
|
25
|
+
whole graph, so they are not something an agent can grep for and therefore not
|
|
26
|
+
something it can skip by accident. Three MCP tools let it ask what it changed,
|
|
27
|
+
what that reaches, and whether it broke a rule — before it asks you to look.
|
|
28
|
+
|
|
29
|
+
The human stays in the loop; what changes is that the loop costs a glance rather
|
|
30
|
+
than a careful reading, and that most breaks are caught before it starts.
|
|
31
|
+
|
|
32
|
+
Runs as a sidecar: it never writes to the target repository.
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
npx hexwright extract --repo ~/work/my-service --base main --out .design
|
|
36
|
+
npx hexwright check --repo ~/work/my-service --base main --scope delta # CI gate
|
|
37
|
+
npx hexwright render --repo ~/work/my-service --base main --image delta.png
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### Usage guides
|
|
41
|
+
|
|
42
|
+
Walkthroughs by scenario, rather than a list of flags.
|
|
43
|
+
|
|
44
|
+
| | guide | for when you are |
|
|
45
|
+
|---|---|---|
|
|
46
|
+
| 1 | **[The agent's loop](docs/1-agent-loop.md)** | wiring the graph into a coding agent — design confirmed before any logic is written, boundaries checked before the PR |
|
|
47
|
+
| 2 | **[Reviewing a branch](docs/2-review.md)** | looking at finished work, by hand or as a CI gate |
|
|
48
|
+
| 3 | **[Exploring the architecture](docs/3-explore.md)** | trying to understand a system you did not write |
|
|
49
|
+
| 4 | **[Everything else](docs/4-recipes.md)** | after the command that fits a one-off question |
|
|
50
|
+
|
|
51
|
+
## Render
|
|
52
|
+
|
|
53
|
+
An image for the PR — what this branch changed, in one picture.
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
hexwright render --repo ~/work/my-service --base main --image delta.png
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
view what this branch added and changed
|
|
61
|
+
drew 10 types · 18 relations · 0 violations
|
|
62
|
+
wrote delta.svg (17 KB)
|
|
63
|
+
wrote delta.png (802×614)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
The default draws **only what changed** — nothing pre-existing. That is not a
|
|
67
|
+
loss of information: every relation the branch introduced has both ends inside
|
|
68
|
+
the changed set, so narrowing keeps all of them while dropping the dependencies
|
|
69
|
+
the modified types already had. On the branch above that is 10 types instead of
|
|
70
|
+
61, and the image fits a PR comment at full size instead of being scaled down
|
|
71
|
+
until the labels are unreadable.
|
|
72
|
+
|
|
73
|
+
Use `--view impact` when the question is who is affected rather than what was
|
|
74
|
+
built — it adds everything that depends on a changed type (30 types, 7 domains
|
|
75
|
+
on the same branch).
|
|
76
|
+
|
|
77
|
+
Added types get a solid orange outline, modified ones a violet dash, new
|
|
78
|
+
relations orange. Everything unchanged stays as dimmed context. Red is reserved
|
|
79
|
+
for boundary violations — domain hues never enter the red or orange band, so a
|
|
80
|
+
red line always means a breach.
|
|
81
|
+
|
|
82
|
+
The delta is laid out **organically**: each domain is a dense block ordered
|
|
83
|
+
Entity → Service → UseCase → Port → DTO, and the blocks are placed by how much
|
|
84
|
+
they actually couple. A change spanning several contexts therefore draws itself
|
|
85
|
+
as a hub with the domains that touch it around it.
|
|
86
|
+
|
|
87
|
+
| `--view` | what it draws |
|
|
88
|
+
|---|---|
|
|
89
|
+
| `delta` *(default)* | only the added and modified types, and the relations between them |
|
|
90
|
+
| `impact` | the above plus everything that depends on a changed type |
|
|
91
|
+
| `core` | Entity · Service · UseCase · Port · Event — the whole shape |
|
|
92
|
+
| `all` | everything, adapters and DTOs included |
|
|
93
|
+
| `domain:<name>` | one bounded context and what it touches |
|
|
94
|
+
|
|
95
|
+
There is no browser involved — the coordinates are a pure function, so the SVG
|
|
96
|
+
is written directly and CI needs no headless Chrome. The same graph always draws
|
|
97
|
+
the same file, which is what makes two branches comparable.
|
|
98
|
+
|
|
99
|
+
`--image x.png` also writes `x.svg`. PNG needs the optional `@resvg/resvg-js`;
|
|
100
|
+
without it the SVG is still written and the command says so. GitHub does not
|
|
101
|
+
render SVG in comments, so attach the PNG there and keep the SVG as the artifact.
|
|
102
|
+
|
|
103
|
+
Layout follows the view — `organic` for `delta` and `domain:`, `hex` for `core`
|
|
104
|
+
and `all` (concentric rings, read as a shape). `--layout organic|grid|hex`
|
|
105
|
+
overrides; `grid` lists domains left to right, ignoring coupling.
|
|
106
|
+
|
|
107
|
+
## MCP
|
|
108
|
+
|
|
109
|
+
Serve the graph to a coding agent over stdio:
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
hexwright mcp --repo ~/work/my-service --base main
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
```json
|
|
116
|
+
{ "mcpServers": { "hexwright": {
|
|
117
|
+
"command": "npx",
|
|
118
|
+
"args": ["-y", "hexwright", "mcp", "--repo", "/abs/path", "--base", "main"] } } }
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Three read-only tools. Anything an agent can get by reading a file is not one of
|
|
122
|
+
them — it already has Read and Grep, and they are usually faster. What is left is
|
|
123
|
+
what files cannot answer.
|
|
124
|
+
|
|
125
|
+
| tool | what only the graph knows |
|
|
126
|
+
|---|---|
|
|
127
|
+
| `check_violations(scope)` | a verdict over the whole graph. `scope: "delta"` = only what this branch introduced |
|
|
128
|
+
| `get_delta()` | the structural diff against the base, per changed type |
|
|
129
|
+
| `dependencies(name, direction, hops)` | which methods a consumer actually calls, and which a port declares that nobody does |
|
|
130
|
+
|
|
131
|
+
Each description says *when* to reach for the tool, not only what it returns —
|
|
132
|
+
that is most of why this is an MCP server rather than a CLI the agent has to be
|
|
133
|
+
taught. What a description cannot carry is a sequence, or an instruction to stop
|
|
134
|
+
and wait for a human; [the agent loop](docs/1-agent-loop.md) covers that.
|
|
135
|
+
|
|
136
|
+
`dependencies` is the one that pays off:
|
|
137
|
+
|
|
138
|
+
```
|
|
139
|
+
MediaPolicyPort [Port] media
|
|
140
|
+
depended on by (2):
|
|
141
|
+
← MediaObjectEventService uses: maxPhotoSizeBytes, photoQuality, … (13)
|
|
142
|
+
← UploadService uses: maxPhotoSizeBytes, maxVideoDurationSec,
|
|
143
|
+
maxVideoSizeBytes, photoAllowedFormats,
|
|
144
|
+
videoAllowedFormats
|
|
145
|
+
|
|
146
|
+
declares 17, never called (4):
|
|
147
|
+
hqPhotoMaxLongSidePx(): Int
|
|
148
|
+
...
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Four dead methods and two consumers overlapping in five of seventeen — an
|
|
152
|
+
interface asking to be split. The port file shows declarations, not callers, so
|
|
153
|
+
reading it does not tell you this.
|
|
154
|
+
|
|
155
|
+
## Assumptions
|
|
156
|
+
|
|
157
|
+
Single-module **Gradle** project, **Kotlin**, **Spring Boot**, with a
|
|
158
|
+
domain-first (package-by-feature) hexagonal layout.
|
|
159
|
+
|
|
160
|
+
The source root is found by Gradle convention — `build.gradle(.kts)` up to two
|
|
161
|
+
levels deep, then `<module>/src/main/kotlin`. A monorepo with several Kotlin
|
|
162
|
+
modules stops with the list and asks you to pick one:
|
|
163
|
+
|
|
164
|
+
```
|
|
165
|
+
found 3 Gradle modules with Kotlin sources (client/androidApp, media-processor, server).
|
|
166
|
+
hexwright assumes a single module — pass --src to pick one.
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
`--src` takes either a module path (`server`) or a source root
|
|
170
|
+
(`server/src/main/kotlin`). If `settings.gradle` declares subprojects, it warns
|
|
171
|
+
and analyzes only the selected module.
|
|
172
|
+
|
|
173
|
+
Expected package layout — the domain is the segment right after the common
|
|
174
|
+
package prefix, which is inferred automatically:
|
|
175
|
+
|
|
176
|
+
```
|
|
177
|
+
<base>.<domain>.domain.model Entity · VO
|
|
178
|
+
<base>.<domain>.domain.event Event
|
|
179
|
+
<base>.<domain>.application.port.inbound UseCase · DTO
|
|
180
|
+
<base>.<domain>.application.port.out Port · DTO
|
|
181
|
+
<base>.<domain>.application.service Service
|
|
182
|
+
<base>.<domain>.adapter.web inbound adapter
|
|
183
|
+
<base>.<domain>.adapter.out outbound adapter
|
|
184
|
+
<base>.common Shared
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
## What it produces
|
|
188
|
+
|
|
189
|
+
```
|
|
190
|
+
nodes 701 Adapter 230 · DTO 126 · UseCase 113 · Port 60 · VO 55 · Service 46 · Entity 25
|
|
191
|
+
edges 2211 DEPENDS_ON 2021 · IMPLEMENTS 188 · EXTENDS 2
|
|
192
|
+
domains 16
|
|
193
|
+
|
|
194
|
+
delta vs main
|
|
195
|
+
added 6 nodes · 15 edges modified 4 nodes removed 0
|
|
196
|
+
+ MediaLabelQueryPort [Port] media
|
|
197
|
+
~ MediaService [Service] media
|
|
198
|
+
|
|
199
|
+
violations 7
|
|
200
|
+
AdminAuthController → admin.AdminAccount inbound adapter touches Entity
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
`--out` writes two files:
|
|
204
|
+
|
|
205
|
+
- `graph.json` — full graph including public contracts
|
|
206
|
+
- `graph.tsv` — one line per node/edge, sorted, prefix-stripped. Commit this and
|
|
207
|
+
`git diff` shows structural change in readable form.
|
|
208
|
+
|
|
209
|
+
## Principles
|
|
210
|
+
|
|
211
|
+
**Only relations that exist in the source.** No derived or inferred edges. A
|
|
212
|
+
reference to `UserId` is not a reference to `User` — promoting it would fabricate
|
|
213
|
+
facts and every metric built on top would be wrong.
|
|
214
|
+
|
|
215
|
+
**Deterministic classification.** Component roles come from path + language
|
|
216
|
+
construct only. No human judgement, so results are reproducible and comparable
|
|
217
|
+
across commits.
|
|
218
|
+
|
|
219
|
+
**Signature-following.** When a type is only held in a local variable, its name
|
|
220
|
+
never appears in the source. The extractor reads the callee's signature to
|
|
221
|
+
recover the reference, so `service.save(x)` resolves `x` through
|
|
222
|
+
`Repository.findById(): Record?`.
|
|
223
|
+
|
|
224
|
+
## Profiles — adapting it to your layout
|
|
225
|
+
|
|
226
|
+
Every convention lives in a profile, not in code. `port/in` versus
|
|
227
|
+
`port/inbound`, `infrastructure/` versus `adapter/`, a different package depth —
|
|
228
|
+
all of that is a config change rather than a fork.
|
|
229
|
+
|
|
230
|
+
Start from the bundled one:
|
|
231
|
+
|
|
232
|
+
```bash
|
|
233
|
+
curl -O https://raw.githubusercontent.com/vonkernel/hexwright/main/profiles/hexagonal-kotlin.yml
|
|
234
|
+
npx hexwright extract --repo . --profile ./hexagonal-kotlin.yml
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
A profile has five parts, and they are read in this order.
|
|
238
|
+
|
|
239
|
+
**Where the domain is.** The domain is a package segment, found after stripping a
|
|
240
|
+
common prefix. Leave `base` empty and the longest common prefix is inferred —
|
|
241
|
+
which is right for a multi-domain codebase and wrong for a single-domain one,
|
|
242
|
+
where it swallows the domain too. Set it explicitly then, or pass
|
|
243
|
+
`--base-package`.
|
|
244
|
+
|
|
245
|
+
```yaml
|
|
246
|
+
domain:
|
|
247
|
+
from: package
|
|
248
|
+
base: "" # com.acme.service — empty means infer
|
|
249
|
+
at: 0 # which segment after the prefix is the domain
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
**Which layer a path is in.** Fragments, matched top to bottom, first hit wins.
|
|
253
|
+
Rename these and the whole tool follows:
|
|
254
|
+
|
|
255
|
+
```yaml
|
|
256
|
+
layers:
|
|
257
|
+
adapter: /infrastructure/ # instead of /adapter/
|
|
258
|
+
application: /application/
|
|
259
|
+
domain: /domain/
|
|
260
|
+
common: /shared/
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
**Subdivisions and adapter direction.** `sublayers` is what separates an inbound
|
|
264
|
+
port from an outbound one; `adapterKinds` is what lets an outbound adapter map
|
|
265
|
+
aggregates while an inbound one may not.
|
|
266
|
+
|
|
267
|
+
```yaml
|
|
268
|
+
sublayers:
|
|
269
|
+
port/inbound: /application/port/in/ # instead of /port/inbound/
|
|
270
|
+
port/out: /application/port/out/
|
|
271
|
+
service: /application/service/
|
|
272
|
+
model: /domain/model/
|
|
273
|
+
|
|
274
|
+
adapterKinds:
|
|
275
|
+
out: /infrastructure/out/
|
|
276
|
+
in: /infrastructure/rest/
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
**What each type is.** Ordered rules, first match wins. A rule may test the
|
|
280
|
+
layer, the sublayer, the language construct (`kinds`), the structural kind
|
|
281
|
+
(`structs`), or a name suffix:
|
|
282
|
+
|
|
283
|
+
```yaml
|
|
284
|
+
components:
|
|
285
|
+
- { nameEnds: Exception, as: Error }
|
|
286
|
+
- { sublayer: model, kinds: [value class], as: VO }
|
|
287
|
+
- { sublayer: model, as: Entity }
|
|
288
|
+
- { sublayer: port/out, structs: [interface], as: Port }
|
|
289
|
+
- { sublayer: port/out, as: DTO }
|
|
290
|
+
- { as: DTO } # fallback
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
Order carries the meaning. `port/out` + `interface` is a Port; the same path
|
|
294
|
+
without `interface` is a DTO, because that is what a class in a port package
|
|
295
|
+
actually is.
|
|
296
|
+
|
|
297
|
+
**What counts as a violation.** Both rules are explicit, so a codebase with a
|
|
298
|
+
different opinion can hold it:
|
|
299
|
+
|
|
300
|
+
```yaml
|
|
301
|
+
rules:
|
|
302
|
+
entityAccess:
|
|
303
|
+
allow: [Service, Port, Entity] # may touch an Entity, same domain only
|
|
304
|
+
allowAdapterKinds: [out] # outbound adapters map aggregates
|
|
305
|
+
crossDomain: deny
|
|
306
|
+
layering:
|
|
307
|
+
- from: [application, domain]
|
|
308
|
+
to: [adapter]
|
|
309
|
+
message: layer back-reference
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
Check a new profile against the tool's own reading before trusting it — if the
|
|
313
|
+
component counts look wrong, a path fragment is wrong:
|
|
314
|
+
|
|
315
|
+
```
|
|
316
|
+
nodes 701 Adapter 230 · DTO 126 · UseCase 113 · Port 60 · VO 55 · Service 46 · Entity 25
|
|
317
|
+
domains 16
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
Zero Ports or a domain count of 1 means the paths or the base package are not
|
|
321
|
+
matching your layout yet.
|
|
322
|
+
|
|
323
|
+
## Boundary rules
|
|
324
|
+
|
|
325
|
+
An Entity may be touched by, within the same domain: `Service`, `Port`, `Entity`,
|
|
326
|
+
and outbound adapters (port implementations must map aggregates). Everything else
|
|
327
|
+
is a violation:
|
|
328
|
+
|
|
329
|
+
- cross-domain Entity access — bounded context breach
|
|
330
|
+
- inbound adapter touching an Entity — controllers should speak DTOs
|
|
331
|
+
- a DTO or UseCase exposing an Entity — domain model leaked into a contract
|
|
332
|
+
- application/domain referencing adapter — layer back-reference
|
|
333
|
+
|
|
334
|
+
## Status
|
|
335
|
+
|
|
336
|
+
Early. Kotlin extractor only; validated against one production codebase
|
|
337
|
+
(701 nodes, 2211 edges) with output verified against a reference implementation.
|
|
338
|
+
|
|
339
|
+
MIT
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
3
|
+
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
4
|
+
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
5
|
+
}) : x)(function(x) {
|
|
6
|
+
if (typeof require !== "undefined") return require.apply(this, arguments);
|
|
7
|
+
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
8
|
+
});
|
|
9
|
+
var __commonJS = (cb, mod) => function __require2() {
|
|
10
|
+
return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export {
|
|
14
|
+
__require,
|
|
15
|
+
__commonJS
|
|
16
|
+
};
|