@rse/specbook 1.2.15 → 1.2.16

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 (44) hide show
  1. package/README.md +98 -11
  2. package/dst/specbook-api.d.ts +22 -21
  3. package/dst/specbook-api.js +80 -27
  4. package/dst/specbook-cli.js +50 -23
  5. package/dst/specbook-cmd-describe.js +8 -4
  6. package/dst/specbook-cmd-export.d.ts +4 -1
  7. package/dst/specbook-cmd-export.js +22 -12
  8. package/dst/specbook-cmd-init.js +2 -8
  9. package/dst/specbook-cmd-lint.d.ts +2 -1
  10. package/dst/specbook-cmd-lint.js +31 -28
  11. package/dst/specbook-config.d.ts +7 -0
  12. package/dst/specbook-config.js +6 -1
  13. package/dst/specbook-coverage.js +3 -4
  14. package/dst/specbook-diagram.d.ts +3 -1
  15. package/dst/specbook-diagram.js +78 -27
  16. package/dst/specbook-export-ast.d.ts +2 -1
  17. package/dst/specbook-export-ast.js +16 -8
  18. package/dst/specbook-export-common.d.ts +7 -0
  19. package/dst/specbook-export-common.js +36 -3
  20. package/dst/specbook-export-html.css +64 -18
  21. package/dst/specbook-export-html.d.ts +2 -1
  22. package/dst/specbook-export-html.js +517 -275
  23. package/dst/specbook-export-image.d.ts +3 -0
  24. package/dst/specbook-export-image.js +121 -0
  25. package/dst/specbook-export-md.d.ts +8 -2
  26. package/dst/specbook-export-md.js +67 -6
  27. package/dst/specbook-export-pdf.js +7 -4
  28. package/dst/specbook-format.md +35 -8
  29. package/dst/specbook-format.yaml +12 -9
  30. package/dst/specbook-ignore.js +5 -5
  31. package/dst/specbook-link.js +1 -1
  32. package/dst/specbook-mcp.js +51 -34
  33. package/dst/specbook-parse-common.d.ts +4 -1
  34. package/dst/specbook-parse-common.js +15 -3
  35. package/dst/specbook-parse-semantic.d.ts +1 -1
  36. package/dst/specbook-parse-syntax.d.ts +1 -1
  37. package/dst/specbook-parse-syntax.js +28 -8
  38. package/dst/specbook-parse.js +6 -2
  39. package/dst/specbook-project.d.ts +7 -0
  40. package/dst/specbook-project.js +93 -0
  41. package/dst/specbook-theme.d.ts +1 -1
  42. package/dst/specbook-theme.js +9 -8
  43. package/dst/specbook-verbose.js +4 -9
  44. package/package.json +14 -6
