hexwright 0.1.0 → 0.2.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/README.md +72 -4
- package/dist/cli.js +262 -60
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -90,19 +90,63 @@ as a hub with the domains that touch it around it.
|
|
|
90
90
|
| `impact` | the above plus everything that depends on a changed type |
|
|
91
91
|
| `core` | Entity · Service · UseCase · Port · Event — the whole shape |
|
|
92
92
|
| `all` | everything, adapters and DTOs included |
|
|
93
|
+
| `violations` | only the breaches, and the types they run through |
|
|
93
94
|
| `domain:<name>` | one bounded context and what it touches |
|
|
94
95
|
|
|
96
|
+
`violations` is a **state** view: it draws what is wrong now, not what this branch
|
|
97
|
+
changed. Unlike the toggle in the web UI it is not narrowed by component, because
|
|
98
|
+
a violation with one end hidden reads as no violation at all.
|
|
99
|
+
|
|
100
|
+
### Before and after
|
|
101
|
+
|
|
102
|
+
A branch that *removes* violations, or swaps one port for another, is not
|
|
103
|
+
described by the delta alone. The picture worth showing is the state it started
|
|
104
|
+
from — which is exactly the state the working tree no longer has. `--at base`
|
|
105
|
+
draws the same types in their pre-branch state:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
hexwright render --repo . --base main --at base --image before.png
|
|
109
|
+
hexwright render --repo . --base main --image after.png
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
The before picture carries **no delta styling** — nothing in a picture of the past
|
|
113
|
+
can be added or modified — and violations stay red, which is the question it
|
|
114
|
+
exists to answer. It needs `--base`. A branch that only adds types has no before
|
|
115
|
+
state at all; the command says so and exits 0.
|
|
116
|
+
|
|
117
|
+
A before/after pair **lines up**. Positions, domain boxes, colours and canvas
|
|
118
|
+
width all follow the set of types being laid out, and the two halves differ by
|
|
119
|
+
whatever the branch added or removed — so each render lays out from the union of
|
|
120
|
+
both and draws only its own half. A type keeps its place across the pair, and a
|
|
121
|
+
deletion leaves the gap where it used to be. Neither command needs a flag for
|
|
122
|
+
this and neither can be run "wrong": both derive the union from the same two
|
|
123
|
+
graphs.
|
|
124
|
+
|
|
95
125
|
There is no browser involved — the coordinates are a pure function, so the SVG
|
|
96
126
|
is written directly and CI needs no headless Chrome. The same graph always draws
|
|
97
|
-
the same file
|
|
127
|
+
the same file.
|
|
98
128
|
|
|
99
129
|
`--image x.png` also writes `x.svg`. PNG needs the optional `@resvg/resvg-js`;
|
|
100
130
|
without it the SVG is still written and the command says so. GitHub does not
|
|
101
131
|
render SVG in comments, so attach the PNG there and keep the SVG as the artifact.
|
|
102
132
|
|
|
103
|
-
Layout follows the view — `organic` for `delta` and `
|
|
104
|
-
and `all` (concentric rings, read as a shape). `--layout
|
|
105
|
-
overrides; `grid` lists domains left to right, ignoring
|
|
133
|
+
Layout follows the view — `organic` for `delta`, `domain:` and `violations`, `hex`
|
|
134
|
+
for `core` and `all` (concentric rings, read as a shape). `--layout
|
|
135
|
+
organic|grid|hex` overrides; `grid` lists domains left to right, ignoring
|
|
136
|
+
coupling.
|
|
137
|
+
|
|
138
|
+
## Exit codes
|
|
139
|
+
|
|
140
|
+
| command | 1 | 0 |
|
|
141
|
+
|---|---|---|
|
|
142
|
+
| `check` | the repository has a violation — with `--scope delta`, only one the branch introduced | otherwise |
|
|
143
|
+
| `render` | a bad argument, or a base ref that is not in the clone | an image was written, **or there was nothing to draw** |
|
|
144
|
+
| `extract` | the source root or the base ref cannot be read | otherwise |
|
|
145
|
+
|
|
146
|
+
`render` treats an empty picture as an answer rather than a failure. `--view
|
|
147
|
+
violations` on the branch that removed the last one, and `--at base` on a branch
|
|
148
|
+
that only adds types, both print what happened and exit 0 — a non-zero exit there
|
|
149
|
+
would break the very script that produces the pair.
|
|
106
150
|
|
|
107
151
|
## MCP
|
|
108
152
|
|
|
@@ -331,6 +375,30 @@ is a violation:
|
|
|
331
375
|
- a DTO or UseCase exposing an Entity — domain model leaked into a contract
|
|
332
376
|
- application/domain referencing adapter — layer back-reference
|
|
333
377
|
|
|
378
|
+
## Releasing
|
|
379
|
+
|
|
380
|
+
Trunk-based, tag-triggered. There is no release branch: `main` is the only branch
|
|
381
|
+
that ships, and one can always be cut from a tag later if a backport ever needs
|
|
382
|
+
one.
|
|
383
|
+
|
|
384
|
+
```bash
|
|
385
|
+
npm version minor -m "release: v%s" # bumps package.json, commits, tags
|
|
386
|
+
git push --follow-tags
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
Pushing the tag runs [`release.yml`](.github/workflows/release.yml), which
|
|
390
|
+
refuses to publish unless the tagged commit is on `main` and the tag matches the
|
|
391
|
+
version in `package.json` — a tag that disagrees with what npm receives is worse
|
|
392
|
+
than a failed release, because it is only found out later. It then runs the same
|
|
393
|
+
gate a pull request has to clear, publishes with provenance, and opens a GitHub
|
|
394
|
+
Release for the tag.
|
|
395
|
+
|
|
396
|
+
There is no npm token. npm is configured to trust this repository and this
|
|
397
|
+
workflow **by filename**, and the OIDC token GitHub mints for the job is exchanged
|
|
398
|
+
for publish rights — so nothing has to be stored, rotated, or kept from expiring.
|
|
399
|
+
The catch is the filename: renaming `release.yml` breaks publishing until npm's
|
|
400
|
+
trusted-publisher entry is updated to match.
|
|
401
|
+
|
|
334
402
|
## Status
|
|
335
403
|
|
|
336
404
|
Early. Kotlin extractor only; validated against one production codebase
|
package/dist/cli.js
CHANGED
|
@@ -146,6 +146,10 @@ async function serveStdio(getQuery, project) {
|
|
|
146
146
|
await server.connect(new StdioServerTransport());
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
+
// src/model.ts
|
|
150
|
+
var nodeIndex = (g) => new Map(g.nodes.map((n) => [n.id, n]));
|
|
151
|
+
var edgeKey = (e) => `${e.src}\0${e.dst}\0${e.rel}`;
|
|
152
|
+
|
|
149
153
|
// src/output/tsv.ts
|
|
150
154
|
function toTsv(g) {
|
|
151
155
|
const prefix = commonPrefix(g.nodes.map((n) => n.id));
|
|
@@ -583,7 +587,9 @@ function gridLayout(g, maxWidth = 3e3) {
|
|
|
583
587
|
}
|
|
584
588
|
|
|
585
589
|
// src/render/select.ts
|
|
586
|
-
|
|
590
|
+
var NothingToDraw = class extends Error {
|
|
591
|
+
};
|
|
592
|
+
function select(g, view, showIdentifiers = false, changed) {
|
|
587
593
|
const byId = new Map(g.nodes.map((n) => [n.id, n]));
|
|
588
594
|
const real = g.edges.filter((e) => showIdentifiers || !e.identifierOnly);
|
|
589
595
|
const withNeighbours = (seed) => {
|
|
@@ -595,6 +601,13 @@ function select(g, view, showIdentifiers = false) {
|
|
|
595
601
|
return out;
|
|
596
602
|
};
|
|
597
603
|
const changedSet = () => {
|
|
604
|
+
if (changed) {
|
|
605
|
+
const seed2 = new Set([...changed].filter((id) => byId.has(id)));
|
|
606
|
+
if (!seed2.size) {
|
|
607
|
+
throw new NothingToDraw("nothing this branch touches exists here \u2014 nothing to draw");
|
|
608
|
+
}
|
|
609
|
+
return seed2;
|
|
610
|
+
}
|
|
598
611
|
const seed = new Set(
|
|
599
612
|
g.nodes.filter((n) => n.status === "added" || n.status === "modified").map((n) => n.id)
|
|
600
613
|
);
|
|
@@ -613,7 +626,18 @@ function select(g, view, showIdentifiers = false) {
|
|
|
613
626
|
};
|
|
614
627
|
let keep;
|
|
615
628
|
let label;
|
|
616
|
-
|
|
629
|
+
let violatingEdgesOnly = false;
|
|
630
|
+
if (view === "violations") {
|
|
631
|
+
keep = /* @__PURE__ */ new Set();
|
|
632
|
+
for (const e of real) {
|
|
633
|
+
if (!e.violation) continue;
|
|
634
|
+
keep.add(e.src);
|
|
635
|
+
keep.add(e.dst);
|
|
636
|
+
}
|
|
637
|
+
if (!keep.size) throw new NothingToDraw("no violations \u2014 nothing to draw");
|
|
638
|
+
violatingEdgesOnly = true;
|
|
639
|
+
label = "every boundary violation, and the types they run through";
|
|
640
|
+
} else if (view === "all") {
|
|
617
641
|
keep = new Set(g.nodes.map((n) => n.id));
|
|
618
642
|
label = "all types";
|
|
619
643
|
} else if (view === "core") {
|
|
@@ -632,14 +656,16 @@ function select(g, view, showIdentifiers = false) {
|
|
|
632
656
|
keep = changedSet();
|
|
633
657
|
const linked = real.filter((e) => keep.has(e.src) && keep.has(e.dst)).length;
|
|
634
658
|
if (linked) {
|
|
635
|
-
label = "what this branch added and changed";
|
|
659
|
+
label = changed ? "the types this branch touches, as they stand" : "what this branch added and changed";
|
|
636
660
|
} else {
|
|
637
661
|
keep = dependents(keep);
|
|
638
|
-
label = "what this branch changed (no new relations \u2014 showing dependents)";
|
|
662
|
+
label = changed ? "the types this branch touches, and their dependents, as they stand" : "what this branch changed (no new relations \u2014 showing dependents)";
|
|
639
663
|
}
|
|
640
664
|
}
|
|
641
665
|
const nodes = g.nodes.filter((n) => keep.has(n.id));
|
|
642
|
-
const edges = real.filter(
|
|
666
|
+
const edges = real.filter(
|
|
667
|
+
(e) => keep.has(e.src) && keep.has(e.dst) && (!violatingEdgesOnly || e.violation)
|
|
668
|
+
);
|
|
643
669
|
return { graph: { ...g, nodes, edges }, label };
|
|
644
670
|
}
|
|
645
671
|
|
|
@@ -762,16 +788,18 @@ function gridWidthFor(g) {
|
|
|
762
788
|
}
|
|
763
789
|
function renderSvg(g, opt = {}) {
|
|
764
790
|
const layout = opt.layout ?? "hex";
|
|
765
|
-
const
|
|
766
|
-
const
|
|
791
|
+
const frame = opt.frame ?? g;
|
|
792
|
+
const hueOf = hueMap(frame.nodes.map((n) => n.domain));
|
|
793
|
+
const place = layout === "hex" ? hexLayout(frame) : layout === "organic" ? organicLayout(frame) : gridLayout(frame, gridWidthFor(frame));
|
|
767
794
|
const coords = new Map(place.map((p) => [p.id, p]));
|
|
768
|
-
const
|
|
795
|
+
const sit = (n) => {
|
|
769
796
|
const c = coords.get(n.id) ?? { x: 0, y: 0 };
|
|
770
797
|
return { n, x: c.x, y: c.y, ...sizeOf(n) };
|
|
771
|
-
}
|
|
798
|
+
};
|
|
799
|
+
const placed = g.nodes.map(sit);
|
|
772
800
|
const at = new Map(placed.map((p) => [p.n.id, p]));
|
|
773
801
|
const domains = /* @__PURE__ */ new Map();
|
|
774
|
-
for (const p of placed) {
|
|
802
|
+
for (const p of frame === g ? placed : frame.nodes.map(sit)) {
|
|
775
803
|
const b = domains.get(p.n.domain);
|
|
776
804
|
const x1 = Math.min(b ? b.x : Number.POSITIVE_INFINITY, p.x - p.w / 2);
|
|
777
805
|
const y1 = Math.min(b ? b.y : Number.POSITIVE_INFINITY, p.y - p.h / 2);
|
|
@@ -965,13 +993,15 @@ function legendBlock(g, w, top, hueOf) {
|
|
|
965
993
|
`<path d="M${x},${y} L${x + 34},${y}" stroke="${VIOLATION}" stroke-width="3.4" fill="none"/>`,
|
|
966
994
|
`<text x="${x + 40}" y="${y + 4}" font-size="11" fill="${VIOLATION}">boundary violation</text>`
|
|
967
995
|
);
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
996
|
+
if (g.nodes.some((n) => n.status === "added" || n.status === "modified")) {
|
|
997
|
+
x += 150;
|
|
998
|
+
rows.push(
|
|
999
|
+
`<rect x="${x}" y="${y - 7}" width="14" height="14" rx="3" fill="none" stroke="${ADDED}" stroke-width="3"/>`,
|
|
1000
|
+
`<text x="${x + 20}" y="${y + 4}" font-size="11" fill="${ADDED}">added</text>`,
|
|
1001
|
+
`<rect x="${x + 66}" y="${y - 7}" width="14" height="14" rx="3" fill="none" stroke="${MODIFIED}" stroke-width="2.6" stroke-dasharray="7 4"/>`,
|
|
1002
|
+
`<text x="${x + 86}" y="${y + 4}" font-size="11" fill="${MODIFIED}">modified</text>`
|
|
1003
|
+
);
|
|
1004
|
+
}
|
|
975
1005
|
y += 26;
|
|
976
1006
|
x = 26;
|
|
977
1007
|
rows.push(`<text x="${x}" y="${y + 4}" font-size="11" fill="${DIM}">domain</text>`);
|
|
@@ -998,10 +1028,6 @@ function legendBlock(g, w, top, hueOf) {
|
|
|
998
1028
|
import { readdirSync as readdirSync3, statSync as statSync3 } from "fs";
|
|
999
1029
|
import { join as join4 } from "path";
|
|
1000
1030
|
|
|
1001
|
-
// src/model.ts
|
|
1002
|
-
var nodeIndex = (g) => new Map(g.nodes.map((n) => [n.id, n]));
|
|
1003
|
-
var edgeKey = (e) => `${e.src}\0${e.dst}\0${e.rel}`;
|
|
1004
|
-
|
|
1005
1031
|
// src/delta.ts
|
|
1006
1032
|
function diff(base, head) {
|
|
1007
1033
|
const b = nodeIndex(base);
|
|
@@ -1088,6 +1114,13 @@ var MODS = "(?:public |internal |private |protected |open |abstract |sealed |dat
|
|
|
1088
1114
|
var DECL = new RegExp(
|
|
1089
1115
|
`^(?<mods>${MODS})(?<kw>class|interface|object)\\s+(?<name>[A-Za-z_]\\w*)`
|
|
1090
1116
|
);
|
|
1117
|
+
var TOP_MODS = "(?:public |internal |private |expect |actual |external |inline |suspend |operator |infix |tailrec |const |lateinit )*";
|
|
1118
|
+
var TYPEALIAS = new RegExp(
|
|
1119
|
+
`^${TOP_MODS}typealias\\s+(\\w+)(?:<[^>]*>)?\\s*=\\s*(.+?)\\s*$`
|
|
1120
|
+
);
|
|
1121
|
+
var TOP_LEVEL = new RegExp(
|
|
1122
|
+
`^(?:@\\w+(?:\\([^)]*\\))?\\s+)*${TOP_MODS}(?:typealias|fun|va[lr])\\s`
|
|
1123
|
+
);
|
|
1091
1124
|
var FUN_MODS = "(?:override |open |abstract |suspend |operator |private |internal |protected |inline |final )*";
|
|
1092
1125
|
var SIG = new RegExp(
|
|
1093
1126
|
`^\\s+(?:@\\w+(?:\\([^)]*\\))?\\s*)*(?<mods>${FUN_MODS})fun\\s+(?<name>\\w+)\\s*\\(`
|
|
@@ -1113,45 +1146,97 @@ function structOf(kind) {
|
|
|
1113
1146
|
if (kind === "object") return "Object";
|
|
1114
1147
|
return "Class";
|
|
1115
1148
|
}
|
|
1116
|
-
function
|
|
1149
|
+
function stripNonCode(text) {
|
|
1117
1150
|
const out = [];
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1151
|
+
const n = text.length;
|
|
1152
|
+
const stack = [{ kind: "code", raw: false, depth: 0 }];
|
|
1153
|
+
const blank = (c) => out.push(c === "\n" ? "\n" : " ");
|
|
1154
|
+
let i = 0;
|
|
1155
|
+
while (i < n) {
|
|
1156
|
+
const f = stack[stack.length - 1];
|
|
1122
1157
|
const c = text[i];
|
|
1123
|
-
if (
|
|
1124
|
-
if (c === "
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
if (c === "
|
|
1132
|
-
out.push("
|
|
1158
|
+
if (f.kind === "code") {
|
|
1159
|
+
if (c === "/" && text[i + 1] === "/") {
|
|
1160
|
+
while (i < n && text[i] !== "\n") {
|
|
1161
|
+
out.push(" ");
|
|
1162
|
+
i++;
|
|
1163
|
+
}
|
|
1164
|
+
continue;
|
|
1165
|
+
}
|
|
1166
|
+
if (c === "/" && text[i + 1] === "*") {
|
|
1167
|
+
out.push(" ", " ");
|
|
1168
|
+
i += 2;
|
|
1169
|
+
while (i < n && !(text[i] === "*" && text[i + 1] === "/")) blank(text[i++]);
|
|
1170
|
+
if (i < n) {
|
|
1171
|
+
out.push(" ", " ");
|
|
1172
|
+
i += 2;
|
|
1173
|
+
}
|
|
1174
|
+
continue;
|
|
1175
|
+
}
|
|
1176
|
+
if (c === '"') {
|
|
1177
|
+
const raw = text[i + 1] === '"' && text[i + 2] === '"';
|
|
1178
|
+
const q = raw ? 3 : 1;
|
|
1179
|
+
for (let k = 0; k < q; k++) out.push('"');
|
|
1180
|
+
i += q;
|
|
1181
|
+
stack.push({ kind: "str", raw, depth: 0 });
|
|
1182
|
+
continue;
|
|
1183
|
+
}
|
|
1184
|
+
if (c === "'") {
|
|
1185
|
+
out.push("'");
|
|
1133
1186
|
i++;
|
|
1134
|
-
|
|
1187
|
+
while (i < n && text[i] !== "'" && text[i] !== "\n") {
|
|
1188
|
+
if (text[i] === "\\" && i + 1 < n) {
|
|
1189
|
+
out.push(" ");
|
|
1190
|
+
i++;
|
|
1191
|
+
}
|
|
1192
|
+
blank(text[i++]);
|
|
1193
|
+
}
|
|
1194
|
+
if (i < n && text[i] === "'") {
|
|
1195
|
+
out.push("'");
|
|
1196
|
+
i++;
|
|
1197
|
+
}
|
|
1135
1198
|
continue;
|
|
1136
1199
|
}
|
|
1137
|
-
|
|
1200
|
+
if (c === "{") f.depth++;
|
|
1201
|
+
else if (c === "}") {
|
|
1202
|
+
if (f.depth === 0 && stack.length > 1) {
|
|
1203
|
+
out.push("}");
|
|
1204
|
+
stack.pop();
|
|
1205
|
+
i++;
|
|
1206
|
+
continue;
|
|
1207
|
+
}
|
|
1208
|
+
f.depth--;
|
|
1209
|
+
}
|
|
1210
|
+
out.push(c);
|
|
1211
|
+
i++;
|
|
1138
1212
|
continue;
|
|
1139
1213
|
}
|
|
1140
|
-
if (!
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
i++;
|
|
1214
|
+
if (!f.raw && c === "\\" && i + 1 < n) {
|
|
1215
|
+
out.push(" ", " ");
|
|
1216
|
+
i += 2;
|
|
1144
1217
|
continue;
|
|
1145
1218
|
}
|
|
1146
|
-
if (!
|
|
1147
|
-
|
|
1148
|
-
|
|
1219
|
+
if (!f.raw && c === "\n") {
|
|
1220
|
+
out.push("\n");
|
|
1221
|
+
stack.pop();
|
|
1149
1222
|
i++;
|
|
1150
1223
|
continue;
|
|
1151
1224
|
}
|
|
1152
|
-
if (c === '"'
|
|
1153
|
-
|
|
1154
|
-
|
|
1225
|
+
if (c === '"' && (!f.raw || text[i + 1] === '"' && text[i + 2] === '"')) {
|
|
1226
|
+
const q = f.raw ? 3 : 1;
|
|
1227
|
+
for (let k = 0; k < q; k++) out.push('"');
|
|
1228
|
+
i += q;
|
|
1229
|
+
stack.pop();
|
|
1230
|
+
continue;
|
|
1231
|
+
}
|
|
1232
|
+
if (c === "$" && text[i + 1] === "{") {
|
|
1233
|
+
out.push("$", "{");
|
|
1234
|
+
i += 2;
|
|
1235
|
+
stack.push({ kind: "code", raw: false, depth: 0 });
|
|
1236
|
+
continue;
|
|
1237
|
+
}
|
|
1238
|
+
blank(c);
|
|
1239
|
+
i++;
|
|
1155
1240
|
}
|
|
1156
1241
|
return out.join("");
|
|
1157
1242
|
}
|
|
@@ -1316,6 +1401,12 @@ function supertypeNames(text, seenColon) {
|
|
|
1316
1401
|
}
|
|
1317
1402
|
|
|
1318
1403
|
// src/extract/kotlin.ts
|
|
1404
|
+
function bodyEnd(fi, di) {
|
|
1405
|
+
const d = fi.decls[di];
|
|
1406
|
+
let end = di + 1 < fi.decls.length ? fi.decls[di + 1].line : fi.lines.length;
|
|
1407
|
+
for (const s of fi.stops) if (s > d.line && s < end) end = s;
|
|
1408
|
+
return end;
|
|
1409
|
+
}
|
|
1319
1410
|
function walk(dir, out = []) {
|
|
1320
1411
|
for (const e of readdirSync2(dir)) {
|
|
1321
1412
|
const p = join2(dir, e);
|
|
@@ -1343,10 +1434,12 @@ function extractKotlin(srcRoot, profile, project, ref) {
|
|
|
1343
1434
|
for (const abs of files) {
|
|
1344
1435
|
const rel = relative2(srcRoot, abs);
|
|
1345
1436
|
if (isExcluded(profile, `/${rel}`)) continue;
|
|
1346
|
-
const lines =
|
|
1437
|
+
const lines = stripNonCode(readFileSync3(abs, "utf8")).split("\n");
|
|
1347
1438
|
let pkg = "";
|
|
1348
1439
|
const imports = /* @__PURE__ */ new Map();
|
|
1349
1440
|
const decls = [];
|
|
1441
|
+
const aliases = /* @__PURE__ */ new Map();
|
|
1442
|
+
const stops = [];
|
|
1350
1443
|
for (let i = 0; i < lines.length; i++) {
|
|
1351
1444
|
const line = lines[i];
|
|
1352
1445
|
const pm = PACKAGE.exec(line);
|
|
@@ -1367,10 +1460,58 @@ function extractKotlin(srcRoot, profile, project, ref) {
|
|
|
1367
1460
|
line: i,
|
|
1368
1461
|
kind: kindOf(dm.groups.mods ?? "", dm.groups.kw)
|
|
1369
1462
|
});
|
|
1463
|
+
continue;
|
|
1370
1464
|
}
|
|
1465
|
+
const am = TYPEALIAS.exec(line);
|
|
1466
|
+
if (am) {
|
|
1467
|
+
aliases.set(am[1], am[2]);
|
|
1468
|
+
stops.push(i);
|
|
1469
|
+
continue;
|
|
1470
|
+
}
|
|
1471
|
+
if (TOP_LEVEL.test(line)) stops.push(i);
|
|
1371
1472
|
}
|
|
1372
1473
|
rawPkgs.push(pkg);
|
|
1373
|
-
infos.push({
|
|
1474
|
+
infos.push({
|
|
1475
|
+
rel,
|
|
1476
|
+
pkg,
|
|
1477
|
+
imports,
|
|
1478
|
+
decls,
|
|
1479
|
+
lines,
|
|
1480
|
+
layer: layerOf(profile, `/${rel}`),
|
|
1481
|
+
aliases,
|
|
1482
|
+
stops
|
|
1483
|
+
});
|
|
1484
|
+
}
|
|
1485
|
+
const aliasByFq = /* @__PURE__ */ new Map();
|
|
1486
|
+
for (const fi of infos) {
|
|
1487
|
+
for (const [name, rhs] of fi.aliases) aliasByFq.set(`${fi.pkg}.${name}`, { rhs, from: fi });
|
|
1488
|
+
}
|
|
1489
|
+
for (const fi of infos) {
|
|
1490
|
+
const reach = /* @__PURE__ */ new Map();
|
|
1491
|
+
for (const [name, rhs] of fi.aliases) reach.set(name, { rhs, from: fi });
|
|
1492
|
+
for (const [simple, fq] of fi.imports) {
|
|
1493
|
+
const a = aliasByFq.get(fq);
|
|
1494
|
+
if (a) reach.set(simple, a);
|
|
1495
|
+
}
|
|
1496
|
+
for (const [fq, a] of aliasByFq) {
|
|
1497
|
+
const dot = fq.lastIndexOf(".");
|
|
1498
|
+
const name = fq.slice(dot + 1);
|
|
1499
|
+
if (fq.slice(0, dot) === fi.pkg && !reach.has(name)) reach.set(name, a);
|
|
1500
|
+
}
|
|
1501
|
+
if (!reach.size) continue;
|
|
1502
|
+
for (const { rhs, from } of reach.values()) {
|
|
1503
|
+
if (from === fi) continue;
|
|
1504
|
+
for (const [name] of rhs.matchAll(TYPENAME)) {
|
|
1505
|
+
const nm = name;
|
|
1506
|
+
if (!fi.imports.has(nm)) fi.imports.set(nm, from.imports.get(nm) ?? `${from.pkg}.${nm}`);
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
for (let pass = 0; pass < 2; pass++) {
|
|
1510
|
+
for (const [name, a] of reach) {
|
|
1511
|
+
const re = new RegExp(`\\b${name}\\b`, "g");
|
|
1512
|
+
fi.lines = fi.lines.map((l) => TYPEALIAS.test(l) ? l : l.replace(re, a.rhs));
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1374
1515
|
}
|
|
1375
1516
|
const base = profile.domain.base || inferBasePackage(rawPkgs);
|
|
1376
1517
|
const domainOf = (pkg) => {
|
|
@@ -1388,7 +1529,7 @@ function extractKotlin(srcRoot, profile, project, ref) {
|
|
|
1388
1529
|
const domain = domainOf(fi.pkg);
|
|
1389
1530
|
for (let di = 0; di < fi.decls.length; di++) {
|
|
1390
1531
|
const d = fi.decls[di];
|
|
1391
|
-
const end =
|
|
1532
|
+
const end = bodyEnd(fi, di);
|
|
1392
1533
|
const body = fi.lines.slice(d.line, end);
|
|
1393
1534
|
const struct = structOf(d.kind);
|
|
1394
1535
|
const sigs = collectSigs(body);
|
|
@@ -1448,7 +1589,7 @@ function extractKotlin(srcRoot, profile, project, ref) {
|
|
|
1448
1589
|
for (let di = 0; di < fi.decls.length; di++) {
|
|
1449
1590
|
const d = fi.decls[di];
|
|
1450
1591
|
const id = `${fi.pkg}.${d.name}`;
|
|
1451
|
-
const end =
|
|
1592
|
+
const end = bodyEnd(fi, di);
|
|
1452
1593
|
const body = fi.lines.slice(d.line, end).join("\n");
|
|
1453
1594
|
for (const sup of parseSupertypes(fi.lines, d.line)) {
|
|
1454
1595
|
const tgt = visible.get(sup);
|
|
@@ -1493,7 +1634,7 @@ function extractKotlin(srcRoot, profile, project, ref) {
|
|
|
1493
1634
|
for (let di = 0; di < fi.decls.length; di++) {
|
|
1494
1635
|
const d = fi.decls[di];
|
|
1495
1636
|
const id = `${fi.pkg}.${d.name}`;
|
|
1496
|
-
const end =
|
|
1637
|
+
const end = bodyEnd(fi, di);
|
|
1497
1638
|
const body = fi.lines.slice(d.line, end).join("\n");
|
|
1498
1639
|
const bind = /* @__PURE__ */ new Map();
|
|
1499
1640
|
for (const m of body.matchAll(FIELD)) {
|
|
@@ -1795,6 +1936,10 @@ var GraphSource = class {
|
|
|
1795
1936
|
this.graph();
|
|
1796
1937
|
return this.last?.delta;
|
|
1797
1938
|
}
|
|
1939
|
+
/**
|
|
1940
|
+
* The graph as it stands at the base ref. Extracted once and cached — the ref is
|
|
1941
|
+
* immutable, so a base-state render costs nothing a delta has not already paid.
|
|
1942
|
+
*/
|
|
1798
1943
|
base() {
|
|
1799
1944
|
if (this.baseGraph) return this.baseGraph;
|
|
1800
1945
|
const { repo, srcRel, profile, project, base } = this.cfg;
|
|
@@ -2006,11 +2151,23 @@ check only
|
|
|
2006
2151
|
has violations without fixing them all first.
|
|
2007
2152
|
|
|
2008
2153
|
render only
|
|
2009
|
-
--view <v> delta | impact | core | all | domain:<name>
|
|
2154
|
+
--view <v> delta | impact | core | all | violations | domain:<name>
|
|
2155
|
+
[delta]
|
|
2156
|
+
violations = only the breaches and the types they run
|
|
2157
|
+
through. A state view: it draws what is wrong now, not
|
|
2158
|
+
what this branch changed.
|
|
2159
|
+
--at <s> head | base [head]
|
|
2160
|
+
base draws the same types in their pre-branch state, for
|
|
2161
|
+
the "before" half of a before/after pair. Requires --base.
|
|
2162
|
+
Delta styling is dropped \u2014 nothing is added or modified in
|
|
2163
|
+
a picture of the past \u2014 and violations stay in red.
|
|
2010
2164
|
--layout <l> organic | grid | hex
|
|
2011
|
-
[organic for delta\xB7domain, hex for core\xB7all]
|
|
2165
|
+
[organic for delta\xB7domain\xB7violations, hex for core\xB7all]
|
|
2012
2166
|
--image <file> output path; .svg, or .png to rasterize [graph.svg]
|
|
2013
2167
|
--identifiers include identifier-only references (FamilyId\xB7UserId)
|
|
2168
|
+
|
|
2169
|
+
Nothing to draw \u2014 no violations, or a branch that only adds types and so has
|
|
2170
|
+
no before state \u2014 is reported on stdout and exits 0.
|
|
2014
2171
|
`;
|
|
2015
2172
|
function main() {
|
|
2016
2173
|
const { values: v, positionals } = parseArgs({
|
|
@@ -2028,6 +2185,7 @@ function main() {
|
|
|
2028
2185
|
mcp: { type: "boolean", default: false },
|
|
2029
2186
|
scope: { type: "string", default: "all" },
|
|
2030
2187
|
view: { type: "string", default: "delta" },
|
|
2188
|
+
at: { type: "string", default: "head" },
|
|
2031
2189
|
layout: { type: "string" },
|
|
2032
2190
|
// no default — chosen to suit the view
|
|
2033
2191
|
image: { type: "string" },
|
|
@@ -2098,26 +2256,58 @@ function main() {
|
|
|
2098
2256
|
}
|
|
2099
2257
|
if (cmd === "render") {
|
|
2100
2258
|
const out = v.image ?? "graph.svg";
|
|
2259
|
+
if (v.at !== "head" && v.at !== "base") {
|
|
2260
|
+
process.stderr.write(`--at takes head or base, not '${v.at}'
|
|
2261
|
+
`);
|
|
2262
|
+
return 1;
|
|
2263
|
+
}
|
|
2264
|
+
const atBase = v.at === "base";
|
|
2265
|
+
if (atBase && !v.base) {
|
|
2266
|
+
process.stderr.write("--at base needs --base <ref> to know which state to draw\n");
|
|
2267
|
+
return 1;
|
|
2268
|
+
}
|
|
2269
|
+
const changed = d ? /* @__PURE__ */ new Set([...d.addedNodes, ...d.modifiedNodes, ...d.removedNodes]) : void 0;
|
|
2270
|
+
const target = atBase ? source.base() : graph;
|
|
2101
2271
|
let sel;
|
|
2102
2272
|
try {
|
|
2103
|
-
sel = select(
|
|
2273
|
+
sel = select(target, v.view, v.identifiers, atBase ? changed : void 0);
|
|
2104
2274
|
} catch (e) {
|
|
2275
|
+
if (e instanceof NothingToDraw) {
|
|
2276
|
+
process.stdout.write(`${project} @ ${target.ref}
|
|
2277
|
+
${e.message}
|
|
2278
|
+
`);
|
|
2279
|
+
return 0;
|
|
2280
|
+
}
|
|
2105
2281
|
process.stderr.write(`${e.message}
|
|
2106
2282
|
`);
|
|
2107
2283
|
return 1;
|
|
2108
2284
|
}
|
|
2285
|
+
let frame;
|
|
2286
|
+
if (v.base && changed) {
|
|
2287
|
+
const half = (gr, ch) => {
|
|
2288
|
+
try {
|
|
2289
|
+
return select(gr, v.view, v.identifiers, ch).graph;
|
|
2290
|
+
} catch {
|
|
2291
|
+
return void 0;
|
|
2292
|
+
}
|
|
2293
|
+
};
|
|
2294
|
+
const head = half(graph);
|
|
2295
|
+
const base = half(source.base(), changed);
|
|
2296
|
+
if (head && base) frame = union(head, base);
|
|
2297
|
+
}
|
|
2109
2298
|
const layout = v.layout === "hex" || v.layout === "grid" || v.layout === "organic" ? v.layout : v.view === "core" || v.view === "all" ? "hex" : "organic";
|
|
2110
2299
|
const svg = renderSvg(sel.graph, {
|
|
2111
2300
|
layout,
|
|
2112
2301
|
viewLabel: sel.label,
|
|
2113
|
-
showIdentifiers: v.identifiers
|
|
2302
|
+
showIdentifiers: v.identifiers,
|
|
2303
|
+
...frame ? { frame } : {}
|
|
2114
2304
|
});
|
|
2115
2305
|
const dir = dirname2(resolve(out));
|
|
2116
2306
|
mkdirSync(dir, { recursive: true });
|
|
2117
2307
|
const svgPath = out.endsWith(".png") ? `${out.slice(0, -4)}.svg` : out;
|
|
2118
2308
|
writeFileSync(svgPath, svg);
|
|
2119
2309
|
process.stdout.write(
|
|
2120
|
-
`${project} @ ${
|
|
2310
|
+
`${project} @ ${target.ref}
|
|
2121
2311
|
view ${sel.label}
|
|
2122
2312
|
drew ${sel.graph.nodes.length} types \xB7 ${sel.graph.edges.length} relations \xB7 ${sel.graph.edges.filter((e) => e.violation).length} violations
|
|
2123
2313
|
wrote ${svgPath} (${Math.round(svg.length / 1024)} KB)
|
|
@@ -2127,8 +2317,7 @@ function main() {
|
|
|
2127
2317
|
const px = toPng(svg, out);
|
|
2128
2318
|
process.stdout.write(
|
|
2129
2319
|
px ? ` wrote ${out} (${px})
|
|
2130
|
-
` :
|
|
2131
|
-
`
|
|
2320
|
+
` : " note PNG skipped \u2014 install @resvg/resvg-js for rasterizing\n"
|
|
2132
2321
|
);
|
|
2133
2322
|
}
|
|
2134
2323
|
return 0;
|
|
@@ -2205,6 +2394,19 @@ wrote ${join6(v.out, "graph.json")} \xB7 graph.tsv
|
|
|
2205
2394
|
}
|
|
2206
2395
|
return cmd === "check" && gated.length ? 1 : 0;
|
|
2207
2396
|
}
|
|
2397
|
+
function union(a, b) {
|
|
2398
|
+
const nodes = new Map(a.nodes.map((n) => [n.id, n]));
|
|
2399
|
+
for (const n of b.nodes) if (!nodes.has(n.id)) nodes.set(n.id, n);
|
|
2400
|
+
const edges = new Map(a.edges.map((e) => [edgeKey(e), e]));
|
|
2401
|
+
for (const e of b.edges) if (!edges.has(edgeKey(e))) edges.set(edgeKey(e), e);
|
|
2402
|
+
return {
|
|
2403
|
+
...a,
|
|
2404
|
+
nodes: [...nodes.values()].sort((x, y) => x.id.localeCompare(y.id)),
|
|
2405
|
+
edges: [...edges.values()].sort(
|
|
2406
|
+
(x, y) => x.src.localeCompare(y.src) || x.dst.localeCompare(y.dst) || x.rel.localeCompare(y.rel)
|
|
2407
|
+
)
|
|
2408
|
+
};
|
|
2409
|
+
}
|
|
2208
2410
|
function toPng(svg, out) {
|
|
2209
2411
|
try {
|
|
2210
2412
|
const req = createRequire(import.meta.url);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hexwright",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Design graph for hexagonal-architecture codebases. Renders what a branch changed as one picture a human can confirm at a glance, and gives a coding agent boundary verdicts it can run on itself before asking for review.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"hexagonal-architecture",
|
|
@@ -61,7 +61,7 @@
|
|
|
61
61
|
"esbuild": "^0.28.1",
|
|
62
62
|
"tsup": "^8.3.5",
|
|
63
63
|
"typescript": "^5.7.2",
|
|
64
|
-
"vitest": "^
|
|
64
|
+
"vitest": "^4.1.10"
|
|
65
65
|
},
|
|
66
66
|
"optionalDependencies": {
|
|
67
67
|
"@resvg/resvg-js": "^2.6.2"
|