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.
Files changed (3) hide show
  1. package/README.md +72 -4
  2. package/dist/cli.js +262 -60
  3. 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, which is what makes two branches comparable.
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 `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.
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
- function select(g, view, showIdentifiers = false) {
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
- if (view === "all") {
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((e) => keep.has(e.src) && keep.has(e.dst));
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 hueOf = hueMap(g.nodes.map((n) => n.domain));
766
- const place = layout === "hex" ? hexLayout(g) : layout === "organic" ? organicLayout(g) : gridLayout(g, gridWidthFor(g));
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 placed = g.nodes.map((n) => {
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
- x += 150;
969
- rows.push(
970
- `<rect x="${x}" y="${y - 7}" width="14" height="14" rx="3" fill="none" stroke="${ADDED}" stroke-width="3"/>`,
971
- `<text x="${x + 20}" y="${y + 4}" font-size="11" fill="${ADDED}">added</text>`,
972
- `<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"/>`,
973
- `<text x="${x + 86}" y="${y + 4}" font-size="11" fill="${MODIFIED}">modified</text>`
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 stripComments(text) {
1149
+ function stripNonCode(text) {
1117
1150
  const out = [];
1118
- let inBlock = false;
1119
- let inLine = false;
1120
- let inStr = false;
1121
- for (let i = 0; i < text.length; i++) {
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 (inLine) {
1124
- if (c === "\n") {
1125
- inLine = false;
1126
- out.push("\n");
1127
- } else out.push(" ");
1128
- continue;
1129
- }
1130
- if (inBlock) {
1131
- if (c === "*" && text[i + 1] === "/") {
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
- inBlock = false;
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
- out.push(c === "\n" ? "\n" : " ");
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 (!inStr && c === "/" && text[i + 1] === "*") {
1141
- inBlock = true;
1142
- out.push(" ");
1143
- i++;
1214
+ if (!f.raw && c === "\\" && i + 1 < n) {
1215
+ out.push(" ", " ");
1216
+ i += 2;
1144
1217
  continue;
1145
1218
  }
1146
- if (!inStr && c === "/" && text[i + 1] === "/") {
1147
- inLine = true;
1148
- out.push(" ");
1219
+ if (!f.raw && c === "\n") {
1220
+ out.push("\n");
1221
+ stack.pop();
1149
1222
  i++;
1150
1223
  continue;
1151
1224
  }
1152
- if (c === '"') inStr = !inStr;
1153
- else if (c === "\n") inStr = false;
1154
- out.push(c);
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 = stripComments(readFileSync3(abs, "utf8")).split("\n");
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({ rel, pkg, imports, decls, lines, layer: layerOf(profile, `/${rel}`) });
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 = di + 1 < fi.decls.length ? fi.decls[di + 1].line : fi.lines.length;
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 = di + 1 < fi.decls.length ? fi.decls[di + 1].line : fi.lines.length;
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 = di + 1 < fi.decls.length ? fi.decls[di + 1].line : fi.lines.length;
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> [delta]
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(graph, v.view, v.identifiers);
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} @ ${graph.ref}
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
- ` : ` note PNG skipped \u2014 install @resvg/resvg-js for rasterizing
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.1.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": "^2.1.8"
64
+ "vitest": "^4.1.10"
65
65
  },
66
66
  "optionalDependencies": {
67
67
  "@resvg/resvg-js": "^2.6.2"