@@ -0,0 +1,3 @@
1
+ import type { Spec } from "./specbook-format-spec.js";
2
+ import { type Verbose } from "./specbook-verbose.js";
3
+ export declare const optimizeImages: (specification: Spec, print: boolean, verbose?: Verbose) => Promise<Map<string, string>>;
@@ -0,0 +1,121 @@
1
+ /*
2
+ ** Specification Book (SpecBook)
3
+ ** Copyright (c) 2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
4
+ ** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
5
+ */
6
+ import { literal } from "./specbook-verbose.js";
7
+ /* the pixel width the raster images are capped to: the 60rem content
8
+ width of the document (at 16px per rem) at a 2x device pixel ratio */
9
+ const maxWidth = 60 * 16 * 2;
10
+ /* the lossy encoding quality of the re-encoded raster images */
11
+ const quality = 85;
12
+ /* the in-memory caches of the optimized images (one per target medium,
13
+ as the two optimize a PNG differently), keyed by the embedded image
14
+ content and swept to the images of the latest optimization for the
15
+ medium, so they serve the repeated renderings of a process (watch,
16
+ preview, MCP, and the passes and formats of a single export) without
17
+ growing over a long-running one */
18
+ const imageCaches = {
19
+ screen: new Map(),
20
+ print: new Map()
21
+ };
22
+ /* optimize a single SVG image with SVGO (the identifiers and classes
23
+ are free to be minified, as the image is rendered in isolation
24
+ through an <img> element), after stripping the "content" attribute
25
+ draw.io leaves on the root element: it carries the entire
26
+ entity-escaped diagram source, which is needless for the rendering
27
+ and exceeds the entity limit of the SVGO parser (an SVG which SVGO
28
+ still rejects stays just stripped) */
29
+ const optimizeSvg = async (content) => {
30
+ const stripped = content.replace(/(<svg\b[^>]*?)\s+content="[^"]*"/, "$1");
31
+ try {
32
+ const { optimize } = await import("svgo");
33
+ return optimize(stripped, { multipass: true }).data;
34
+ }
35
+ catch {
36
+ return stripped;
37
+ }
38
+ };
39
+ /* optimize a single raster image given as a base64 data: URL with
40
+ Sharp: an image wider than the cap is downscaled and a JPEG stays a
41
+ JPEG, while a PNG is converted to WebP for the screen and to JPEG
42
+ for print, as Chromium passes just this format through into the PDF
43
+ unchanged and embeds every other one losslessly (as JPEG knows no
44
+ transparency, the image is flattened onto the white of the paper); a
45
+ WebP is treated like a PNG, but for the screen one within the cap
46
+ stays untouched, as re-encoding it again would just degrade it */
47
+ const optimizeRaster = async (content, print) => {
48
+ const m = content.match(/^data:(image\/(?:png|jpeg|webp));base64,(.*)$/s);
49
+ if (m === null)
50
+ return content;
51
+ const { default: sharp } = await import("sharp");
52
+ const source = sharp(Buffer.from(m[2], "base64"));
53
+ if (m[1] === "image/webp" && !print && ((await source.metadata()).width ?? 0) <= maxWidth)
54
+ return content;
55
+ const image = source
56
+ .rotate()
57
+ .resize({ width: maxWidth, withoutEnlargement: true });
58
+ const webp = m[1] !== "image/jpeg" && !print;
59
+ const data = webp ?
60
+ await image.webp({ quality }).toBuffer() :
61
+ await image.flatten({ background: "#ffffff" }).jpeg({ quality, mozjpeg: true }).toBuffer();
62
+ const type = webp ? "image/webp" : "image/jpeg";
63
+ return `data:${type};base64,${data.toString("base64")}`;
64
+ };
65
+ /* collect the embedded image contents of an object and its descendants
66
+ (the empty entries of the unreadable files left out) */
67
+ const collect = (object, contents) => {
68
+ for (const content of object.description?.embedding ?? [])
69
+ if (content !== "")
70
+ contents.add(content);
71
+ for (const property of object.properties)
72
+ for (const content of property.embedding ?? [])
73
+ if (content !== "")
74
+ contents.add(content);
75
+ for (const child of object.children)
76
+ collect(child, contents);
77
+ };
78
+ /* optimize the embedded images of a specification for the HTML export
79
+ (the screen) or the PDF export (print), yielding the map from the
80
+ embedded image contents onto their optimized ones, served from the
81
+ cache where possible: an optimization which fails (an image the
82
+ libraries cannot process, or a platform Sharp provides no binary
83
+ for) or which does not shrink the image keeps the original, so the
84
+ export never depends on it */
85
+ export const optimizeImages = async (specification, print, verbose) => {
86
+ const medium = print ? "print" : "screen";
87
+ const contents = new Set();
88
+ for (const artifact of specification.artifacts)
89
+ for (const object of artifact.objects)
90
+ collect(object, contents);
91
+ const cache = new Map();
92
+ let cached = 0;
93
+ let before = 0;
94
+ let after = 0;
95
+ for (const content of contents) {
96
+ let optimized = imageCaches[medium].get(content);
97
+ if (optimized !== undefined)
98
+ cached++;
99
+ else {
100
+ try {
101
+ optimized = content.startsWith("data:") ?
102
+ await optimizeRaster(content, print) : await optimizeSvg(content);
103
+ }
104
+ catch {
105
+ optimized = content;
106
+ }
107
+ if (optimized.length >= content.length)
108
+ optimized = content;
109
+ }
110
+ cache.set(content, optimized);
111
+ before += content.length;
112
+ after += optimized.length;
113
+ }
114
+ imageCaches[medium] = cache;
115
+ if (contents.size > 0)
116
+ verbose?.((cached === contents.size ?
117
+ `reusing ${literal(cached)} cached image(s) for ${medium}: ` :
118
+ `optimizing ${literal(contents.size)} image(s) for ${medium} (${literal(cached)} cached): `) +
119
+ `${literal(Math.round(before / 1024))} KB -> ${literal(Math.round(after / 1024))} KB`);
120
+ return cache;
121
+ };
@@ -1,3 +1,9 @@
1
- import type { Spec } from "./specbook-format-spec.js";
1
+ import type { Spec, SpecArtifact } from "./specbook-format-spec.js";
2
2
  import type { Schema } from "./specbook-format-schema.js";
