@jabworks/condux 0.22.0 → 0.23.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jabworks/condux",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "description": "OpenCode plugin for the condux workflow toolkit — bundles the condux skills and injects the specialist agents (coder, explorer, planner, researcher) plus an opt-in plan-review listener.",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -4,18 +4,29 @@
4
4
  // diagram-kit.md) and cannot see its own render. It reads one file, finds
5
5
  // every inline <svg>…</svg> block, and reports the geometry mistakes a human
6
6
  // would catch on sight: an edge that runs through a box it does not connect
7
- // to, a label sitting on another label or on a node's own title text, an
8
- // edge nobody labeled, and text that falls outside the canvas.
7
+ // to, a label sitting on another label or on a node's own title text, a
8
+ // title wider than its own box, an edge nobody labeled, and text that falls
9
+ // outside the canvas.
9
10
  //
10
11
  // This is a regex-level reader, not a DOM. It understands exactly the
11
12
  // idioms diagram-kit.md teaches — `<g transform="translate(x,y)">` offsets,
12
13
  // `var(--…)` fills/strokes (never resolved — colors play no part in any
13
14
  // check), one shared `<marker>` — and two known approximations:
14
15
  //
15
- // - text width is estimated at 0.6 em per character (no font metrics);
16
+ // - text width is estimated at 0.6 em per character (no font metrics) —
17
+ // measured exact for the kit's mono labels, generous for regular sans,
18
+ // a little narrow for semibold titles on wide fallback fonts;
16
19
  // - Bézier and arc commands (C S Q T A) contribute only their final
17
20
  // endpoint as a straight segment — curvature itself is invisible to it.
18
21
  //
22
+ // Font size is resolved the way a browser cascades it, restricted to what a
23
+ // regex can see: a `.class { font-size: Npx }` rule matching the text's
24
+ // `class`, then a bare `text` / `svg text` rule, then the element's own
25
+ // `font-size` attribute, then one inherited from an enclosing `<g>` or the
26
+ // `<svg>` tag, and finally the browser default of 16px — reported on stderr
27
+ // when it has to be assumed, because an unsized diagram is read blind. Any
28
+ // other selector shape, and any unit but px, is invisible to it.
29
+ //
19
30
  // No dependencies, no network, no shelling out, no filesystem writes.
20
31
 
21
32
  import fs from 'node:fs';
@@ -24,9 +35,13 @@ import { pathToFileURL } from 'node:url';
24
35
  // ---------------------------------------------------------------------------
25
36
  // Small geometry helpers. A "box" is always { x0, y0, x1, y1 } in viewBox
26
37
  // units; nodes/boundaries are boxes with a `line` and (once assigned) a
27
- // `title`; texts are boxes with `text`, `line`, `free`, `ownEdge`.
38
+ // `title`; texts are boxes with `text`, `line`, `ax`/`ay` (the anchor
39
+ // point), `free`, `owner`, `ownEdge`.
28
40
  // ---------------------------------------------------------------------------
29
41
 
42
+ // What a browser uses when nothing in the document sizes the text.
43
+ const DEFAULT_FONT_SIZE = 16;
44
+
30
45
  const shrinkBox = (box, n) => ({ x0: box.x0 + n, y0: box.y0 + n, x1: box.x1 - n, y1: box.y1 - n });
31
46
 
32
47
  const expandBox = (box, n) => shrinkBox(box, -n);
@@ -185,6 +200,42 @@ const stripTags = (s) =>
185
200
  // idiom.
186
201
  // ---------------------------------------------------------------------------
187
202
 
