@sigloch/graph-view-edit 0.6.0 → 0.7.2

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/dist/index.html CHANGED
@@ -4,8 +4,8 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>graph-view-edit</title>
7
- <script type="module" crossorigin src="/assets/index-Bn2YV92M.js"></script>
8
- <link rel="stylesheet" crossorigin href="/assets/index-P7N8B6D1.css">
7
+ <script type="module" crossorigin src="/assets/index-Dbtyb0kh.js"></script>
8
+ <link rel="stylesheet" crossorigin href="/assets/index-Cu_Z807i.css">
9
9
  </head>
10
10
  <body>
11
11
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sigloch/graph-view-edit",
3
- "version": "0.6.0",
3
+ "version": "0.7.2",
4
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
5
  "type": "module",
6
6
  "bin": {
@@ -17,20 +17,20 @@
17
17
  "build": "vite build",
18
18
  "preview": "vite preview",
19
19
  "prepack": "npm run build",
20
- "test": "vitest run"
20
+ "test": "node scripts/run-tests.mjs"
21
21
  },
22
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
23
  "dependencies": {
24
- "@sigloch/contracts": ">=5 <7",
24
+ "@sigloch/contracts": ">=5 <10",
25
25
  "@sigloch/graph-api-core": ">=5 <6",
26
- "@sigloch/graphcode-client": ">=1 <2",
26
+ "@sigloch/graphcode-client": ">=1.3 <2",
27
27
  "@vitejs/plugin-react": "^4.3.4",
28
28
  "vite": "^5.4.11",
29
29
  "zod": "^4.3.6"
30
30
  },