3
- export declare const renderMarkdown: (specification: Spec, config?: Schema) => string;
3
+ import type { Verbose } from "./specbook-verbose.js";
4
+ export declare const formatTimestamp: (date: Date) => string;
5
+ export interface MarkdownRebase {
6
+ origins: Map<SpecArtifact, string>;
7
+ dir: string;
8
+ }
9
+ export declare const renderMarkdown: (specification: Spec, config?: Schema, rebase?: MarkdownRebase, verbose?: Verbose) => Promise<string>;
@@ -3,11 +3,13 @@
3
3
  ** Copyright (c) 2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
4
4
  ** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
5
5
  */
6
+ import * as path from "node:path";
6
7
  import { specDiagrams } from "./specbook-diagram.js";
7
- import { becauseRegex } from "./specbook-parse-common.js";
8
+ import { becauseRegex, embeddingRegex, embeddingCount } from "./specbook-parse-common.js";
8
9
  import { isTitleObject } from "./specbook-export-common.js";
10
+ import { optimizeImages } from "./specbook-export-image.js";
9
11
  /* format a timestamp in the frontmatter format */
10
- const formatTimestamp = (date) => {
12
+ export const formatTimestamp = (date) => {
11
13
  const pad = (n) => String(n).padStart(2, "0");
12
14
  return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ` +
13
15
  `${pad(date.getHours())}:${pad(date.getMinutes())}`;
@@ -91,8 +93,19 @@ const renderObjectMd = (object, level, diagrams) => {
91
93
  parts.push(...object.children.map((child) => renderObjectMd(child, level + 1, diagrams)));
92
94
  return parts.filter((part) => part !== "").join("\n\n");
93
95
  };
94
- /* render the entire specification into normalized Markdown */
95
- export const renderMarkdown = (specification, config) => {
96
+ /* re-base the local relative image references of a text from the
97
+ directory of their source file onto the one of the output file */
98
+ const rebaseMd = (text, from, to) => text.replace(embeddingRegex, (match, alt, reference) => {
99
+ const file = reference?.trim();
100
+ if (file === undefined || /^[a-z][a-z0-9+.-]+:/i.test(file) || path.isAbsolute(file))
101
+ return match;
102
+ const rebased = path.relative(to, path.resolve(from, file)).split(path.sep).join("/");
103
+ return `![${alt}](${rebased})`;
104
+ });
105
+ /* render the entire specification into normalized Markdown, embedding
106
+ the images (so the export stands alone) and optionally re-basing the
107
+ remaining image references onto the output directory */
108
+ export const renderMarkdown = async (specification, config, rebase, verbose) => {
96
109
  /* derive the Gradia specs of the diagram-configured objects
97
110
  (an invalid diagram situation omits the diagram, as it is
98
111
  already reported as a lint diagnostic, and the diagram of
@@ -102,6 +115,54 @@ export const renderMarkdown = (specification, config) => {
102
115
  for (const [object, result] of specDiagrams(specification, config))
103
116
  if (result.spec !== undefined && !isTitleObject(object))
104
117
  diagrams.set(object, result.spec);
118
+ /* embed the images of a text, optimized like the ones of the HTML
119
+ export: an embeddable "![alt](file)" becomes the reference-style
120
+ "![alt][img-N]", whose "[img-N]: data:..." definition ends the
121
+ document (a "{theme}" one takes its light variant, as Markdown
122
+ knows no themes), and only the remaining ones are re-based */
123
+ const optimized = await optimizeImages(specification, false, verbose);
124
+ const labels = new Map();
125
+ const embedMd = (text, embedding, from, to) => {
126
+ let i = 0;
127
+ const embedded = text.replace(embeddingRegex, (match, alt, reference) => {
128
+ const count = embeddingCount(reference);
129
+ const content = count > 0 ? embedding[i] : undefined;
130
+ i += count;
131
+ if (content === undefined || content === "")
132
+ return match;
133
+ const image = optimized.get(content) ?? content;
134
+ const url = image.startsWith("data:") ? image :
135
+ `data:image/svg+xml;base64,${Buffer.from(image, "utf8").toString("base64")}`;
136
+ const label = labels.get(url) ?? `img-${labels.size + 1}`;
137
+ labels.set(url, label);
138
+ return `![${alt}][${label}]`;
139
+ });
140
+ return from !== undefined && to !== undefined && from !== to ?
141
+ rebaseMd(embedded, from, to) : embedded;
142
+ };
143
+ /* embed and re-base on clones of the objects (as the AST is shared
144
+ with the other exports), moving the diagrams along */
145
+ const localized = (object, from, to) => {
146
+ const clone = {
147
+ ...object,
148
+ properties: object.properties.map((property) => ({ ...property, value: embedMd(property.value, property.embedding ?? [], from, to) })),
149
+ children: object.children.map((child) => localized(child, from, to))
150
+ };
151
+ if (object.description !== undefined)
152
+ clone.description = { ...object.description,
153
+ description: embedMd(object.description.description, object.description.embedding ?? [], from, to) };
154
+ const spec = diagrams.get(object);
155
+ if (spec !== undefined)
156
+ diagrams.set(clone, spec);
157
+ return clone;
158
+ };
159
+ const objects = specification.artifacts.flatMap((artifact) => {
160
+ const origin = rebase?.origins.get(artifact);
161
+ const from = origin !== undefined ? path.resolve(path.dirname(origin)) : undefined;
162
+ const to = rebase !== undefined ? path.resolve(rebase.dir) : undefined;
163
+ return artifact.objects.map((object) => localized(object, from, to));
164
+ });
165
+ const definitions = Array.from(labels, ([url, label]) => `[${label}]: ${url}\n`).join("");
105
166
  /* the single frontmatter block (only recognized at the start of a
106
167
  file on re-parse) carries the earliest creation and the latest
107
168
  modification timestamp of all artifacts */
@@ -111,6 +172,6 @@ export const renderMarkdown = (specification, config) => {
111
172
  `Created: ${formatTimestamp(created)}\n` +
112
173
  `Modified: ${formatTimestamp(modified)}\n` +
113
174
  "---\n\n" +
114
- specification.artifacts.flatMap((artifact) => artifact.objects)
115
- .map((object) => renderObjectMd(object, 1, diagrams)).join("\n\n") + "\n";
175
+ objects.map((object) => renderObjectMd(object, 1, diagrams)).join("\n\n") + "\n" +
176
+ (definitions !== "" ? `\n${definitions}` : "");
116
177
  };
@@ -13,6 +13,7 @@ const anchorPages = async (pdf) => {
13
13
  const doc = await PDFDocument.load(pdf);
14
14
  const indexes = new Map();
15
15
  doc.getPages().forEach((page, index) => indexes.set(page.ref.toString(), index + 1));
16
+ /* map every named destination onto the page number of its target */
16
17
  const pages = new Map();
17
18
  const dests = doc.catalog.lookupMaybe(PDFName.of("Dests"), PDFDict);
18
19
  if (dests !== undefined) {
@@ -46,7 +47,7 @@ const addOutline = async (doc, entries) => {
46
47
  targets.set(name.decodeText(), dest);
47
48
  }
48
49
  }
49
- const resolve = (entries) => entries.flatMap((entry) => {
50
+ const resolve = (list) => list.flatMap((entry) => {
50
51
  const dest = targets.get(entry.anchor);
51
52
  const children = resolve(entry.children);
52
53
  return dest !== undefined ? [{ title: entry.title, dest, children }] : children;
@@ -56,9 +57,9 @@ const addOutline = async (doc, entries) => {
56
57
  return;
57
58
  /* recursively materialize the outline items, wiring up the
58
59
  Parent/Prev/Next/First/Last cross-references of the tree */
59
- const materialize = (items, parent) => {
60
- const refs = items.map(() => context.nextRef());
61
- items.forEach((item, i) => {
60
+ const materialize = (list, parent) => {
61
+ const refs = list.map(() => context.nextRef());
62
+ list.forEach((item, i) => {
62
63
  const children = item.children.length > 0 ? materialize(item.children, refs[i]) : undefined;
63
64
  context.assign(refs[i], context.obj({
64
65
  Title: PDFHexString.fromText(item.title),
@@ -163,6 +164,8 @@ const resolveBrowser = async (verbose) => {
163
164
  `"${literal("SPECBOOK_BROWSER")}"`);
164
165
  return options;
165
166
  }
167
+ /* prefer the downloaded Playwright Chromium and
168
+ else probe a system-installed Google Chrome */
166
169
  let executable = "";
167
170
  try {
168
171
  executable = chromium.executablePath();
@@ -151,6 +151,9 @@ type SchemaGradiaConfig = Partial<{
151
151
  "color-node-ghost-name": string
152
152
  "color-node-ghost-box": string
153
153
  "color-node-ghost-border": string
154
+ "color-node-self-name": string
155
+ "color-node-self-box": string
156
+ "color-node-self-border": string
154
157
  "color-group-name": string
155
158
  "color-group-box": string
156
159
  "color-group-border": string
@@ -251,11 +254,13 @@ type SchemaGradiaConfig = Partial<{
251
254
  livelocks are ruled out
252
255
 
253
256
  - `SchemaObject.diagram?: SchemaDiagram`:
254
- diagram generated for every object of this kind,
257
+ diagram generated for every object of this kind (left out when the
258
+ configuration is emitted compressed by level 2 or higher),
255
259
  BECAUSE relations buried in references are graspable only when drawn
256
260
 
257
261
  - `SchemaObject.format?: SchemaFormat`:
258
- HTML/PDF rendering format of the objects of this kind,
262
+ HTML/PDF rendering format of the objects of this kind (left out when
263
+ the configuration is emitted compressed by level 2 or higher),
259
264
  BECAUSE rich objects need sections, uniform ones read as tables
260
265
 
261
266
  - `SchemaObject.props?: SchemaProperty[]`:
@@ -417,7 +422,7 @@ type SchemaGradiaConfig = Partial<{
417
422
  BECAUSE the node name alone rarely makes the nodes comparable
418
423
 
419
424
  - `SchemaDiagram.config?: SchemaGradiaConfig`:
420
- arbitrary Gradia rendering options (e.g. `grid-columns-max: 5`),
425
+ arbitrary Gradia rendering options (e.g. `grid-columns-max: 2`),
421
426
  BECAUSE layout tuning is cosmetics, not a schema concern
422
427
 
423
428
  - `SchemaFormat`:
@@ -498,7 +503,8 @@ type SchemaGradiaConfig = Partial<{
498
503
  BECAUSE an embedded font renders alike where it is not installed
499
504
 
500
505
  - `SchemaGradiaConfig."color-node-*": string`:
501
- colors of the node boxes in their regular, primary, and ghost role,
506
+ colors of the node boxes in their regular, primary, ghost, and self
507
+ role,
502
508
  BECAUSE the role of a node has to be readable at a glance
503
509
 
504
510
  - `SchemaGradiaConfig."color-group-*", "color-edge-*": string`:
@@ -557,7 +563,9 @@ that kind. Its `type` selects the diagram shape (`graph`, `hub`, or
557
563
  `grid`), `nodes` and `edges` select the participating objects through
558
564
  comma-separated `[[xxx]]` reference patterns (`nodes` defaults to the
559
565
  object itself plus all objects below it), and `center` names the object
560
- a `hub` is projected onto (default: `self`).
566
+ a `hub` is projected onto (default: `self`). An edge object of the
567
+ center targeting the center itself (a self-reference) places the center
568
+ a second time among its outputs, rendered as a dashed "self" node.
561
569
 
562
570
  Instead of naming an existing object, `center` can also declare a
563
571
  *synthetic* center node, which represents something no specification
@@ -577,7 +585,15 @@ node only, `qualified` labels every node with its object kind,
577
585
  `properties` lists the property names whose values are attached to the
578
586
  nodes as key/value annotations (with every `[[xxx]]` reference stripped
579
587
  to its target object name), and `config` passes arbitrary Gradia
580
- rendering options (e.g. `grid-columns-max: 5`) through to the diagram.
588
+ rendering options (e.g. `grid-columns-max: 2`) through to the diagram.
589
+ As the Gradia defaults are dimensioned for a stand-alone canvas,
590
+ SpecBook presets a compact geometry for every diagram, which `config`
591
+ overrides: `size-canvas-margin: 8`, `size-node-height-scale: 2`,
592
+ `hub-node-gap: 10`, `hub-node-count-max: 6` (a `hub` column of more
593
+ nodes wraps into two staggered sub-columns), `grid-gap-horizontal: 24`,
594
+ `grid-gap-vertical: 12`, and `grid-columns-min: 4` (the square
595
+ derivation of the column count otherwise leaves a third of the document
596
+ width unused).
581
597
 
582
598
  `ordered` replaces the layered layout of a `graph` by the document
583
599
  order of the objects: the top-level nodes stack top-down in that order
@@ -1026,8 +1042,11 @@ constrain reference-valued properties (e.g. `[[PERSONA:*]]` or
1026
1042
 
1027
1043
  A Markdown image `![<alt/>](<file/>)` inside a description or property
1028
1044
  value embeds a local image file, resolved relative to the artifact file:
1029
- SVG files are inlined as-is; PNG/JPEG files are embedded as base64
1030
- `data:` URLs. URLs and other file types are left untouched.
1045
+ SVG files are inlined as-is; PNG/JPEG/WebP files are embedded as base64
1046
+ `data:` URLs. URLs and other file types are left untouched. The
1047
+ HTML/PDF export optimizes the embedded images on its own (downscaled
1048
+ and re-encoded PNG/JPEG/WebP, minified SVG), so the files can stay in their
1049
+ original resolution.
1031
1050
 
1032
1051
  A reference carrying the `{theme}` placeholder -- e.g.
1033
1052
  `![Logo](logo-{theme}.svg)` -- is a *theme-aware* embedding: it expands
@@ -1036,3 +1055,11 @@ both of which are embedded, as two consecutive entries in this order. The
1036
1055
  HTML export shows just the variant matching the color theme currently
1037
1056
  active in the document, while the PDF export (like print in general)
1038
1057
  always uses the `light` variant.
1058
+
1059
+ The reference-style image `![<alt/>][<label/>]` embeds the image of its
1060
+ definition `[<label/>]: data:image/<type/>;base64,<data/>` (SVG, PNG,
1061
+ JPEG, or WebP) in the same file, the only supported link definition.
1062
+ This is the form the normalized Markdown export emits for every embedded
1063
+ image (a theme-aware one by its `light` variant), with the definitions
1064
+ at the end of the document, so it stands alone without the image files.
1065
+ Author the inline form; the reference-style one is for re-parsing only.
@@ -154,8 +154,8 @@
154
154
  value: enum(Dark,Light)
155
155
  optional: true
156
156
  - name: THEME-TONE
157
- desc: The theme color tone.
158
- value: /^#[0-9a-fA-F]{6}$/
157
+ desc: The theme color tone, optionally followed by the complement color tone (else derived).
158
+ value: /^#[0-9a-fA-F]{6}(\s+#[0-9a-fA-F]{6})?$/
159
159
  optional: true
160
160
 
161
161
  - kind: REQS
@@ -250,7 +250,7 @@
250
250
  config:
251
251
  size-node-width-max: 440
252
252
  size-node-height-scale: 1.75
253
- grid-columns-max: 5
253
+ grid-columns-max: 4
254
254
  children:
255
255
  - kind: PREMISE
256
256
  desc: >
@@ -643,7 +643,7 @@
643
643
  - PRIORITY
644
644
  config:
645
645
  size-node-width-max: 440
646
- grid-columns-max: 5
646
+ grid-columns-max: 4
647
647
  children:
648
648
  - kind: REQUIREMENT
649
649
  desc: >
@@ -702,7 +702,7 @@
702
702
  - CATEGORY
703
703
  config:
704
704
  size-node-width-max: 440
705
- grid-columns-max: 5
705
+ grid-columns-max: 4
706
706
  children:
707
707
  - kind: REQUIREMENT
708
708
  desc: >
@@ -799,7 +799,7 @@
799
799
  - SOURCE
800
800
  config:
801
801
  size-node-width-max: 440
802
- grid-columns-max: 5
802
+ grid-columns-max: 4
803
803
  children:
804
804
  - kind: RULE
805
805
  desc: >
@@ -1426,7 +1426,7 @@
1426
1426
  config:
1427
1427
  size-node-width-max: 440
1428
1428
  size-node-height-scale: 2
1429
- grid-columns-max: 5
1429
+ grid-columns-max: 4
1430
1430
  children:
1431
1431
  - kind: LIFECYCLE
1432
1432
  desc: >
@@ -1582,8 +1582,11 @@
1582
1582
  onlyConnected: true
1583
1583
  qualified: true
1584
1584
  config:
1585
+ size-node-width-min: 300
1585
1586
  size-node-width-max: 440
1586
- graph-channel-width-max: 340
1587
+ graph-channel-width-max: 700
1588
+ graph-node-degree-max: 8
1589
+ size-edge-port-gap: 18
1587
1590
  children:
1588
1591
  - kind: PERMISSION
1589
1592
  desc: >
@@ -3900,7 +3903,7 @@
3900
3903
  - STATUS
3901
3904
  config:
3902
3905
  size-node-width-max: 440
3903
- grid-columns-max: 5
3906
+ grid-columns-max: 4
3904
3907
  children:
3905
3908
  - kind: DECISION
3906
3909
  desc: |
@@ -34,8 +34,8 @@ const compileRule = (line, base) => {
34
34
  if (pattern.startsWith("/"))
35
35
  pattern = pattern.slice(1);
36
36
  const glob = anchored ? (base === "" ? pattern : `${base}/${pattern}`) : `**/${pattern}`;
37
- const isMatch = picomatch(glob, { dot: true });
38
- return { matcher: (p) => isMatch(p), negated, dirOnly };
37
+ const isMatch = picomatch(glob, { dot: true, nobrace: true, noextglob: true, nonegate: true });
38
+ return { matcher: isMatch, negated, dirOnly };
39
39
  };
40
40
  /* load the rules of a single exclude file, given as an absolute "file"
41
41
  and the working-tree-relative "base" its anchored patterns resolve
@@ -73,10 +73,10 @@ const workingTree = (dir) => {
73
73
  /* resolve the global Git excludes file: the configured
74
74
  "core.excludesFile", or else the XDG location Git falls back onto
75
75
  when that configuration value is unset */
76
- const globalFile = () => {
76
+ const globalFile = (root) => {
77
77
  let file = "";
78
78
  try {
79
- file = execFileSync("git", ["config", "--get", "core.excludesFile"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
79
+ file = execFileSync("git", ["config", "--get", "core.excludesFile"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
80
80
  }
81
81
  catch {
82
82
  /* no such configuration value, or no Git at all */
@@ -123,7 +123,7 @@ export const excluder = (dir) => {
123
123
  return () => false;
124
124
  /* the rules which no directory of the working tree owns and which
125
125
  hence govern it as a whole, in ascending Git precedence */
126
- const base = [...loadRules(globalFile(), ""), ...loadRules(infoFile(root), "")];
126
+ const base = [...loadRules(globalFile(root), ""), ...loadRules(infoFile(root), "")];
127
127
  /* the per-directory ".gitignore" rules, loaded on first use, as one
128
128
  run queries many files sharing the very same directories */
129
129
  const cache = new Map();
@@ -142,7 +142,7 @@ const nearest = (index, matches, from) => {
142
142
  return i;
143
143
  };
144
144
  const proximities = matches.map((object) => proximity(object));
145
- const best = Math.max(...proximities);
145
+ const best = proximities.reduce((max, p) => Math.max(max, p), 0);
146
146
  return matches.filter((_, i) => proximities[i] === best);
147
147
  };
148
148
  /* resolve a reference into a unique target: a single segment tries the