@sigloch/graph-view-edit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,13 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>graph-view-edit</title>
7
+ <script type="module" crossorigin src="/assets/index-UnMYySno.js"></script>
8
+ <link rel="stylesheet" crossorigin href="/assets/index-Cxy3uYBx.css">
9
+ </head>
10
+ <body>
11
+ <div id="root"></div>
12
+ </body>
13
+ </html>
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@sigloch/graph-view-edit",
3
+ "version": "0.1.0",
4
+ "description": "React/Vite viewer+editor over @sigloch/graphcode — 12 graph-views + 16 doc-views via a declarative View-Registry, plus an SE-Dashboard sibling route. Reads docs/graph/<member>.graph.json client-side (no 2nd Kuzu handle).",
5
+ "type": "module",
6
+ "bin": {
7
+ "gve": "bin/gve.mjs"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "bin",
12
+ "vite.config.js",
13
+ "src/config.mjs"
14
+ ],
15
+ "scripts": {
16
+ "dev": "vite",
17
+ "build": "vite build",
18
+ "preview": "vite preview",
19
+ "prepack": "npm run build",
20
+ "test": "vitest run"
21
+ },
22
+ "comment:deps": "Runtime = what vite.config.js + bin/gve.mjs load at Node level when serving the prebuilt dist/. The UI libs (react, react-dom, elkjs, @tanstack/react-table, zustand) are bundled INTO dist/ by vite and must stay devDependencies — shipping them would make consumers download the whole UI stack twice.",
23
+ "dependencies": {
24
+ "@sigloch/contracts": "^0.8.0",
25
+ "@sigloch/graph-api-core": "^0.4.1",
26
+ "@sigloch/graphcode-client": "^0.2.0",
27
+ "@vitejs/plugin-react": "^4.3.4",
28
+ "vite": "^5.4.11",
29
+ "zod": "^4.3.6"
30
+ },
31
+ "devDependencies": {
32
+ "@playwright/test": "^1.58.2",
33
+ "@tanstack/react-table": "^8.20.5",
34
+ "@testing-library/react": "^16.0.1",
35
+ "elkjs": "^0.11.1",
36
+ "jsdom": "^25.0.1",
37
+ "react": "^18.3.1",
38
+ "react-dom": "^18.3.1",
39
+ "vitest": "^2.0.0",
40
+ "zustand": "^4.5.5"
41
+ },
42
+ "author": "andreas@siglochconsulting",
43
+ "license": "MIT",
44
+ "engines": {
45
+ "node": ">=22"
46
+ },
47
+ "publishConfig": {
48
+ "access": "public"
49
+ }
50
+ }
package/src/config.mjs ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Static config loader (CR-GVE-101 / REQ-ops-config-static).
3
+ *
4
+ * Reads a versionable JSON config file (default `config.json`, see
5
+ * `config.example.json` for the template) and validates it against
6
+ * `ConfigSchema`. Missing file or missing fields fall back to schema
7
+ * defaults; a present-but-invalid file throws (fail fast, no silent
8
+ * fallback — schema-first per REQ-nfr-stack).
9
+ *
10
+ * Nested object defaults use `.prefault({})`, not `.default({})` (CR-GVE-221):
11
+ * since zod 4 `.default()` hands its value through RAW, so `.default({})` on
12
+ * `graph` would yield `{}` instead of `{path: …, host: null}` — the viewer
13
+ * would lose the graph path whenever config.json is absent. `.prefault()` runs
14
+ * the value through the inner schema, i.e. zod 3's `.default()` semantics.
15
+ * Defaults on primitives are unaffected and stay `.default(…)`.
16
+ *
17
+ * @author andreas@siglochconsulting
18
+ */
19
+ import { existsSync, readFileSync } from 'node:fs';
20
+ import { resolve } from 'node:path';
21
+ import { z } from 'zod';
22
+
23
+ export const ConfigSchema = z.object({
24
+ graph: z
25
+ .object({
26
+ path: z.string().default('docs/graph/graph-view-edit.graph.json'),
27
+ host: z.string().nullable().default(null),
28
+ })
29
+ .prefault({}),
30
+ port: z.number().int().positive().default(4317),
31
+ ontologyDescriptor: z.string().default('se'),
32
+ tokenTheme: z.string().default('default'),
33
+ viewRegistryDefaults: z
34
+ .object({
35
+ view: z.string().nullable().default(null),
36
+ scope: z.string().nullable().default(null),
37
+ })
38
+ .prefault({}),
39
+ edit: z
40
+ .object({
41
+ enabled: z.boolean().default(false),
42
+ })
43
+ .prefault({}),
44
+ });
45
+
46
+ export function loadConfig(path = process.env.GVE_CONFIG_PATH || resolve(process.cwd(), 'config.json')) {
47
+ const raw = existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : {};
48
+ return ConfigSchema.parse(raw);
49
+ }
package/vite.config.js ADDED
@@ -0,0 +1,411 @@
1
+ import { readFileSync, existsSync, readdirSync, statSync, watch } from 'node:fs';
2
+ import { join, basename } from 'node:path';
3
+ import { defineConfig } from 'vite';
4
+ import react from '@vitejs/plugin-react';
5
+ import { DefaultRuleEngine, SE_DESCRIPTOR } from '@sigloch/graph-api-core';
6
+ import { ONTOLOGY_VERSION, RULES_VERSION } from '@sigloch/contracts/se';
7
+ // CR-GC-265: these eight come from the read-side client, not the substrate.
8
+ // They are pure projection plus a node:net socket call — depending on
9
+ // @sigloch/graphcode for them pulled kuzu-wasm, the MCP SDK and the TypeScript
10
+ // compiler (~99 MB) into a viewer that must never open a store.
11
+ import {
12
+ computeReadiness,
13
+ readinessPanel,
14
+ recommendationsPanel,
15
+ artifactsPanel,
16
+ healthPanel,
17
+ VIEW_FILENAMES,
18
+ callHost,
19
+ HOST_SOCK_BASENAME,
20
+ } from '@sigloch/graphcode-client';
21
+ import { loadConfig } from './src/config.mjs';
22
+
23
+ /**
24
+ * Static config (CR-GVE-138 / REQ-ops-config-static) — loadConfig validates
25
+ * config.json (or GVE_CONFIG_PATH) against ConfigSchema once at server start;
26
+ * missing file = schema defaults, invalid file = fail fast. The result drives
27
+ * the dev-server port below and is served to the browser at runtime via
28
+ * GET /api/config (configApiPlugin → src/app-config.mjs initAppConfig), so
29
+ * the client reads the SAME validated config — nothing is baked into the
30
+ * bundle, one prebuilt dist/ serves any repo (CR-GVE-181).
31
+ *
32
+ * Unter vitest wird die COMMITTETE config.example.json gepinnt (analog
33
+ * tests/helpers/dev-server.mjs): eine lokale config.json — z.B. ein User-
34
+ * Experiment mit anderem graph.path — darf die Suite nicht auf einen fremden
35
+ * Graphen umleiten. Explizites GVE_CONFIG_PATH gewinnt weiterhin.
36
+ */
37
+ const APP_CONFIG = loadConfig(
38
+ process.env.GVE_CONFIG_PATH ?? (process.env.VITEST ? 'config.example.json' : undefined),
39
+ );
40
+
41
+ /**
42
+ * Repo root this app instance reads/writes (CR-GVE-127). Defaults to
43
+ * process.cwd() — zero behavior change for the normal case (running inside
44
+ * the repo it's viewing). Overridable via GVE_REPO_ROOT so the SAME app
45
+ * instance can point at a different repo's docs/graph/*.graph.json +
46
+ * .graphcode/host.sock — e.g. an isolated sandbox copy, safe for
47
+ * destructive testing without touching the live tracked project graph.
48
+ */
49
+ function resolveRepoRoot() {
50
+ return process.env.GVE_REPO_ROOT || process.cwd();
51
+ }
52
+
53
+ /**
54
+ * Serves docs/graph/*.json from the resolved repo root (CR-GVE-127) instead
55
+ * of relying on Vite's default static passthrough from its OWN project
56
+ * root — without this, GVE_REPO_ROOT would redirect the API plugins but
57
+ * Renderer.jsx's graph fetch would still silently read the wrong repo's
58
+ * data. No-op (falls through to Vite's own static handling) when
59
+ * GVE_REPO_ROOT is unset.
60
+ */
61
+ function graphStaticPlugin() {
62
+ const middleware = (req, res, next) => {
63
+ const repoRoot = process.env.GVE_REPO_ROOT;
64
+ if (!repoRoot || !req.url?.startsWith('/docs/graph/')) return next();
65
+ const filePath = join(repoRoot, req.url);
66
+ if (!existsSync(filePath)) return next();
67
+ res.setHeader('Content-Type', 'application/json');
68
+ res.end(readFileSync(filePath));
69
+ };
70
+ return {
71
+ name: 'gve-graph-static-override',
72
+ configureServer(server) {
73
+ server.middlewares.use(middleware);
74
+ },
75
+ configurePreviewServer(server) {
76
+ server.middlewares.use(middleware);
77
+ },
78
+ };
79
+ }
80
+
81
+ /**
82
+ * GET /api/config (CR-GVE-181) — serves the validated server-side config to
83
+ * the browser at runtime instead of baking it into the bundle via `define`,
84
+ * so ONE prebuilt dist/ serves any repo. graph.path is resolved against the
85
+ * active repo root per request: when the configured file doesn't exist there
86
+ * (a foreign repo without its own gve config.json), the first
87
+ * docs/graph/*.graph.json wins — the same discovery the dashboard uses, so
88
+ * `gve --repo <path>` needs no per-repo config to find the graph.
89
+ */
90
+ function configApiPlugin() {
91
+ const middleware = (req, res, next) => {
92
+ if (req.url !== '/api/config') return next();
93
+ const repoRoot = resolveRepoRoot();
94
+ const config = structuredClone(APP_CONFIG);
95
+ if (!existsSync(join(repoRoot, config.graph.path))) {
96
+ const graphDir = join(repoRoot, 'docs', 'graph');
97
+ const hit = existsSync(graphDir)
98
+ ? readdirSync(graphDir).find((f) => f.endsWith('.graph.json'))
99
+ : null;
100
+ if (hit) config.graph.path = `docs/graph/${hit}`;
101
+ }
102
+ res.setHeader('Content-Type', 'application/json');
103
+ res.end(JSON.stringify(config));
104
+ };
105
+ return {
106
+ name: 'gve-config-api',
107
+ configureServer(server) {
108
+ server.middlewares.use(middleware);
109
+ },
110
+ configurePreviewServer(server) {
111
+ server.middlewares.use(middleware);
112
+ },
113
+ };
114
+ }
115
+
116
+ /**
117
+ * SE-Dashboard API (CR-GVE-117) — GET /api/dashboard, computed server-side.
118
+ * @sigloch/graphcode's panel functions can't be imported into browser code
119
+ * (Node-only internals break the Vite build — see CR-GVE-120's VIEW_FILENAMES
120
+ * finding). vite.config.js itself is never bundled for the browser, so this
121
+ * middleware is the safe place for that computation; the React dashboard
122
+ * just fetches the JSON like any other static resource.
123
+ */
124
+ function dashboardApiPlugin() {
125
+ const cwd = resolveRepoRoot();
126
+ const GRAPH_DIR = join(cwd, 'docs', 'graph');
127
+ const VIEWS_DIR = join(cwd, 'docs', 'views');
128
+ const engine = new DefaultRuleEngine(SE_DESCRIPTOR.version);
129
+ engine.register(SE_DESCRIPTOR.rules ?? []);
130
+
131
+ function findGraphFile() {
132
+ if (!existsSync(GRAPH_DIR)) return null;
133
+ const hit = readdirSync(GRAPH_DIR).find((f) => f.endsWith('.graph.json'));
134
+ return hit ? join(GRAPH_DIR, hit) : null;
135
+ }
136
+
137
+ function loadGraph(file) {
138
+ const json = JSON.parse(readFileSync(file, 'utf8'));
139
+ const nodes = (json.elements ?? []).map((e) => {
140
+ const { id, type, name, description, ...rest } = e;
141
+ return { uid: id, type, name, description: description ?? '', attributes: rest };
142
+ });
143
+ const edges = (json.traces ?? []).map((t) => {
144
+ const { source, target, type, ...rest } = t;
145
+ return { sourceId: source, targetId: target, edgeType: type, attributes: rest };
146
+ });
147
+ return { nodes, edges };
148
+ }
149
+
150
+ function scanArtifacts(graphFile) {
151
+ const graphMtime = existsSync(graphFile) ? statSync(graphFile).mtimeMs : 0;
152
+ const items = Object.entries(VIEW_FILENAMES).map(([id, fname]) => {
153
+ const p = join(VIEWS_DIR, fname);
154
+ const exists = existsSync(p);
155
+ const staleVsGraph = exists ? statSync(p).mtimeMs < graphMtime : false;
156
+ return { id, label: id, exists, staleVsGraph };
157
+ });
158
+ return artifactsPanel(items);
159
+ }
160
+
161
+ function synthHealth(graph) {
162
+ return healthPanel({
163
+ status: 'ok',
164
+ store: 'committed graph.json (read-only)',
165
+ gate: 'n/a — dashboard is read-only (writes go through MCP mutate())',
166
+ versions: {
167
+ ontology: ONTOLOGY_VERSION,
168
+ rules: RULES_VERSION,
169
+ engine: SE_DESCRIPTOR.version,
170
+ elements: graph.nodes.length,
171
+ traces: graph.edges.length,
172
+ },
173
+ });
174
+ }
175
+
176
+ function buildDashboard() {
177
+ const file = findGraphFile();
178
+ if (!file) return { member: null, empty: true, error: 'no docs/graph/*.graph.json found', computedAt: new Date().toISOString() };
179
+ const graph = loadGraph(file);
180
+ const violations = engine.evaluate(graph);
181
+ const report = computeReadiness(violations, graph);
182
+ return {
183
+ member: basename(file).replace(/\.graph\.json$/, ''),
184
+ empty: graph.nodes.length === 0,
185
+ readiness: readinessPanel(report),
186
+ recommendations: recommendationsPanel(violations, 50),
187
+ artifacts: scanArtifacts(file),
188
+ health: synthHealth(graph),
189
+ computedAt: new Date().toISOString(),
190
+ };
191
+ }
192
+
193
+ const middleware = (req, res, next) => {
194
+ if (req.url !== '/api/dashboard') return next();
195
+ res.setHeader('Content-Type', 'application/json');
196
+ res.end(JSON.stringify(buildDashboard()));
197
+ };
198
+
199
+ return {
200
+ name: 'gve-dashboard-api',
201
+ configureServer(server) {
202
+ server.middlewares.use(middleware);
203
+ },
204
+ configurePreviewServer(server) {
205
+ server.middlewares.use(middleware);
206
+ },
207
+ };
208
+ }
209
+
210
+ /**
211
+ * The actual request handling for POST /api/mutate (CR-GVE-008), factored
212
+ * out of the plugin's HTTP glue so it's directly unit-testable (no server,
213
+ * no sockets) with an injected `callHostImpl` — same DI pattern as
214
+ * command-bridge.mjs's fetchImpl. Forwards {commands, baseVersion,
215
+ * consumerId} to graph_mutate over the elected graphcode host's
216
+ * `.graphcode/host.sock` (CR-GC-241) — never opens a second Kuzu handle.
217
+ * Fails loud (throws) when the socket is absent/dead instead of a silent
218
+ * no-op; the plugin's HTTP wrapper turns that into a clear 503.
219
+ *
220
+ * graph_mutate does NOT auto-export docs/graph/*.graph.json (graph_export is
221
+ * a separate tool, "the single sync path" per its own description) — this
222
+ * repo's read path (Option A) reads that committed file, so without an
223
+ * export a refetch after editing would still show stale data. On a
224
+ * successful mutate with real changes, this also calls graph_export with
225
+ * force:true — graph_export's refuse-to-clobber guard aborts on ANY
226
+ * deletion by design (not just stale/drifted state — it protects against a
227
+ * stale/parallel process silently dropping committed data, a real past
228
+ * incident per its own source comment). That staleness risk doesn't apply
229
+ * here: this export runs immediately after a mutation that just passed
230
+ * THIS SAME live gate in THIS SAME request, so the deletion is known-
231
+ * intentional, not a stale process's guess (best-effort either way: the
232
+ * data is already safely persisted in Kuzu, so an export failure is
233
+ * attached as `exportWarning` rather than turning a successful edit into a
234
+ * failure response).
235
+ */
236
+ export async function handleMutateRequest(body, { repoRoot = process.cwd(), callHostImpl = callHost } = {}) {
237
+ const socketPath = join(repoRoot, '.graphcode', HOST_SOCK_BASENAME);
238
+ const result = await callHostImpl(socketPath, 'graph_mutate', body);
239
+ if (result.success && result.mutations > 0) {
240
+ try {
241
+ await callHostImpl(socketPath, 'graph_export', { force: true });
242
+ } catch (err) {
243
+ return { ...result, exportWarning: `graph_export failed after a successful mutate: ${err.message}` };
244
+ }
245
+ }
246
+ return result;
247
+ }
248
+
249
+ /**
250
+ * POST /api/realize (CR-GVE-135 / REQ-real-bind) — same host.sock transport
251
+ * and post-write export rationale as handleMutateRequest, but for the
252
+ * graph_realize tool: binds FUNC↔codeRef (R-20) and optionally TEST↔testRef
253
+ * (R-19) through the same Apply-Gate (graph_realize composes
254
+ * harness.mutate, no parallel write path).
255
+ */
256
+ export async function handleRealizeRequest(body, { repoRoot = process.cwd(), callHostImpl = callHost } = {}) {
257
+ const socketPath = join(repoRoot, '.graphcode', HOST_SOCK_BASENAME);
258
+ const result = await callHostImpl(socketPath, 'graph_realize', body);
259
+ if (result.success !== false) {
260
+ try {
261
+ await callHostImpl(socketPath, 'graph_export', { force: true });
262
+ } catch (err) {
263
+ return { ...result, exportWarning: `graph_export failed after a successful realize: ${err.message}` };
264
+ }
265
+ }
266
+ return result;
267
+ }
268
+
269
+ /**
270
+ * POST /api/mutate (CR-GVE-008) — bridges the edit-surface's write path to
271
+ * the SAME Apply-Gate this repo's own graphcode MCP session uses, via the
272
+ * local host.sock shim (CR-GC-241) rather than a second Kuzu owner.
273
+ * POST /api/realize (CR-GVE-135) rides the same plugin.
274
+ */
275
+ function mutateApiPlugin() {
276
+ const cwd = resolveRepoRoot();
277
+
278
+ const middleware = (req, res, next) => {
279
+ const isMutate = req.url === '/api/mutate';
280
+ const isRealize = req.url === '/api/realize';
281
+ if ((!isMutate && !isRealize) || req.method !== 'POST') return next();
282
+ let raw = '';
283
+ req.on('data', (chunk) => { raw += chunk; });
284
+ req.on('end', async () => {
285
+ res.setHeader('Content-Type', 'application/json');
286
+ let body;
287
+ try {
288
+ body = JSON.parse(raw);
289
+ } catch {
290
+ res.statusCode = 400;
291
+ res.end(JSON.stringify({ success: false, error: 'invalid JSON body' }));
292
+ return;
293
+ }
294
+ try {
295
+ const result = isRealize
296
+ ? await handleRealizeRequest(body, { repoRoot: cwd })
297
+ : await handleMutateRequest(body, { repoRoot: cwd });
298
+ res.end(JSON.stringify(result));
299
+ } catch (err) {
300
+ // A dead/absent socket is a 503 "start a session"; a tool-level
301
+ // rejection (e.g. graph_realize: unknown funcUid) is the tool's own
302
+ // message, not a host problem (CR-GVE-135).
303
+ const hostDown = /ENOENT|ECONNREFUSED|ECONNRESET|EPIPE|socket/i.test(err.message);
304
+ res.statusCode = hostDown ? 503 : 422;
305
+ res.end(
306
+ JSON.stringify({
307
+ success: false,
308
+ error: hostDown
309
+ ? `no graphcode host reachable for this repo (${err.message}) — start a graphcode session first`
310
+ : err.message,
311
+ }),
312
+ );
313
+ }
314
+ });
315
+ };
316
+
317
+ return {
318
+ name: 'gve-mutate-api',
319
+ configureServer(server) {
320
+ server.middlewares.use(middleware);
321
+ },
322
+ configurePreviewServer(server) {
323
+ server.middlewares.use(middleware);
324
+ },
325
+ };
326
+ }
327
+
328
+ /**
329
+ * SSE Invalidate hub (CR-GVE-139 / REQ-live-sse), factored out of the plugin
330
+ * HTTP glue for direct unit-testability (same DI pattern as
331
+ * handleMutateRequest). GET /api/events holds the response open as a
332
+ * text/event-stream client; broadcast() pushes an `invalidate`
333
+ * LiveUpdateEvent naming the affected domains, which sse-client.mjs
334
+ * (createSSEClient) hands to each panel for a SELECTIVE refresh.
335
+ */
336
+ export function createEventsHub() {
337
+ const clients = new Set();
338
+ return {
339
+ middleware(req, res, next) {
340
+ if (req.url !== '/api/events') return next();
341
+ res.writeHead(200, {
342
+ 'Content-Type': 'text/event-stream',
343
+ 'Cache-Control': 'no-cache',
344
+ Connection: 'keep-alive',
345
+ });
346
+ res.write('retry: 3000\n\n');
347
+ clients.add(res);
348
+ req.on('close', () => clients.delete(res));
349
+ },
350
+ broadcast(domains) {
351
+ const frame = `event: invalidate\ndata: ${JSON.stringify({ domains })}\n\n`;
352
+ for (const res of clients) res.write(frame);
353
+ },
354
+ clientCount: () => clients.size,
355
+ };
356
+ }
357
+
358
+ /**
359
+ * GET /api/events (CR-GVE-139). The invalidate TRIGGER is a watcher on
360
+ * docs/graph/*.graph.json — the single point every write converges on
361
+ * (own edits via /api/mutate's post-mutate export AND external writers like
362
+ * a graphcode MCP session exporting), so there's exactly one refresh path
363
+ * instead of a per-writer special case. A graph change also invalidates
364
+ * rules/readiness — both are derived from the same committed file.
365
+ */
366
+ function eventsApiPlugin() {
367
+ const hub = createEventsHub();
368
+ const graphDir = join(resolveRepoRoot(), 'docs', 'graph');
369
+ let debounce = null;
370
+ function armWatcher() {
371
+ if (!existsSync(graphDir)) return;
372
+ const watcher = watch(graphDir, (eventType, filename) => {
373
+ if (!filename?.endsWith('.graph.json')) return;
374
+ clearTimeout(debounce);
375
+ debounce = setTimeout(() => hub.broadcast(['graph', 'rules', 'readiness']), 150);
376
+ });
377
+ // Never hold the process open (vitest runs this config's plugins too —
378
+ // an un-unref'd FSWatcher kept its worker from exiting).
379
+ watcher.unref();
380
+ }
381
+ return {
382
+ name: 'gve-events-api',
383
+ configureServer(server) {
384
+ armWatcher();
385
+ server.middlewares.use(hub.middleware);
386
+ },
387
+ configurePreviewServer(server) {
388
+ armWatcher();
389
+ server.middlewares.use(hub.middleware);
390
+ },
391
+ };
392
+ }
393
+
394
+ export default defineConfig({
395
+ plugins: [react(), graphStaticPlugin(), configApiPlugin(), dashboardApiPlugin(), mutateApiPlugin(), eventsApiPlugin()],
396
+ // config.port is the default; an explicit CLI --port (e.g. the test suites'
397
+ // tests/helpers/dev-server.mjs, which picks a free OS-assigned port with
398
+ // --strictPort) still overrides it, per Vite's precedence.
399
+ server: { port: APP_CONFIG.port },
400
+ preview: { port: APP_CONFIG.port },
401
+ test: {
402
+ // Vitest's own default (test/spec files) plus the NFR regression guards
403
+ // (CR-GVE-115), which intentionally use .bench.mjs/.smoke.mjs names to
404
+ // read as benchmarks/smoke tests rather than unit tests — without this,
405
+ // `npm test` would silently never run them.
406
+ include: ['**/*.{test,spec}.?(c|m)[jt]s?(x)', 'tests/nfr/**/*.{bench,smoke}.mjs'],
407
+ // Setzt den Modul-Singleton-Graph-Store (CR-GVE-192) vor jedem Test zurück,
408
+ // sonst leckt ein in Test A geladener Graph in Test B.
409
+ setupFiles: ['tests/setup.mjs'],
410
+ },
411
+ });