31
31
  "devDependencies": {
32
32
  "@playwright/test": "^1.58.2",
33
- "@sigloch/graphcode": "^0.13.0",
33
+ "@sigloch/graphcode": "^0.18.0",
34
34
  "@tanstack/react-table": "^8.20.5",
35
35
  "@testing-library/react": "^16.0.1",
36
36
  "elkjs": "^0.11.1",
package/vite.config.js CHANGED
@@ -3,7 +3,7 @@ import { join, basename } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { defineConfig } from 'vite';
5
5
  import react from '@vitejs/plugin-react';
6
- import { DefaultRuleEngine, SE_DESCRIPTOR } from '@sigloch/graph-api-core';
6
+ import { DefaultRuleEngine, SE_DESCRIPTOR, fromOntologyGraph } from '@sigloch/graph-api-core';
7
7
  import { ONTOLOGY_VERSION, RULES_VERSION } from '@sigloch/contracts/se';
8
8
  // CR-GC-265: these eight come from the read-side client, not the substrate.
9
9
  // They are pure projection plus a node:net socket call — depending on
@@ -18,6 +18,8 @@ import {
18
18
  VIEW_FILENAMES,
19
19
  ARTIFACT_CATALOG,
20
20
  analysisCreationCurrencyProvider,
21
+ PHASE_GATE_RULES,
22
+ groupViolations,
21
23
  callHost,
22
24
  HOST_SOCK_BASENAME,
23
25
  } from '@sigloch/graphcode-client';
@@ -159,43 +161,19 @@ function configApiPlugin() {
159
161
  * just fetches the JSON like any other static resource.
160
162
  */
161
163
  /**
162
- * docs/graph/*.graph.json graph-api-core's Graph{nodes,edges} the ONE
163
- * definition of this repo's wire format (audit F5). graph-api-core/browser
164
- * exports only the forward direction (`projectToOntologyGraph`: Graph
165
- * OntologyGraph, what graph_export itself uses to WRITE this file) — no
166
- * inverse is published, so this is that function's exact mirror image,
167
- * derived by reading its field-lift contract (se-descriptor.ts) rather than
168
- * re-guessing the shape: status/asil/method/kinds live inside `attributes`
169
- * (that's where projectToOntologyGraph reads them back out of), created_at/
170
- * updated_at are GraphNode's own top-level camelCase fields (never inside
171
- * attributes, or `projectToOntologyGraph` would read them back as '').
164
+ * docs/graph/*.graph.json graph-api-core's Graph{nodes,edges} via the
165
+ * PUBLISHED inverse `fromOntologyGraph` (CR-SM-254) no hand-rolled mirror.
166
+ * The previous hand-derived copy lifted exactly four flat keys
167
+ * (status/asil/method/kinds) into `attributes` and dropped everything else
168
+ * the exporter writes flat (concept, severity/occurrence/detection,
169
+ * analysisFreshness, testRefs, …) the CR-GC-402 "second truth": the
170
+ * dashboard evaluated a castrated graph and disagreed with graph_readiness.
172
171
  * `tests/vite-config-load-graph.test.mjs` pins the round trip against the
173
172
  * REAL projectToOntologyGraph, so a drift in either direction fails loud.
174
173
  */
175
174
  export function loadGraph(file) {
176
175
  const json = JSON.parse(readFileSync(file, 'utf8'));
177
- const nodes = (json.elements ?? []).map((e) => ({
178
- uid: e.id,
179
- type: e.type,
180
- name: e.name,
181
- description: e.description ?? '',
182
- createdAt: e.created_at,
183
- updatedAt: e.updated_at,
184
- attributes: { ...(e.attributes ?? {}), status: e.status, asil: e.asil, method: e.method, kinds: e.kinds },
185
- }));
186
- const edges = (json.traces ?? []).map((t) => ({
187
- sourceId: t.source,
188
- targetId: t.target,
189
- edgeType: t.type,
190
- attributes: {
191
- ...(t.attributes ?? {}),
192
- category: t.category,
193
- label: t.label,
194
- weight: t.weight,
195
- created_at: t.created_at,
196
- verified_at: t.verified_at,
197
- },
198
- }));
176
+ const { nodes, edges } = fromOntologyGraph(json);
199
177
  // CR-GC-300: graph_export now stamps graphVersion at write time — the live
200
178
  // comparison value computeAnalysisCurrency() needs against each analysis
201
179
  // artifact's SYS.attributes.analysisFreshness.<id>.graphVersion stamp
@@ -204,10 +182,146 @@ export function loadGraph(file) {
204
182
  return { nodes, edges, graphVersion: json.graphVersion ?? 0 };
205
183
  }
206
184
 
185
+ /**
186
+ * Das Recommendations-Teilstück des Dashboard-Payloads (CR-GVE-250):
187
+ * `recommendationsPanel`s `items`/`total` UNVERÄNDERT — die Items tragen
188
+ * `topCandidate`, das die Ein-Klick-Fix-Geste (CR-GVE-111) braucht — plus
189
+ * die Gruppen als zusätzliche Sicht, nicht als Ersatz.
190
+ *
191
+ * Die Gruppierung selbst ist seit CR-GVE-259 `groupViolations` aus
192
+ * @sigloch/graphcode-client — dieselbe Funktion, die die MCP-Fläche unter
193
+ * `detail:'grouped'` liefert (CR-GC-411). Die frühere lokale Kopie ist
194
+ * GELÖSCHT, nicht deprecated: eine Aggregation, zwei Konsumenten.
195
+ */
196
+ export function recommendationsPayload(violations, limit = 50) {
197
+ return { ...recommendationsPanel(violations, limit), groups: groupViolations(violations) };
198
+ }
199
+
200
+ /**
201
+ * Gate-Blocker als strukturierte Gruppen (CR-GVE-256) — dieselbe Mechanik wie
202
+ * `recommendationsPayload` (erst gruppieren, dann kappen; Counts aus den
203
+ * UNGEKAPPTEN Listen), eingeschränkt auf die Regeln, die das Gate besitzt.
204
+ * Nur error-severity blockiert (warnings/info sind advisory `open` —
205
+ * readiness.js scorePhaseGate) — deshalb wird hier identisch gefiltert.
206
+ */
207
+ export function gateBlockerGroups(violations, ruleIds, elementLimit = 10) {
208
+ return groupViolations(
209
+ violations.filter((v) => v.severity === 'error' && ruleIds.includes(v.ruleId)),
210
+ elementLimit,
211
+ );
212
+ }
213
+
214
+ /**
215
+ * Gate-übergreifende Aggregat-Sicht (CR-GVE-256): EINE Gruppe je ruleId über
216
+ * alle Gates, mit Gate-Chips — dieselbe Regel wird nicht n-fach gelistet.
217
+ * Die Gate-Zuordnung ist die VORHANDENE server-seitige Ableitung
218
+ * (`PHASE_GATE_RULES` aus @sigloch/graphcode-client — dieselbe Tabelle, aus
219
+ * der readiness.js die Gates scored), kein lokaler zweiter Regel-Katalog.
220
+ * Impl-Gates besitzen keine Element-Regeln (ihre Blocker sind CRs/Scope/
221
+ * Creations — strukturell, ohne ruleId) und tauchen deshalb hier nicht auf.
222
+ */
223
+ export function gateBlockerRollup(violations, gateRules = PHASE_GATE_RULES, elementLimit = 10) {
224
+ const ruleToGates = new Map();
225
+ for (const [gateId, ruleIds] of Object.entries(gateRules)) {
226
+ for (const rid of ruleIds) {
227
+ if (!ruleToGates.has(rid)) ruleToGates.set(rid, []);
228
+ ruleToGates.get(rid).push(gateId);
229
+ }
230
+ }
231
+ const owned = violations.filter((v) => v.severity === 'error' && ruleToGates.has(v.ruleId));
232
+ return groupViolations(owned, elementLimit).map((g) => ({ ...g, gates: ruleToGates.get(g.ruleId) }));
233
+ }
234
+
235
+ /**
236
+ * Das Readiness-Teilstück des Dashboard-Payloads (CR-GVE-256):
237
+ * `readinessPanel` UNVERÄNDERT als Basis, aber je Gate ersetzt das flache
238
+ * `blocking`-String-Array durch `blockerGroups` (regelbasierte Blocker,
239
+ * gruppiert) + `blockingOther` (Milestones/CRs/Creations/Completeness — die
240
+ * Zeilen ohne ruleId). Das flache Feld wird ENTFERNT, nicht daneben
241
+ * weitergereicht — sonst rendert es irgendwann wieder jemand (Parallelpfad).
242
+ */
243
+ export function readinessPayload(report) {
244
+ const panel = readinessPanel(report);
245
+ const enrich = (g) => {
246
+ const ruleIds = PHASE_GATE_RULES[g.id] ?? [];
247
+ const { blocking, ...rest } = g;
248
+ return {
249
+ ...rest,
250
+ blockerGroups: gateBlockerGroups(report.violations, ruleIds),
251
+ // Regel-Zeilen sind als Gruppen abgebildet; übrig bleiben die
252
+ // strukturellen Blocker ("<CR> not done", "milestone … missing",
253
+ // "<Creation> not performed", "… completeness x/y").
254
+ blockingOther: blocking.filter((line) => !ruleIds.some((rid) => line.startsWith(`${rid}: `))),
255
+ };
256
+ };
257
+ return {
258
+ ...panel,
259
+ phaseGates: panel.phaseGates.map(enrich),
260
+ implGates: panel.implGates.map(enrich),
261
+ blockerRollup: gateBlockerRollup(report.violations),
262
+ };
263
+ }
264
+
265
+ /**
266
+ * Autopilot-Scoreboard (CR-GVE-257) — Präsentations-Aggregation der
267
+ * `.graphcode/trajectory.jsonl` des Ziel-Repos (Projektion des OperationsLog,
268
+ * graphcode CR-GC-252). NUR Zeilen zählen aus einer Datei, die graphcode
269
+ * schreibt — keine eigene Metrik-/Regelberechnung (Grenze, Entscheidung
270
+ * 2026-08-25; Spiderweb/Konvergenz sind graphcode CR-DRAFT-GC-410).
271
+ *
272
+ * Zähl-Semantik entlang der CR-Referenzzahlen (193 / 2 418 / 45 im
273
+ * graphcode-Repo): applied/rejected über `operation === 'mutate'`;
274
+ * `opsTotal` = Summe `opCounts` der APPLIED Mutationen (was wirklich durchs
275
+ * Gate in den Graphen ging); Sessions = distinct `consumerId`; der
276
+ * Autorschafts-Split zählt die applied Mutationen (wer den Graphen WIRKLICH
277
+ * geschrieben hat). Violations-Delta: erste → letzte Zeile der Session des
278
+ * letzten Eintrags. Kaputte Zeilen werden übersprungen, leere Datei → null
279
+ * (die Karte zeigt „keine Trajektorie vorhanden", kein Crash).
280
+ */
281
+ export function autopilotStats(jsonlText) {
282
+ const entries = (jsonlText ?? '')
283
+ .split('\n')
284
+ .map((line) => {
285
+ try {
286
+ return JSON.parse(line);
287
+ } catch {
288
+ return null;
289
+ }
290
+ })
291
+ .filter((e) => e && typeof e === 'object');
292
+ if (entries.length === 0) return null;
293
+ const mutates = entries.filter((e) => e.operation === 'mutate');
294
+ const applied = mutates.filter((e) => e.applied === true);
295
+ const versions = applied.map((e) => e.graphVersion).filter((v) => typeof v === 'number');
296
+ const authors = { agent: 0, human: 0 };
297
+ for (const e of applied) authors[e.consumerType === 'agent' ? 'agent' : 'human'] += 1;
298
+ const lastSessionId = entries[entries.length - 1].consumerId;
299
+ const lastSession = entries.filter((e) => e.consumerId === lastSessionId);
300
+ const zero = { error: 0, warning: 0, info: 0 };
301
+ return {
302
+ applied: applied.length,
303
+ rejected: mutates.length - applied.length,
304
+ graphVersionFrom: versions.length ? Math.min(...versions) : null,
305
+ graphVersionTo: versions.length ? Math.max(...versions) : null,
306
+ opsTotal: applied.reduce((n, e) => n + (typeof e.opCounts === 'number' ? e.opCounts : 0), 0),
307
+ sessions: new Set(entries.map((e) => e.consumerId)).size,
308
+ activeDays: new Set(entries.map((e) => String(e.ts ?? '').slice(0, 10)).filter(Boolean)).size,
309
+ authors,
310
+ lastSession: {
311
+ consumerId: lastSessionId,
312
+ violationsFrom: { ...zero, ...(lastSession[0]?.violations ?? {}) },
313
+ violationsTo: { ...zero, ...(lastSession[lastSession.length - 1]?.violations ?? {}) },
314
+ },
315
+ };
316
+ }
317
+
207
318
  function dashboardApiPlugin() {
208
319
  const cwd = resolveRepoRoot();
209
320
  const GRAPH_DIR = join(cwd, 'docs', 'graph');
210
321
  const VIEWS_DIR = join(cwd, 'docs', 'views');
322
+ // CR-GVE-257: die Trajektorie liegt im Ziel-Repo, nicht im Viewer-Repo —
323
+ // derselbe resolveRepoRoot()-Anker wie GRAPH_DIR/host.sock.
324
+ const TRAJECTORY_FILE = join(cwd, '.graphcode', 'trajectory.jsonl');
211
325
  const engine = new DefaultRuleEngine(SE_DESCRIPTOR.version);
212
326
  engine.register(SE_DESCRIPTOR.rules ?? []);
213
327
 
@@ -241,7 +355,18 @@ function dashboardApiPlugin() {
241
355
  const staleVsGraph = exists ? statSync(p).mtimeMs < graphMtime : false;
242
356
  return { id: entry.id, exists, staleVsGraph };
243
357
  });
244
- return artifactsPanel(items);
358
+ // CR-GVE-252: welche Zeile ein Dokument oeffnen kann, entscheidet der
359
+ // KATALOG, nicht `kind`. 14 der 15 Artefakte haben einen VIEW_FILENAMES-
360
+ // Eintrag — auch vier der fuenf `analysis`-Creations (conops/fmea/trade/
361
+ // implplan). Nur `assumption-review` hat keinen. Die frueher hier
362
+ // implizite Gleichsetzung `kind === 'render'` == "hat eine Datei" war
363
+ // schlicht falsch (CR-GVE-250 §8.1). Dieselbe Tabelle, gegen die
364
+ // /api/view-doc aufloest — kein zweites Kriterium im Client.
365
+ const panel = artifactsPanel(items);
366
+ return {
367
+ ...panel,
368
+ artifacts: panel.artifacts.map((a) => ({ ...a, hasDoc: !!VIEW_FILENAMES[a.id] })),
369
+ };
245
370
  }
246
371
 
247
372
  function synthHealth(graph) {
@@ -270,16 +395,48 @@ function dashboardApiPlugin() {
270
395
  member: basename(file).replace(/\.graph\.json$/, ''),
271
396
  repoRoot: servedRepoRoot(),
272
397
  empty: graph.nodes.length === 0,
273
- readiness: readinessPanel(report),
274
- recommendations: recommendationsPanel(violations, 50),
398
+ readiness: readinessPayload(report),
399
+ recommendations: recommendationsPayload(violations, 50),
275
400
  artifacts: scanArtifacts(file, graph),
276
401
  health: synthHealth(graph),
402
+ // CR-GVE-257: Repo ohne Trajektorie → null (Karte zeigt den Hinweis).
403
+ autopilot: existsSync(TRAJECTORY_FILE) ? autopilotStats(readFileSync(TRAJECTORY_FILE, 'utf8')) : null,
277
404
  computedAt: new Date().toISOString(),
278
405
  };
279
406
  }
280
407
 
408
+ /**
409
+ * GET /api/view-doc?id=<artifactId> (CR-GVE-250) — der einzige Weg von der
410
+ * Artefaktzeile des Dashboards zu docs/views/<name>.md: `dist/` liefert
411
+ * `docs/` nicht aus, ein statischer Link geht also nicht.
412
+ *
413
+ * `id` wird AUSSCHLIESSLICH über den Katalog `VIEW_FILENAMES` aufgelöst —
414
+ * aus der Anfrage wird nie ein Pfad zusammengesetzt, deshalb läuft ein
415
+ * `../..`-Versuch nicht ins Dateisystem, sondern in den Katalog-Miss (404).
416
+ * `Object.hasOwn` statt `in`, sonst würden `__proto__`/`constructor` als
417
+ * Treffer gelten.
418
+ */
419
+ function readViewDoc(id) {
420
+ if (!id || !Object.hasOwn(VIEW_FILENAMES, id)) return null;
421
+ const file = join(VIEWS_DIR, VIEW_FILENAMES[id]);
422
+ return existsSync(file) ? readFileSync(file, 'utf8') : null;
423
+ }
424
+
281
425
  const middleware = (req, res, next) => {
282
- if (req.url !== '/api/dashboard') return next();
426
+ const url = new URL(req.url ?? '/', 'http://localhost');
427
+ if (url.pathname === '/api/view-doc') {
428
+ const markdown = readViewDoc(url.searchParams.get('id'));
429
+ if (markdown === null) {
430
+ res.statusCode = 404;
431
+ res.setHeader('Content-Type', 'application/json');
432
+ res.end(JSON.stringify({ error: 'unknown view-doc id' }));
433
+ return;
434
+ }
435
+ res.setHeader('Content-Type', 'text/markdown; charset=utf-8');
436
+ res.end(markdown);
437
+ return;
438
+ }
439
+ if (url.pathname !== '/api/dashboard') return next();
283
440
  res.setHeader('Content-Type', 'application/json');
284
441
  res.end(JSON.stringify(buildDashboard()));
285
442
  };