203
+ // The two style-rule shapes the checker reads, from every <style> block in
204
+ // the document (comments already blanked): `text { font-size: Npx }` or
205
+ // `svg text { … }` sets the base, `.name { … }` / `text.name { … }` /
206
+ // `svg .name { … }` maps a class. Later rules win, as in a stylesheet.
207
+ // Anything else — element ids, descendant chains, `em`/`rem`/`var()` — is
208
+ // skipped: a size the checker cannot resolve must not become a guess.
209
+ const BASE_SELECTOR = /^(?:svg\s+)?text$/;
210
+ const CLASS_SELECTOR = /^(?:svg\s+)?(?:text)?\.([\w-]+)$/;
211
+
212
+ const parseStyleFontSizes = (source) => {
213
+ const styles = { base: null, classes: new Map() };
214
+ const styleRe = /<style\b[^>]*>([\s\S]*?)<\/style>/gi;
215
+ const ruleRe = /([^{}]+)\{([^}]*)\}/g;
216
+ let sm;
217
+
218
+ while ((sm = styleRe.exec(source))) {
219
+ // A CSS comment is not a rule, and left in place it glues itself onto
220
+ // the selector that follows it.
221
+ const css = sm[1].replace(/\/\*[\s\S]*?\*\//g, ' ');
222
+ let rm;
223
+ while ((rm = ruleRe.exec(css))) {
224
+ const size = /(?:^|[\s;{])font-size\s*:\s*(\d+(?:\.\d+)?)px\b/.exec(rm[2]);
225
+ if (!size) continue;
226
+
227
+ for (const selector of rm[1].split(',')) {
228
+ const s = selector.trim();
229
+ if (BASE_SELECTOR.test(s)) styles.base = parseFloat(size[1]);
230
+ const cm = CLASS_SELECTOR.exec(s);
231
+ if (cm) styles.classes.set(cm[1], parseFloat(size[1]));
232
+ }
233
+ }
234
+ }
235
+
236
+ return styles;
237
+ };
238
+
188
239
  const CURVE_ARG_COUNTS = { C: 6, S: 4, Q: 4, T: 2, A: 7 };
189
240
 
190
241
  const parsePathSegments = (d, warnCurve) => {
@@ -300,10 +351,14 @@ const parsePathSegments = (d, warnCurve) => {
300
351
  // (and any positioned <tspan> children) without a general text-node model.
301
352
  // ---------------------------------------------------------------------------
302
353
 
303
- const walkBlock = (source, blockOffset, blockSource, findLine, notify) => {
354
+ const walkBlock = (source, blockOffset, blockSource, findLine, notify, styles) => {
304
355
  const rects = [];
305
356
  const texts = [];
306
357
  const edges = [];
358
+ let defaulted = 0;
359
+ const onDefault = () => {
360
+ defaulted++;
361
+ };
307
362
 
308
363
  const svgOpenMatch = /^<svg\b[^>]*>/.exec(blockSource);
309
364
  const svgOpenTag = svgOpenMatch ? svgOpenMatch[0] : '<svg>';
@@ -319,7 +374,9 @@ const walkBlock = (source, blockOffset, blockSource, findLine, notify) => {
319
374
 
320
375
  let curOffsetX = 0;
321
376
  let curOffsetY = 0;
322
- let curFontSize = parseFloat(getAttr(svgOpenTag, 'font-size') ?? '') || 13;
377
+ // null until some enclosing tag carries a font-size — the text element then
378
+ // decides between its own attribute, the style rules, and the default.
379
+ let curFontSize = parseFloat(getAttr(svgOpenTag, 'font-size') ?? '') || null;
323
380
  let skipDepth = 0;
324
381
  const stack = [];
325
382
  // The frame a closing tag restores: offsets, inherited font size, and
@@ -364,6 +421,8 @@ const walkBlock = (source, blockOffset, blockSource, findLine, notify) => {
364
421
  offX: curOffsetX,
365
422
  offY: curOffsetY,
366
423
  curFontSize,
424
+ styles,
425
+ onDefault,
367
426
  texts,
368
427
  lineNo,
369
428
  });
@@ -430,6 +489,12 @@ const walkBlock = (source, blockOffset, blockSource, findLine, notify) => {
430
489
  if (!selfClosing) stack.push(snapshot());
431
490
  }
432
491
 
492
+ if (defaulted > 0) {
493
+ notify(
494
+ `diagram-check: ${defaulted} text element(s) carry no font-size from an attribute, an enclosing tag, or a text/.class style rule — assumed ${DEFAULT_FONT_SIZE}px; set font-size on the <svg> tag so widths are read as drawn`,
495
+ );
496
+ }
497
+
433
498
  return { rects, texts, edges, viewBox };
434
499
  };
435
500
 
@@ -518,15 +583,39 @@ const pushTextBox = (texts, text, absX, absY, anchor, fontSize, lineNo) => {
518
583
  x1 = absX + width;
519
584
  }
520
585
 
521
- texts.push({ x0, y0: top, x1, y1: bottom, text, line: lineNo });
586
+ texts.push({ x0, y0: top, x1, y1: bottom, ax: absX, ay: absY, text, line: lineNo });
522
587
  };
523
588
 
524
- const processTextElement = ({ tag, rawContent, contentOffset, findLine, offX, offY, curFontSize, texts, lineNo }) => {
589
+ // The cascade a browser applies to a <text>, restricted to the shapes the
590
+ // style scan understands: a matching class rule beats a bare `text` rule,
591
+ // which beats the element's own attribute, which beats an inherited one.
592
+ // `inherited` is null when no enclosing tag carried a size; `onDefault` is
593
+ // called when the browser default has to stand in.
594
+ const resolveFontSize = (tag, inherited, styles, onDefault) => {
595
+ const classAttr = getAttr(tag, 'class');
596
+ if (classAttr) {
597
+ for (const cls of classAttr.trim().split(/\s+/)) {
598
+ if (styles.classes.has(cls)) return styles.classes.get(cls);
599
+ }
600
+ }
601
+
602
+ if (styles.base !== null) return styles.base;
603
+
604
+ const attr = getAttr(tag, 'font-size');
605
+ if (attr) return parseFloat(attr);
606
+
607
+ if (inherited !== null) return inherited;
608
+
609
+ onDefault();
610
+
611
+ return DEFAULT_FONT_SIZE;
612
+ };
613
+
614
+ const processTextElement = ({ tag, rawContent, contentOffset, findLine, offX, offY, curFontSize, styles, onDefault, texts, lineNo }) => {
525
615
  const x = parseFloat(getAttr(tag, 'x') ?? '0');
526
616
  const y = parseFloat(getAttr(tag, 'y') ?? '0');
527
617
  const anchor = getAttr(tag, 'text-anchor') ?? 'start';
528
- const fontSizeAttr = getAttr(tag, 'font-size');
529
- const fontSize = fontSizeAttr ? parseFloat(fontSizeAttr) : curFontSize;
618
+ const fontSize = resolveFontSize(tag, curFontSize, styles, onDefault);
530
619
 
531
620
  const tspanRe = /<tspan\b([^>]*)>([\s\S]*?)<\/tspan>/g;
532
621
  const tspans = [];
@@ -594,9 +683,12 @@ const findingsForBlock = ({ rects, texts, edges, viewBox }) => {
594
683
  const nodes = rects.filter((r) => !r.isBoundary);
595
684
 
596
685
  // Owned vs. free text, and each node's title (first owned text, in
597
- // document order).
686
+ // document order). A text belongs to the node its box sits in — or, when
687
+ // the box has outgrown the node, the node its anchor point sits in: a
688
+ // title too wide for its box is still that box's title, and reporting it
689
+ // as a free label straddling the node would misname the defect.
598
690
  for (const text of texts) {
599
- const owner = nodes.find((node) => fullyInside(text, node, 1));
691
+ const owner = nodes.find((node) => fullyInside(text, node, 1) || pointInBox(text.ax, text.ay, node));
600
692
  if (owner) {
601
693
  text.free = false;
602
694
  text.owner = owner;
@@ -666,11 +758,13 @@ const findingsForBlock = ({ rects, texts, edges, viewBox }) => {
666
758
  }
667
759
  }
668
760
 
669
- // 3. label-over-box (free text straddling a node's border)
761
+ // 3. label-over-box (a text box straddling the border of a node it does
762
+ // not belong to) and text-overflows-box (a node's own text wider or
763
+ // taller than the node, past the 1-unit tolerance)
670
764
  for (const text of texts) {
671
- if (!text.free) continue;
672
-
673
765
  for (const node of nodes) {
766
+ if (node === text.owner) continue;
767
+
674
768
  if (rectIntersect(shrinkBox(text, 1), node)) {
675
769
  findings.push({
676
770
  code: 'label-over-box',
@@ -679,6 +773,20 @@ const findingsForBlock = ({ rects, texts, edges, viewBox }) => {
679
773
  });
680
774
  }
681
775
  }
776
+
777
+ if (text.owner && !fullyInside(text, text.owner, 1)) {
778
+ const { owner } = text;
779
+ const over = Math.max(owner.x0 - text.x0, text.x1 - owner.x1, owner.y0 - text.y0, text.y1 - owner.y1);
780
+ const where =
781
+ owner.title === text.text
782
+ ? `its own ${fmt(owner.x1 - owner.x0)}×${fmt(owner.y1 - owner.y0)} box`
783
+ : describeNode(owner);
784
+ findings.push({
785
+ code: 'text-overflows-box',
786
+ line: text.line,
787
+ detail: `"${text.text}" overflows ${where} by ${fmt(over)}`,
788
+ });
789
+ }
682
790
  }
683
791
 
684
792
  // 4. edge-through-label (excluding a free text's own edge)
@@ -736,8 +844,9 @@ const findingsForBlock = ({ rects, texts, edges, viewBox }) => {
736
844
  // Public API
737
845
  // ---------------------------------------------------------------------------
738
846
 
739
- // Finds every <svg>…</svg> block in `source` and runs all six checks against
740
- // each. Returns Finding[] = { code, line, detail }[], sorted by line then code.
847
+ // Finds every <svg>…</svg> block in `source` and runs all seven checks
848
+ // against each. Returns Finding[] = { code, line, detail }[], sorted by line
849
+ // then code.
741
850
  export const checkSvg = (rawSource) => {
742
851
  // The kit's own marker block carries `<!-- usage: <line … /> -->`, and the
743
852
  // kit says to paste it verbatim — scanned raw, that comment is a phantom
@@ -745,6 +854,7 @@ export const checkSvg = (rawSource) => {
745
854
  // comments character-for-character (newlines kept) so line numbers hold.
746
855
  const source = rawSource.replace(/<!--[\s\S]*?-->/g, (comment) => comment.replace(/[^\n]/g, ' '));
747
856
  const findLine = makeLineFinder(source);
857
+ const styles = parseStyleFontSizes(source);
748
858
  let curveWarned = false;
749
859
  const notify = (msg) => process.stderr.write(msg + '\n');
750
860
  notify.curve = () => {
@@ -758,7 +868,7 @@ export const checkSvg = (rawSource) => {
758
868
  let m;
759
869
 
760
870
  while ((m = blockRe.exec(source))) {
761
- const parsed = walkBlock(source, m.index, m[0], findLine, notify);
871
+ const parsed = walkBlock(source, m.index, m[0], findLine, notify, styles);
762
872
  findings.push(...findingsForBlock(parsed));
763
873
  }
764
874
 
@@ -8,8 +8,10 @@ of the `<style>` block, verbatim.
8
8
 
9
9
  ## Shared Conventions
10
10
 
11
- - Canvas: `<svg viewBox="0 0 W H" style="max-width:100%">` sized to content;
12
- the page wraps it in the wireframe kit's `.frame` shell (either mode's).
11
+ - Canvas: `<svg viewBox="0 0 W H" font-size="13" style="max-width:100%">`
12
+ sized to content; the page wraps it in the wireframe kit's `.frame` shell
13
+ (either mode's). The `font-size` is the base every text inherits — see
14
+ Sizing below.
13
15
  - Palette is token roles, referenced with `var(--…)` (CSS variables work in
14
16
  inline SVG attributes): fills `var(--card)` / `var(--muted)`, strokes
15
17
  `var(--border)`, edges `var(--subtle)`, text `var(--foreground)`. The one
@@ -19,6 +21,17 @@ of the `<style>` block, verbatim.
19
21
  rows, cardinalities, edge labels (D2). 13–14px labels, 11px annotations
20
22
  in `var(--muted-foreground)`. If a label doesn't fit its box, the box
21
23
  grows — never shrink the font below 11px.
24
+ - Sizing: the base size goes on the `<svg>` tag (`font-size="13"`), every
25
+ other size as a `font-size` attribute on the `<text>` itself. Never a
26
+ `text { font-size }` rule in `<style>` — in the browser it overrides every
27
+ `font-size` attribute in the diagram, so the sizes you wrote are not the
28
+ sizes that render. The checker resolves sizes the way the browser does but
29
+ sees only attributes, a bare `text` rule and `.class` rules; a text it
30
+ cannot size is read at the browser's 16px and reported on stderr. Budget
31
+ width at 0.6 em per character (exact for mono, generous for regular sans,
32
+ a little tight for semibold titles): at 16px a 23-character title needs
33
+ more than a 210 box, and when it doesn't fit the box grows — the checker
34
+ reports `text-overflows-box`, never a smaller font.
22
35
  - Arrowheads via one shared `<marker>`:
23
36
 
24
37
  ```html
@@ -62,8 +75,10 @@ of the `<style>` block, verbatim.
62
75
  `<rect>` in the fill the label sits on (`var(--background)` on open
63
76
  canvas, `var(--muted)` inside a filled boundary) drawn behind the text —
64
77
  the checker ignores stroke-less rects, so the halo is never mistaken for a
65
- node. Two labels never share a corridor position: stagger by 14px or move
66
- one to another segment.
78
+ node. Size the halo from the same budget as the label — characters × 11 ×
79
+ 0.6, plus 4px each side — a halo narrower than its label lets the crossing
80
+ edge show through the ends. Two labels never share a corridor position:
81
+ stagger by 14px or move one to another segment.
67
82
  6. *Fan-in is a smell.* More than four edges into one box, or more than three
68
83
  edges in one gutter, means the diagram is answering two questions — split
69
84
  it (same rule as "Choosing the Shape").
@@ -78,7 +93,8 @@ of the `<style>` block, verbatim.
78
93
  |---|---|
79
94
  | `edge-through-box` | an edge segment crosses a node rect it doesn't attach to |
80
95
  | `label-over-label` | two text boxes intersect |
81
- | `label-over-box` | a text box straddles a node rect border |
96
+ | `label-over-box` | a text box straddles the border of a node it doesn't belong to |
97
+ | `text-overflows-box` | a node's own text is wider or taller than the node |
82
98
  | `edge-through-label` | an edge crosses a text box that isn't its own label |
83
99
  | `unlabeled-edge` | no text within 24px of the edge |
84
100
  | `text-outside-canvas` | text escapes the viewBox |