@sigloch/graph-view-edit 0.7.0 → 0.8.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/vite.config.js CHANGED
@@ -1,10 +1,25 @@
1
1
  import { readFileSync, writeFileSync, mkdirSync, unlinkSync, existsSync, readdirSync, realpathSync, statSync, watch } from 'node:fs';
2
+ import { execFileSync } from 'node:child_process';
2
3
  import { join, basename } from 'node:path';
3
4
  import { fileURLToPath } from 'node:url';
4
5
  import { defineConfig } from 'vite';
5
6
  import react from '@vitejs/plugin-react';
6
- import { DefaultRuleEngine, SE_DESCRIPTOR } from '@sigloch/graph-api-core';
7
- import { ONTOLOGY_VERSION, RULES_VERSION } from '@sigloch/contracts/se';
7
+ import { DefaultRuleEngine, SE_DESCRIPTOR, fromOntologyGraph } from '@sigloch/graph-api-core';
8
+ // CR-GVE-262: `moduleMetrics` ist DIESELBE Funktion, auf die graphcodes
9
+ // graph_metrics ein dünnes Binding ist ("KEINE Rechnung hier", tools/metrics.ts)
10
+ // — die Kohäsions-Karte rechnet also nichts selbst, sie ruft die eine Referenz.
11
+ // CR-GVE-274: `metrics` ist DIESELBE Funktion, aus der der Host seinen Ist-Vektor
12
+ // bildet (archMetrics -> fit-advisory.ts) — der Flugschreiber rechnet also nichts
13
+ // Eigenes, er ruft dieselbe Referenz, genau wie er es fuer die Regeln mit
14
+ // DefaultRuleEngine tut. Gemessen: 0,7 ms je Stand.
15
+ import { metrics as archMetricsOf } from '@sigloch/se-engine';
16
+ import {
17
+ ONTOLOGY_VERSION,
18
+ RULES_VERSION,
19
+ moduleMetrics,
20
+ MetricPolicySchema,
21
+ DEFAULT_METRIC_POLICY,
22
+ } from '@sigloch/contracts/se';
8
23
  // CR-GC-265: these eight come from the read-side client, not the substrate.
9
24
  // They are pure projection plus a node:net socket call — depending on
10
25
  // @sigloch/graphcode for them pulled kuzu-wasm, the MCP SDK and the TypeScript
@@ -18,6 +33,8 @@ import {
18
33
  VIEW_FILENAMES,
19
34
  ARTIFACT_CATALOG,
20
35
  analysisCreationCurrencyProvider,
36
+ PHASE_GATE_RULES,
37
+ groupViolations,
21
38
  callHost,
22
39
  HOST_SOCK_BASENAME,
23
40
  } from '@sigloch/graphcode-client';
@@ -123,12 +140,22 @@ function graphStaticPlugin() {
123
140
  * (a foreign repo without its own gve config.json), the first
124
141
  * docs/graph/*.graph.json wins — the same discovery the dashboard uses, so
125
142
  * `gve --repo <path>` needs no per-repo config to find the graph.
143
+ *
144
+ * `repoRoot` (CR-GVE-271) ist KEIN Config-Feld — es steht bewusst nicht in
145
+ * ConfigSchema, sondern nur in dieser Antwort: es ist die Identität dieser
146
+ * Instanz, dieselbe wie in `/api/dashboard`. Warum hier zusätzlich: ein
147
+ * Starter fragt nur »bedient dieser Viewer MEIN Repo?«, und `/api/dashboard`
148
+ * beantwortet das erst, nachdem es Readiness über den Host gegen den Store
149
+ * gerechnet hat — in graphcode selbst ~1,1 s. Jede Probe mit knappem Budget
150
+ * las das als »kein Viewer da« und startete einen weiteren; Vite bumpte den
151
+ * Port, und die Waisen sammelten sich. Diese Antwort kostet keine Rechnung.
126
152
  */
127
153
  function configApiPlugin() {
128
154
  const middleware = (req, res, next) => {
129
155
  if (req.url !== '/api/config') return next();
130
156
  const repoRoot = resolveRepoRoot();
131
157
  const config = structuredClone(APP_CONFIG);
158
+ config.repoRoot = servedRepoRoot();
132
159
  if (!existsSync(join(repoRoot, config.graph.path))) {
133
160
  const graphDir = join(repoRoot, 'docs', 'graph');
134
161
  const hit = existsSync(graphDir)
@@ -159,43 +186,19 @@ function configApiPlugin() {
159
186
  * just fetches the JSON like any other static resource.
160
187
  */
161
188
  /**
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 '').
189
+ * docs/graph/*.graph.json graph-api-core's Graph{nodes,edges} via the
190
+ * PUBLISHED inverse `fromOntologyGraph` (CR-SM-254) no hand-rolled mirror.
191
+ * The previous hand-derived copy lifted exactly four flat keys
192
+ * (status/asil/method/kinds) into `attributes` and dropped everything else
193
+ * the exporter writes flat (concept, severity/occurrence/detection,
194
+ * analysisFreshness, testRefs, …) the CR-GC-402 "second truth": the
195
+ * dashboard evaluated a castrated graph and disagreed with graph_readiness.
172
196
  * `tests/vite-config-load-graph.test.mjs` pins the round trip against the
173
197
  * REAL projectToOntologyGraph, so a drift in either direction fails loud.
174
198
  */
175
199
  export function loadGraph(file) {
176
200
  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
- }));
201
+ const { nodes, edges } = fromOntologyGraph(json);
199
202
  // CR-GC-300: graph_export now stamps graphVersion at write time — the live
200
203
  // comparison value computeAnalysisCurrency() needs against each analysis
201
204
  // artifact's SYS.attributes.analysisFreshness.<id>.graphVersion stamp
@@ -205,55 +208,786 @@ export function loadGraph(file) {
205
208
  }
206
209
 
207
210
  /**
208
- * Regel-Aggregat für das Recommendations-Panel (CR-GVE-250) — EINE Zeile je
209
- * ruleId statt 30 fast gleicher Blöcke.
211
+ * Das Recommendations-Teilstück des Dashboard-Payloads (CR-GVE-250):
212
+ * `recommendationsPanel`s `items`/`total` UNVERÄNDERT die Items tragen
213
+ * `topCandidate`, das die Ein-Klick-Fix-Geste (CR-GVE-111) braucht — plus
214
+ * die Gruppen als zusätzliche Sicht, nicht als Ersatz.
215
+ *
216
+ * Die Gruppierung selbst ist seit CR-GVE-259 `groupViolations` aus
217
+ * @sigloch/graphcode-client — dieselbe Funktion, die die MCP-Fläche unter
218
+ * `detail:'grouped'` liefert (CR-GC-411). Die frühere lokale Kopie ist
219
+ * GELÖSCHT, nicht deprecated: eine Aggregation, zwei Konsumenten.
220
+ */
221
+ export function recommendationsPayload(violations, limit = 50) {
222
+ return { ...recommendationsPanel(violations, limit), groups: groupViolations(violations) };
223
+ }
224
+
225
+ /**
226
+ * Gate-Blocker als strukturierte Gruppen (CR-GVE-256) — dieselbe Mechanik wie
227
+ * `recommendationsPayload` (erst gruppieren, dann kappen; Counts aus den
228
+ * UNGEKAPPTEN Listen), eingeschränkt auf die Regeln, die das Gate besitzt.
229
+ * Nur error-severity blockiert (warnings/info sind advisory `open` —
230
+ * readiness.js scorePhaseGate) — deshalb wird hier identisch gefiltert.
231
+ */
232
+ export function gateBlockerGroups(violations, ruleIds, elementLimit = 10) {
233
+ return groupViolations(
234
+ violations.filter((v) => v.severity === 'error' && ruleIds.includes(v.ruleId)),
235
+ elementLimit,
236
+ );
237
+ }
238
+
239
+ /**
240
+ * Gate-übergreifende Aggregat-Sicht (CR-GVE-256): EINE Gruppe je ruleId über
241
+ * alle Gates, mit Gate-Chips — dieselbe Regel wird nicht n-fach gelistet.
242
+ * Die Gate-Zuordnung ist die VORHANDENE server-seitige Ableitung
243
+ * (`PHASE_GATE_RULES` aus @sigloch/graphcode-client — dieselbe Tabelle, aus
244
+ * der readiness.js die Gates scored), kein lokaler zweiter Regel-Katalog.
245
+ * Impl-Gates besitzen keine Element-Regeln (ihre Blocker sind CRs/Scope/
246
+ * Creations — strukturell, ohne ruleId) und tauchen deshalb hier nicht auf.
247
+ */
248
+ export function gateBlockerRollup(violations, gateRules = PHASE_GATE_RULES, elementLimit = 10) {
249
+ const ruleToGates = new Map();
250
+ for (const [gateId, ruleIds] of Object.entries(gateRules)) {
251
+ for (const rid of ruleIds) {
252
+ if (!ruleToGates.has(rid)) ruleToGates.set(rid, []);
253
+ ruleToGates.get(rid).push(gateId);
254
+ }
255
+ }
256
+ const owned = violations.filter((v) => v.severity === 'error' && ruleToGates.has(v.ruleId));
257
+ return groupViolations(owned, elementLimit).map((g) => ({ ...g, gates: ruleToGates.get(g.ruleId) }));
258
+ }
259
+
260
+ /**
261
+ * Das Readiness-Teilstück des Dashboard-Payloads (CR-GVE-256):
262
+ * `readinessPanel` UNVERÄNDERT als Basis, aber je Gate ersetzt das flache
263
+ * `blocking`-String-Array durch `blockerGroups` (regelbasierte Blocker,
264
+ * gruppiert) + `blockingOther` (Milestones/CRs/Creations/Completeness — die
265
+ * Zeilen ohne ruleId). Das flache Feld wird ENTFERNT, nicht daneben
266
+ * weitergereicht — sonst rendert es irgendwann wieder jemand (Parallelpfad).
267
+ */
268
+ export function readinessPayload(report) {
269
+ const panel = readinessPanel(report);
270
+ const enrich = (g) => {
271
+ const ruleIds = PHASE_GATE_RULES[g.id] ?? [];
272
+ const { blocking, ...rest } = g;
273
+ return {
274
+ ...rest,
275
+ blockerGroups: gateBlockerGroups(report.violations, ruleIds),
276
+ // Regel-Zeilen sind als Gruppen abgebildet; übrig bleiben die
277
+ // strukturellen Blocker ("<CR> not done", "milestone … missing",
278
+ // "<Creation> not performed", "… completeness x/y").
279
+ blockingOther: blocking.filter((line) => !ruleIds.some((rid) => line.startsWith(`${rid}: `))),
280
+ };
281
+ };
282
+ return {
283
+ ...panel,
284
+ phaseGates: panel.phaseGates.map(enrich),
285
+ implGates: panel.implGates.map(enrich),
286
+ blockerRollup: gateBlockerRollup(report.violations),
287
+ };
288
+ }
289
+
290
+ /**
291
+ * Autopilot-Scoreboard (CR-GVE-257) — Präsentations-Aggregation der
292
+ * `.graphcode/trajectory.jsonl` des Ziel-Repos (Projektion des OperationsLog,
293
+ * graphcode CR-GC-252). NUR Zeilen zählen aus einer Datei, die graphcode
294
+ * schreibt — keine eigene Metrik-/Regelberechnung (Grenze, Entscheidung
295
+ * 2026-08-25; Spiderweb/Konvergenz sind graphcode CR-DRAFT-GC-410).
296
+ *
297
+ * Zähl-Semantik entlang der CR-Referenzzahlen (193 / 2 418 / 45 im
298
+ * graphcode-Repo): applied/rejected über `operation === 'mutate'`;
299
+ * `opsTotal` = Summe `opCounts` der APPLIED Mutationen (was wirklich durchs
300
+ * Gate in den Graphen ging); Sessions = distinct `consumerId`; der
301
+ * Autorschafts-Split zählt die applied Mutationen (wer den Graphen WIRKLICH
302
+ * geschrieben hat). Violations-Delta: erste → letzte Zeile der Session des
303
+ * letzten Eintrags. Kaputte Zeilen werden übersprungen, leere Datei → null
304
+ * (die Karte zeigt „keine Trajektorie vorhanden", kein Crash).
305
+ */
306
+ export function autopilotStats(jsonlText) {
307
+ const entries = (jsonlText ?? '')
308
+ .split('\n')
309
+ .map((line) => {
310
+ try {
311
+ return JSON.parse(line);
312
+ } catch {
313
+ return null;
314
+ }
315
+ })
316
+ .filter((e) => e && typeof e === 'object');
317
+ if (entries.length === 0) return null;
318
+ const mutates = entries.filter((e) => e.operation === 'mutate');
319
+ const applied = mutates.filter((e) => e.applied === true);
320
+ const versions = applied.map((e) => e.graphVersion).filter((v) => typeof v === 'number');
321
+ const authors = { agent: 0, human: 0 };
322
+ for (const e of applied) authors[e.consumerType === 'agent' ? 'agent' : 'human'] += 1;
323
+ const lastSessionId = entries[entries.length - 1].consumerId;
324
+ const lastSession = entries.filter((e) => e.consumerId === lastSessionId);
325
+ const zero = { error: 0, warning: 0, info: 0 };
326
+ return {
327
+ applied: applied.length,
328
+ rejected: mutates.length - applied.length,
329
+ graphVersionFrom: versions.length ? Math.min(...versions) : null,
330
+ graphVersionTo: versions.length ? Math.max(...versions) : null,
331
+ opsTotal: applied.reduce((n, e) => n + (typeof e.opCounts === 'number' ? e.opCounts : 0), 0),
332
+ sessions: new Set(entries.map((e) => e.consumerId)).size,
333
+ activeDays: new Set(entries.map((e) => String(e.ts ?? '').slice(0, 10)).filter(Boolean)).size,
334
+ authors,
335
+ lastSession: {
336
+ consumerId: lastSessionId,
337
+ violationsFrom: { ...zero, ...(lastSession[0]?.violations ?? {}) },
338
+ violationsTo: { ...zero, ...(lastSession[lastSession.length - 1]?.violations ?? {}) },
339
+ },
340
+ };
341
+ }
342
+
343
+ /**
344
+ * Karte 1 „Hält der Bauplan?" (CR-GVE-262) — Kohäsion je Modul aus
345
+ * `moduleMetrics` (@sigloch/contracts/se), der EINEN Referenzrechnung hinter
346
+ * `graph_metrics.cohesion`. Reine Präsentations-Aggregation (Grenze
347
+ * CR-GVE-257): sortieren + summieren, keine eigene Metrik.
210
348
  *
211
- * Die Zählung läuft über die UNGEKAPPTEN `violations`, nicht über
212
- * `recommendationsPanel(...).items`: das Panel kappt ZUERST bei 50, also
213
- * käme aus den Items für eine 60× feuernde Regel "50×" heraus eine falsche
214
- * Zahl, und die ist schlimmer als eine lange Liste. Erst gruppieren, dann
215
- * kappen.
349
+ * `cohesion: null` (nicht messbar < 2 FUNCs oder keine externe Verbindung)
350
+ * bleibt null und zählt NICHT in die Kennzahl: nicht messbar ist nicht 0
351
+ * (CR-GC-326-Regel). Sortiert nach Verbindungszahl absteigenddie
352
+ * Reihenfolge ist das Signal; nicht messbare Module stehen am Ende.
353
+ */
354
+ export function cohesionPayload(ontologyJson) {
355
+ return cohesionRollup(moduleMetrics(ontologyJson));
356
+ }
357
+
358
+ /**
359
+ * Die Präsentations-Aggregation über fertige Modulzeilen (CR-GVE-272) — sortieren
360
+ * und summieren, sonst nichts. Sie ist von der QUELLE der Zeilen getrennt, weil es
361
+ * die Quelle ist, die sich mit diesem CR ändert: Host statt Eigenrechnung. Die
362
+ * Sortierung bleibt die aus CR-GVE-262 (Verbindungszahl absteigend) — den Host
363
+ * seine eigene Rangfolge (schlechteste Kohäsion zuerst) durchreichen zu lassen,
364
+ * wäre eine Änderung der DARSTELLUNG und gehört nicht in einen CR, der die HERKUNFT
365
+ * umstellt.
216
366
  *
217
- * `elementIds` wird bei `elementLimit` abgeschnitten und sagt über
218
- * `elementIdsOmitted`, wie viele fehlen nie stumm; genau die stumme
219
- * Kappung ist der Fehler, den dieser CR behebt.
220
- */
221
- export function recommendationGroups(violations, elementLimit = 10) {
222
- const byRule = new Map();
223
- for (const v of violations) {
224
- let g = byRule.get(v.ruleId);
225
- if (!g) {
226
- g = { ruleId: v.ruleId, severity: v.severity, count: 0, message: v.message, fixHint: v.fixHint, allElementIds: [] };
227
- byRule.set(v.ruleId, g);
367
+ * Alle sechs Messungen je Modul reisen mit — vorher behielt diese Stelle
368
+ * `cohesion` und `allocatedFuncs` und warf `instability`, `lcom4`, `fanIn` und
369
+ * `fanOut` weg, weshalb die Karte den Wert hatte, aber nicht urteilen konnte.
370
+ */
371
+ function cohesionRollup(rawModules) {
372
+ const rows = rawModules.map((m) => ({
373
+ moduleId: m.moduleId,
374
+ moduleName: m.moduleName,
375
+ allocatedFuncs: m.allocatedFuncs,
376
+ cohesion: m.cohesion,
377
+ instability: m.instability ?? null,
378
+ lcom4: m.lcom4 ?? null,
379
+ fanIn: m.fanIn ?? null,
380
+ fanOut: m.fanOut ?? null,
381
+ }));
382
+ const connections = (m) => (m.cohesion ? m.cohesion.internal + m.cohesion.external : -1);
383
+ rows.sort((a, b) => connections(b) - connections(a) || a.moduleId.localeCompare(b.moduleId));
384
+ const measurable = rows.filter((m) => m.cohesion);
385
+ return {
386
+ modules: rows,
387
+ internal: measurable.reduce((n, m) => n + m.cohesion.internal, 0),
388
+ total: measurable.reduce((n, m) => n + m.cohesion.internal + m.cohesion.external, 0),
389
+ };
390
+ }
391
+
392
+ /**
393
+ * Der Architektur-Block des Dashboards (CR-GVE-272) — EINE Stelle, die entscheidet,
394
+ * woher Modulzahlen, Schwellen und Zielmarke kommen, und die es ANSAGT.
395
+ *
396
+ * Mit Host: alles aus `graph_metrics`, unverändert durchgereicht — Modulzeilen,
397
+ * `policy`/`policySource` (CR-GC-329) und `fit` mit Ist-Vektor, Zielgewichten,
398
+ * Zielwerten und der Widerspruchsliste (CR-GC-451/457).
399
+ *
400
+ * Ohne Host: der bisherige lokale Weg, und `origin: 'local'` mit `reason` sagt es
401
+ * an — dasselbe Muster wie `readinessSource`. Was der lokale Weg NICHT kennt, ist
402
+ * dann `null`, nie ein erfundener Wert: `fit` gibt es lokal gar nicht (der
403
+ * Ist-Vektor kommt aus der se-engine, die gve nicht lädt), und aus dem Zielprofil
404
+ * liest der Fallback nur die Gewichte.
405
+ */
406
+ export function architecturePayload({ hostMetrics, hostError, ontologyJson, repoRoot }) {
407
+ if (hostMetrics) {
408
+ return {
409
+ origin: 'host',
410
+ reason: null,
411
+ cohesion: cohesionRollup(hostMetrics.modules),
412
+ policy: hostMetrics.policy,
413
+ policySource: hostMetrics.policySource ?? null,
414
+ fit: hostMetrics.fit ?? null,
415
+ };
416
+ }
417
+ const local = readMetricPolicy(repoRoot);
418
+ return {
419
+ origin: 'local',
420
+ reason: hostError,
421
+ cohesion: cohesionPayload(ontologyJson),
422
+ policy: local.policy,
423
+ policySource: local.source,
424
+ // CR-GVE-280: der Grund reist mit, sonst ist `invalid` eine Behauptung ohne Fundstelle.
425
+ ...(local.reason ? { policyError: local.reason } : {}),
426
+ // Der Ist-Vektor ist eine Host-Zahl; ohne Host gibt es ihn nicht. Die
427
+ // Zielrichtung aus der Profildatei bleibt lesbar, der Zielwert kommt bewusst
428
+ // NICHT mit: er ist nur zusammen mit dem Ist-Wert sinnvoll, gegen den er steht.
429
+ fit: null,
430
+ profile: targetProfilePayload(repoRoot),
431
+ };
432
+ }
433
+
434
+ /**
435
+ * Die Architektur-Regeln der Befund-Tabelle (CR-GVE-262).
436
+ *
437
+ * CR-GVE-273: `R-04` (Kopplung) und `MT-02` (LCOM4) sind hier RAUS. Sie sind die
438
+ * Regelform von zwei Spalten, die seit CR-GVE-272 in der Modultabelle daneben
439
+ * stehen — dieselbe Kopplung, dieselbe Gruppenzahl, nur anders formuliert; genau
440
+ * der Eindruck „sieht doppelt aus" aus dem Review. Verloren geht nichts: ihr
441
+ * Urteil ist der Chip in der Tabelle, und der Chip nennt die Regel-ID, also ist
442
+ * die Regelerklärung einen Hover entfernt.
443
+ *
444
+ * `RD-04` bleibt — es feuert auch auf FUNC-Container und ist damit nicht dieselbe
445
+ * Aussage wie eine Modulzeile. `R-23` bleibt — es ist der Befund, zu dem es einen
446
+ * anwendbaren Vorschlag gibt.
447
+ */
448
+ const ARCH_RULE_LABELS = {
449
+ 'RD-04': 'Zu viele Kinder auf einer Ebene',
450
+ 'R-23': 'Modul ohne Funktion',
451
+ };
452
+
453
+ /**
454
+ * Karte 2, Tabellenteil (CR-GVE-262) — die Architektur-Befunde aus dem
455
+ * VORHANDENEN Engine-Lauf (dieselben Violations, die rules_evaluate liefert),
456
+ * gefiltert auf R-04/RD-04/MT-02/R-23. `numbers` sind die Zahlen aus der
457
+ * Regel-Meldung SELBST (keine zweite Rechnung neben der Regel); `applicable`
458
+ * ist der Join gegen graph_suggest: true/false = Suggestion vorhanden und
459
+ * vom Gate durchgelassen/abgelehnt, null = kein Vorschlag zu diesem Befund
460
+ * (oder graph_suggest nicht erreichbar — das sagt `suggestAvailable`).
461
+ */
462
+ export function architectureFindings(violations, suggest) {
463
+ return violations
464
+ .filter((v) => Object.hasOwn(ARCH_RULE_LABELS, v.ruleId))
465
+ .map((v) => {
466
+ const s = suggest?.suggestions?.find((x) => x.ruleId === v.ruleId && x.elementId === v.elementId);
467
+ return {
468
+ ruleId: v.ruleId,
469
+ label: ARCH_RULE_LABELS[v.ruleId],
470
+ elementId: v.elementId,
471
+ severity: v.severity,
472
+ // Kein Treffer mitten in einem Bezeichner: die 4 in "LCOM4=6" ist Teil
473
+ // des Regelnamens, keine Zahl der Meldung (Lookbehind auf Buchstaben).
474
+ numbers: (v.message.match(/(?<![A-Za-z])\d+(?:[.,]\d+)?/g) ?? []).join(' / '),
475
+ message: v.message,
476
+ applicable: s ? s.applicable === true : null,
477
+ };
478
+ });
479
+ }
480
+
481
+ /**
482
+ * Das hinterlegte Zielprofil (CR-GC-295, committete Steuer-Config
483
+ * `.graphcode/target-profile.json` des bedienten Repos) — nur GELESEN für die
484
+ * Zielmarken der Karte 2. Fehlt es oder ist es unlesbar: null — die Karte
485
+ * zeigt dann den Hinweis statt einer erfundenen Zielrichtung (AC 4).
486
+ */
487
+ export function targetProfilePayload(repoRoot) {
488
+ const file = join(repoRoot, '.graphcode', 'target-profile.json');
489
+ if (!existsSync(file)) return null;
490
+ try {
491
+ const { weights } = JSON.parse(readFileSync(file, 'utf8'));
492
+ return weights && typeof weights === 'object' ? { weights } : null;
493
+ } catch {
494
+ return null;
495
+ }
496
+ }
497
+
498
+ /**
499
+ * DIE Regel-Engine-Konstruktion — eine für den Live-Stand (buildDashboard)
500
+ * und für die History-Messung (CR-GC-410): derselbe Messpfad, keine zweite
501
+ * Konfiguration, die auseinanderdriften könnte.
502
+ */
503
+ function createRuleEngine() {
504
+ const engine = new DefaultRuleEngine(SE_DESCRIPTOR.version);
505
+ engine.register(SE_DESCRIPTOR.rules ?? []);
506
+ return engine;
507
+ }
508
+
509
+ /**
510
+ * JSONC → JSON für `graphcode.config.jsonc` (CR-GC-402). Zeichen-Scanner statt
511
+ * Regex, weil ein `//` INNERHALB eines Strings (ein Pfad, eine URL) sonst den Rest
512
+ * der Zeile verschluckt.
513
+ *
514
+ * Warum eine eigene Zeile Code und kein Import: die Referenz-Implementierung liegt
515
+ * in graphcodes `src/harness/config.ts`, und `@sigloch/graphcode` zieht kuzu-wasm
516
+ * plus das MCP-SDK nach — in einen Viewer, der strukturell nie einen Store öffnen
517
+ * darf (CR-GC-265). Ein leichter Subpath dafür existiert nicht.
518
+ */
519
+ export function stripJsonComments(text) {
520
+ let out = '';
521
+ let inString = false;
522
+ let inLine = false;
523
+ let inBlock = false;
524
+ for (let i = 0; i < text.length; i++) {
525
+ const c = text[i];
526
+ const next = text[i + 1];
527
+ if (inLine) {
528
+ if (c === '\n') { inLine = false; out += c; }
529
+ continue;
530
+ }
531
+ if (inBlock) {
532
+ if (c === '*' && next === '/') { inBlock = false; i++; }
533
+ else if (c === '\n') out += c;
534
+ continue;
535
+ }
536
+ if (inString) {
537
+ out += c;
538
+ if (c === '\\') { out += text[++i] ?? ''; continue; }
539
+ if (c === '"') inString = false;
540
+ continue;
228
541
  }
229
- g.count += 1;
230
- if (v.elementId) g.allElementIds.push(v.elementId);
542
+ if (c === '"') { inString = true; out += c; continue; }
543
+ if (c === '/' && next === '/') { inLine = true; i++; continue; }
544
+ if (c === '/' && next === '*') { inBlock = true; i++; continue; }
545
+ out += c;
231
546
  }
232
- return [...byRule.values()]
233
- .sort((a, b) => b.count - a.count)
234
- .map(({ allElementIds, ...g }) => ({
235
- ...g,
236
- elementIds: allElementIds.slice(0, elementLimit),
237
- elementIdsOmitted: Math.max(0, allElementIds.length - elementLimit),
238
- }));
547
+ return out.replace(/,(\s*[}\]])/g, '$1');
239
548
  }
240
549
 
241
550
  /**
242
- * Das Recommendations-Teilstück des Dashboard-Payloads (CR-GVE-250):
243
- * `recommendationsPanel`s `items`/`total` UNVERÄNDERT die Items tragen
244
- * `topCandidate`, das die Ein-Klick-Fix-Geste (CR-GVE-111) braucht — plus
245
- * die Gruppen als zusätzliche Sicht, nicht als Ersatz.
551
+ * Die Urteilsschwellen des BEDIENTEN Repos (CR-GC-402) — dieselbe Datei, aus der
552
+ * der graphcode-Host seine Policy nimmt, gelesen für den Fall, dass kein Host
553
+ * antwortet. Ohne sie urteilte die Ersatzrechnung mit `DEFAULT_METRIC_POLICY` und
554
+ * widersprach dem Host an genau den Regeln, deren Schwelle das Repo verstellt hat
555
+ * (gemessen: MT-01 feuerte dashboardseitig 1×, hostseitig 0×, weil das Repo
556
+ * `instability: null` gesetzt hatte).
557
+ *
558
+ * Fehlt/kaputt die Datei → Default MIT Vermerk (`source`), nie ein stiller
559
+ * Ersatzwert: die Herkunft geht mit der Zahl an die Anzeige.
246
560
  */
247
- export function recommendationsPayload(violations, limit = 50) {
248
- return { ...recommendationsPanel(violations, limit), groups: recommendationGroups(violations) };
561
+ export function readMetricPolicy(repoRoot) {
562
+ const file = join(repoRoot, 'graphcode.config.jsonc');
563
+ if (!existsSync(file)) return { policy: DEFAULT_METRIC_POLICY, source: 'default' };
564
+ try {
565
+ const parsed = JSON.parse(stripJsonComments(readFileSync(file, 'utf8')));
566
+ const result = MetricPolicySchema.safeParse(parsed?.metricPolicy);
567
+ if (result.success) return { policy: result.data, source: 'config' };
568
+ // CR-GVE-280: eine Datei, die DA ist und nicht passt, ist etwas anderes als keine Datei.
569
+ // Vorher meldete beides `default` — wer eine Schwelle eintrug und nichts geschah, sah eine
570
+ // Antwort, die aussah wie "es gibt hier keine Config". Gefunden hat es der eigene Test:
571
+ // seine Fixture-Config verlor mit CR-SM-282/-283 die Pflichtfelder `decompositionBreadth`
572
+ // und `boundaryWidth`, wurde still verworfen, und die Zusage "urteilt mit der Policy DES
573
+ // REPOS" war monatelang unbelegt. Der Viewer kann nicht abbrechen wie der Host — aber
574
+ // verschweigen darf er es nicht.
575
+ return {
576
+ policy: DEFAULT_METRIC_POLICY,
577
+ source: 'invalid',
578
+ reason: result.error.issues.map((i) => `${i.path.join('.') || '(Wurzel)'}: ${i.message}`).join(' · '),
579
+ };
580
+ } catch (e) {
581
+ return { policy: DEFAULT_METRIC_POLICY, source: 'invalid', reason: String(e?.message ?? e) };
582
+ }
583
+ }
584
+
585
+ /**
586
+ * Die Readiness beim graphcode-Host holen (CR-GC-402, Option C) — derselbe
587
+ * `host.sock`-Weg wie /api/mutate und graph_suggest, also DAS Gate, DER Store,
588
+ * EINE Rechnung. `detail:true`, weil der Payload sowohl den Report als auch die
589
+ * rohen Violations braucht (Empfehlungen, Blocker-Gruppen, Architektur-Befunde) —
590
+ * ein zweiter Aufruf von `rules_evaluate` wäre eine zweite Erhebung derselben Liste.
591
+ *
592
+ * Auch die RC-Konformanzregeln (RC-01..06) kommen nur über diesen Weg: sie brauchen
593
+ * den Quellbaum, den der Viewer nicht auswertet. Das war das zweite Rest-Delta des CR.
594
+ *
595
+ * Kein Host → `{report: null, error}`; der Aufrufer rechnet dann selbst UND sagt es an.
596
+ * Eine formfremde Antwort (fremder/alter Host) zählt genauso als „nicht verwertbar" —
597
+ * lieber die angesagte Ersatzrechnung als eine halbe Zahl aus unbekannter Quelle.
598
+ */
599
+ export async function fetchHostReadiness(repoRoot, callHostImpl = callHost) {
600
+ const socketPath = join(repoRoot, '.graphcode', HOST_SOCK_BASENAME);
601
+ try {
602
+ const report = await callHostImpl(socketPath, 'graph_readiness', { detail: true });
603
+ if (!report?.compliance || !Array.isArray(report.violations)) {
604
+ return { report: null, error: 'graph_readiness lieferte keinen verwertbaren Report (detail:true)' };
605
+ }
606
+ return { report, error: null };
607
+ } catch (err) {
608
+ return { report: null, error: err.message };
609
+ }
610
+ }
611
+
612
+ /**
613
+ * Die Architektur-Kennzahlen vom HOST (CR-GVE-272) — dieselbe Linie wie
614
+ * `fetchHostReadiness` eine Etage höher: der Host besitzt die Zahlen.
615
+ *
616
+ * Vorher beantwortete gve dieselbe Frage an DREI Stellen lokal: `cohesionPayload`
617
+ * rechnete `moduleMetrics()` über die COMMITTETE Datei (der Host misst den
618
+ * Live-Store — zwischen Mutation und Export sagen die zwei Verschiedenes),
619
+ * `readMetricPolicy` las `graphcode.config.jsonc` selbst, und
620
+ * `targetProfilePayload` las das Zielprofil selbst und behielt daraus nur die
621
+ * Gewichte. Genau diese Aufteilung verbietet CR-GC-329/451/457: Wert, Schwelle
622
+ * und Zielmarke verlassen den Host in EINER Antwort, und wer eine davon selbst
623
+ * hält, ist eine zweite Quelle für dieselbe Zahl.
624
+ *
625
+ * `modules` UND `policy` müssen da sein, sonst ist die Antwort nicht verwertbar:
626
+ * ein alter Host (vor CR-GC-329) liefert Zeilen ohne Schwellen, und eine Karte,
627
+ * die dann die Schwelle lokal ergänzt, wäre wieder die zweite Quelle. Lieber die
628
+ * angesagte Ersatzrechnung als eine halbe Zahl aus unbekannter Quelle.
629
+ */
630
+ export async function fetchHostMetrics(repoRoot, callHostImpl = callHost) {
631
+ const socketPath = join(repoRoot, '.graphcode', HOST_SOCK_BASENAME);
632
+ try {
633
+ const m = await callHostImpl(socketPath, 'graph_metrics', {});
634
+ if (!Array.isArray(m?.modules) || !m?.policy) {
635
+ return { metrics: null, error: 'graph_metrics lieferte keine verwertbaren Modulzahlen (modules + policy)' };
636
+ }
637
+ return { metrics: m, error: null };
638
+ } catch (err) {
639
+ return { metrics: null, error: err.message };
640
+ }
641
+ }
642
+
643
+ /**
644
+ * Flugschreiber „Wirkt die Arbeit?" (CR-GC-410) — jeden COMMITTETEN Graph-
645
+ * Stand (Git-History von docs/graph/*.graph.json) über den VORHANDENEN
646
+ * Messpfad bewerten: fromOntologyGraph + DefaultRuleEngine(SE_DESCRIPTOR),
647
+ * exakt der Weg, den buildDashboard für den Live-Stand geht (CR-GC-303/324-
648
+ * Lehre: kein zweiter Messpfad). Reine Lese-Funktion — `git log`/`git show`,
649
+ * nichts wird geschrieben; trajectory.jsonl und recordAudit bleiben unberührt.
650
+ *
651
+ * Cache je Commit-Hash (Stände sind unveränderlich): der zweite Aufruf misst
652
+ * nur, was neu committet wurde. Ein unparsebarer/prä-ontologischer Stand wird
653
+ * ÜBERSPRUNGEN und gezählt, nie als (0/0)-Punkt erfunden — nicht messbar ist
654
+ * nicht 0 (CR-GC-326-Regel). Kein Git-Repo / kein committeter Stand → null.
655
+ * Messwert 2026-08-27 (kalt): graphcode, 78 Stände → ~3.1 s (~40 ms/Stand);
656
+ * warm (alles im Cache) → ~70 ms fürs `git log`.
657
+ *
658
+ * CR-GC-441: je Stand zusätzlich `warnings`/`infos` — dieselben Violations,
659
+ * nur nach Severity aufgeteilt (`severity` ist ein geschlossenes Trio, also
660
+ * gilt immer errors + warnings + infos === open; keine zweite Auswertung).
661
+ */
662
+
663
+ /**
664
+ * Schema-Version des Cache-Eintrags (CR-GC-441). Der Cache lebt im
665
+ * Prozess-Speicher (eine Map im dashboardApiPlugin, nichts auf Disk) und ist je
666
+ * Commit-Hash geschlüsselt — ein Stand ist unveränderlich, sein MESSERGEBNIS
667
+ * aber nicht: wächst die Messung um ein Feld, trägt der alte Eintrag es nicht
668
+ * und schlüge als `warnings: undefined` bis in die Karte durch. Die Version
669
+ * gehört deshalb in den Key: alte Einträge treffen nie, neu gemessen wird
670
+ * automatisch. Bei jedem neuen/geänderten Messfeld hochzählen.
671
+ */
672
+ const HISTORY_MEASURE_SCHEMA = 3;
673
+
674
+ export function measureGraphHistory(repoRoot, { cache = new Map(), gitImpl, policy: givenPolicy } = {}) {
675
+ const git =
676
+ gitImpl ??
677
+ ((args) =>
678
+ execFileSync('git', ['-C', repoRoot, ...args], {
679
+ encoding: 'utf8',
680
+ maxBuffer: 256 * 1024 * 1024,
681
+ // stderr schlucken: „kein Git-Repo" ist hier ein erwarteter Zustand
682
+ // (→ null), kein Log-Lärm im Server-/Testlauf.
683
+ stdio: ['ignore', 'pipe', 'pipe'],
684
+ }));
685
+ let log;
686
+ try {
687
+ log = git(['log', '--reverse', '--format=%x01%H %cI', '--name-only', '--', 'docs/graph/*.graph.json']);
688
+ } catch {
689
+ return null; // kein Git-Repo — bekannter Zustand, kein Fehler
690
+ }
691
+ // %x01-Marker statt Zeilenraten: je Commit ein Header + die geänderten Dateien.
692
+ const states = [];
693
+ let cur = null;
694
+ for (const line of log.split('\n')) {
695
+ if (line.startsWith('\x01')) {
696
+ const [hash, ts] = line.slice(1).split(' ');
697
+ cur = { hash, ts };
698
+ } else if (cur && line.endsWith('.graph.json')) {
699
+ states.push({ ...cur, file: line });
700
+ cur = null; // erste Graph-Datei des Commits zählt als der Stand
701
+ }
702
+ }
703
+ if (states.length === 0) return null;
704
+ const engine = createRuleEngine();
705
+ // CR-GC-402: dieselben Schwellen wie die Karte daneben — sonst misst der
706
+ // Flugschreiber die History gegen einen anderen Maßstab.
707
+ //
708
+ // CR-GVE-272: seit die Karte ihre Schwellen vom HOST bezieht, reicht der
709
+ // eigene Datei-Read dafür nicht mehr — er wäre wieder die zweite Quelle, nur
710
+ // eine Etage tiefer und ohne Test, der es merkt. Der Aufrufer gibt die
711
+ // Host-Policy weiter; ohne Host bleibt der Read die Quelle des Fallbacks.
712
+ const { policy, source: policySource } = givenPolicy
713
+ ? { policy: givenPolicy.policy, source: givenPolicy.source }
714
+ : readMetricPolicy(repoRoot);
715
+ const t0 = performance.now();
716
+ let measured = 0;
717
+ let skipped = 0;
718
+ const out = [];
719
+ for (const s of states) {
720
+ // CR-GVE-272: die Policy-HERKUNFT gehört in den Key. Ein Stand ist
721
+ // unveränderlich, sein Messergebnis aber nur relativ zu den Schwellen, gegen
722
+ // die gemessen wurde — startet der Host mitten in der Session, träfen sonst
723
+ // die Einträge des lokalen Laufs weiter und die Kurve mischte zwei Maßstäbe.
724
+ const key = `${HISTORY_MEASURE_SCHEMA}:${policySource}:${s.hash}`;
725
+ let m = cache.get(key);
726
+ if (!m) {
727
+ try {
728
+ // CR-GVE-274: der Stand liegt hier bereits als OntologyGraph vor — genau
729
+ // die Form, die `metrics()` erwartet. Der Vektor kostet EINEN Aufruf auf
730
+ // dem schon geladenen Objekt, keinen zweiten Ladepfad und kein zweites
731
+ // `git show`.
732
+ const ontologyJson = JSON.parse(git(['show', `${s.hash}:${s.file}`]));
733
+ const { nodes, edges } = fromOntologyGraph(ontologyJson);
734
+ const violations = engine.evaluate({ nodes, edges }, policy);
735
+ m = {
736
+ elements: nodes.length,
737
+ open: violations.length,
738
+ errors: violations.filter((v) => v.severity === 'error').length,
739
+ warnings: violations.filter((v) => v.severity === 'warning').length,
740
+ infos: violations.filter((v) => v.severity === 'info').length,
741
+ fit: archMetricsOf(ontologyJson, { layer: 'arch' }),
742
+ };
743
+ } catch {
744
+ m = { skipped: true };
745
+ }
746
+ cache.set(key, m);
747
+ measured += 1;
748
+ }
749
+ if (m.skipped) skipped += 1;
750
+ else out.push({ hash: s.hash, short: s.hash.slice(0, 7), ts: s.ts, ...m });
751
+ }
752
+ return {
753
+ states: out,
754
+ skipped,
755
+ measured,
756
+ fromCache: states.length - measured,
757
+ measureMs: Math.round(performance.now() - t0),
758
+ };
759
+ }
760
+
761
+ /**
762
+ * y-Achsen-Maximum des Flugschreibers (CR-GC-443): die kleinste RUNDE GANZE
763
+ * Zahl oberhalb des Daten-Maximums, mit ~5 % Luft, damit der höchste Punkt
764
+ * nicht am Rahmen klebt. Ganzzahlig, weil die Achse genau diese Zahl
765
+ * anschreibt — eine gerundete Beschriftung über einer krummen Skala wäre
766
+ * gelogen. Unter 10 ist jede Zahl rund genug (Daten-Max + 1); darüber die
767
+ * gewohnte 1/1.2/1.5/2/2.5/…-Leiter, die nie mehr als ~25 % Höhe verschenkt.
768
+ */
769
+ const FR_NICE_STEPS = [1, 1.2, 1.5, 2, 2.5, 3, 3.5, 4, 5, 6, 8, 10];
770
+
771
+ export function frAxisTop(dataMax) {
772
+ if (!(dataMax > 0)) return 1; // alles gebunden: flache Linie unten, keine 0-Division
773
+ if (dataMax < 10) return Math.ceil(dataMax) + 1;
774
+ const target = dataMax * 1.05;
775
+ const mag = 10 ** Math.floor(Math.log10(target));
776
+ const step = FR_NICE_STEPS.find((m) => m * mag >= target) ?? 10;
777
+ return Math.round(step * mag);
778
+ }
779
+
780
+ /**
781
+ * Präsentations-Aggregation der History-Messung (CR-GC-410): Pfad in Commit-
782
+ * Reihenfolge (nach rechts = gebaut, nach unten = gebunden) und der
783
+ * Sekundärbefund „N der letzten M Stände ohne error" als Gate-Wirkungs-Nachweis
784
+ * (Fenster max. 24).
785
+ *
786
+ * CR-GC-441 ergänzt zwei Dinge, damit „0 der letzten 24 ohne error" nicht als
787
+ * Arbeitsurteil missverstanden wird:
788
+ * - `severityRange`: min/max je Severity über DASSELBE Fenster. Eine Spanne ist
789
+ * ein Fakt über die gemessenen Stände, keine Trendaussage — genau das, was die
790
+ * Daten tragen, wenn das Regelwerk sich unter ihnen bewegt hat. Fehlt die
791
+ * Aufschlüsselung in den Ständen (alter Payload), ist sie `null`, nicht 0.
792
+ * - `rulesVersion`: mit welchem Regelstand JEDER historische Stand bewertet
793
+ * wurde — der Vorbehalt, den die Karte anschreibt.
794
+ *
795
+ * CR-GC-443 ergänzt `scale`: die y-Skala folgt dem DATEN-Maximum. Sie gehört in
796
+ * diese Aggregation (wie errorFree), nicht in die Zeichenschicht — dort bleibt
797
+ * reine Pixel-Geometrie, es gibt keine zweite Skalenrechnung.
798
+ *
799
+ * CR-GVE-266 streicht die Vergleichsgerade („wenn jedes neue Element seine
800
+ * Verstöße mitbrächte"). Sie verließ die Skala regelmäßig nach oben und kostete
801
+ * dafür Kappungs-Marker, Legendensatz und drei Payload-Felder — die Skala hing
802
+ * schon seit CR-GC-443 nicht mehr an ihr.
803
+ */
804
+ export function flightRecorderPayload(history) {
805
+ if (!history || history.states.length === 0) return null;
806
+ const maxElements = Math.max(1, ...history.states.map((s) => s.elements));
807
+ // Daten-Maximum über ALLE gezeichneten Serien: errors+warnings+infos === open
808
+ // (geschlossenes Trio, CR-GC-441), `open` ist also ihre obere Schranke —
809
+ // ein Maximum über die Nebenlinien wäre dieselbe Zahl.
810
+ const dataMax = Math.max(0, ...history.states.map((s) => s.open));
811
+ const yMax = frAxisTop(dataMax);
812
+ const windowSize = Math.min(24, history.states.length);
813
+ const recent = history.states.slice(-windowSize);
814
+ const hasSeverity = recent.every((s) => typeof s.warnings === 'number' && typeof s.infos === 'number');
815
+ const span = (key) => ({
816
+ min: Math.min(...recent.map((s) => s[key])),
817
+ max: Math.max(...recent.map((s) => s[key])),
818
+ });
819
+ return {
820
+ states: history.states,
821
+ scale: { maxElements, dataMax, yMax },
822
+ errorFree: { count: recent.filter((s) => s.errors === 0).length, window: windowSize },
823
+ severityRange: hasSeverity
824
+ ? { window: windowSize, errors: span('errors'), warnings: span('warnings'), infos: span('infos') }
825
+ : null,
826
+ // CR-GVE-274: „bewegt es sich dorthin?" — die Bewegung über DASSELBE Fenster,
827
+ // über das die Karte auch `errorFree` und `severityRange` aussagt. Nicht seit
828
+ // dem allerersten Commit: der älteste Stand dieses Repos hat 68 Elemente und
829
+ // keine Modulstruktur, gemessen wären das +4.06 auf coherence — wahr, aber es
830
+ // mischt „wir haben es gebaut" mit „wir haben es verbessert". Eine Zahl, ein
831
+ // Vergleich, dieselbe Spanne wie nebenan.
832
+ //
833
+ // Übersprungene Stände tragen kein `fit` und fallen heraus; unter zwei
834
+ // messbaren Ständen gibt es keine Bewegung, und eine Differenz gegen nichts
835
+ // wäre keine 0, sondern erfunden.
836
+ fitTrend: (() => {
837
+ const measured = recent.filter((s) => s.fit);
838
+ if (measured.length < 2) return null;
839
+ const first = measured[0];
840
+ const last = measured[measured.length - 1];
841
+ return { from: first.fit, to: last.fit, states: measured.length, window: windowSize, since: first.ts };
842
+ })(),
843
+ // Der Maßstab, gegen den ALLE Stände gemessen wurden — nicht der, der zum
844
+ // jeweiligen Commit galt. Die Karte schreibt das an.
845
+ rulesVersion: RULES_VERSION,
846
+ skipped: history.skipped,
847
+ measured: history.measured,
848
+ fromCache: history.fromCache,
849
+ measureMs: history.measureMs,
850
+ };
851
+ }
852
+
853
+ /**
854
+ * graph_suggest über denselben host.sock-Pfad wie /api/mutate (CR-GC-241) —
855
+ * das Anwendbarkeits-Urteil (`applicable`, seit CR-GC-431 im Ergebnis) braucht
856
+ * das Gate und existiert nur im laufenden graphcode-Host; gve kann und darf
857
+ * es nicht nachrechnen. Kein Host erreichbar → null, die Karte sagt das
858
+ * (bekannter Zustand, kein Fehler — wie autopilot: null).
859
+ */
860
+ export async function fetchSuggest(repoRoot, callHostImpl = callHost) {
861
+ try {
862
+ const socketPath = join(repoRoot, '.graphcode', HOST_SOCK_BASENAME);
863
+ return await callHostImpl(socketPath, 'graph_suggest', { k: 20 });
864
+ } catch {
865
+ return null;
866
+ }
867
+ }
868
+
869
+ /**
870
+ * graph_next_step über denselben host.sock-Weg (CR-GC-433) — die Hygiene-Hälfte
871
+ * der Zeile „Der nächste Zug". Bis hierher lebte der Rundenschritt nur im
872
+ * Autopilot-Loop bzw. auf Abruf im Chat; das Dashboard hat ihn nie gezeigt.
873
+ *
874
+ * Kein Host → null. Der Viewer rechnet die Dimension NICHT nach: sie kommt aus
875
+ * `takeSteeringSnapshot` (contracts-Katalog inkl. der Regeln, die nur der
876
+ * Steering-Pfad wertet) — eine zweite Rechnung hier würde eine andere Dimension
877
+ * nennen als der Chat (die Lehre aus CR-GC-402).
878
+ */
879
+ export async function fetchNextStep(repoRoot, callHostImpl = callHost) {
880
+ try {
881
+ const socketPath = join(repoRoot, '.graphcode', HOST_SOCK_BASENAME);
882
+ return await callHostImpl(socketPath, 'graph_next_step', {});
883
+ } catch {
884
+ return null;
885
+ }
886
+ }
887
+
888
+ /**
889
+ * Regel-Erklärungen über denselben `host.sock`-Weg (CR-GVE-269). `HELP_CONTENT`
890
+ * in graphcode trägt zu JEDER Regel einen Klartext-Satz, die SE-Formulierung und
891
+ * wo anwendbar den kopierbaren Prompt; `graph_help` gibt sie heraus. gve holt sie
892
+ * ab und schreibt sie NICHT selbst — ein zweiter Regeltext wäre dieselbe zweite
893
+ * Quelle wie eine zweite Rechnung (CR-GVE-257, CR-GC-402).
894
+ *
895
+ * Je ID ein Aufruf, parallel, Fehler einzeln verschluckt: eine unbekannte ID
896
+ * (`graph_help` wirft dann) darf die Karte nicht mitnehmen — die Zeile steht
897
+ * auch ohne Erklärung, sie ist nur nackt.
898
+ */
899
+ export async function fetchRuleHelp(repoRoot, ruleIds, callHostImpl = callHost) {
900
+ const ids = [...new Set((ruleIds ?? []).filter(Boolean))];
901
+ if (!ids.length) return {};
902
+ const socketPath = join(repoRoot, '.graphcode', HOST_SOCK_BASENAME);
903
+ const entries = await Promise.all(ids.map(async (id) => {
904
+ try {
905
+ const e = await callHostImpl(socketPath, 'graph_help', { token: id });
906
+ // Layer 1 (SE-Formulierung) bleibt draußen: das ist genau das Vokabular,
907
+ // das der Tooltip ersetzen soll.
908
+ //
909
+ // CR-GVE-273: bei einer KENNZAHL sind es drei Antworten, nicht eine
910
+ // (CR-GC-458) — was gezählt wird, wofür der Wert steht, was ihn bewegt.
911
+ // Auf `plain` gekürzt käme genau die Hälfte an, die der Auftraggeber
912
+ // vermisst hat. gve formuliert hier nichts nach: fehlt ein Feld, fehlt es.
913
+ if (!e?.plain) return null;
914
+ const base = { title: e.title ?? id, plain: e.plain, prompt: e.prompt ?? null };
915
+ return e.kind === 'metric'
916
+ ? [id, { ...base, kind: 'metric', measure: e.measure, purpose: e.purpose, lever: e.lever, scale: e.scale ?? null }]
917
+ : [id, base];
918
+ } catch {
919
+ return null;
920
+ }
921
+ }));
922
+ return Object.fromEntries(entries.filter(Boolean));
923
+ }
924
+
925
+ /**
926
+ * „Der nächste Zug" (CR-GC-433, Trigger-Art 2+3) — Architektur- und
927
+ * Hygiene-Empfehlung NEBENEINANDER, permanent, ohne Handoff-Endzustand als
928
+ * Voraussetzung (Entscheidung (a): Architektur läuft parallel zur Hygiene,
929
+ * nicht dahinter).
930
+ *
931
+ * Reine Zusammenführung zweier Host-Antworten — hier wird nichts gerankt und
932
+ * nichts gerechnet: `graph_suggest` liefert seine Liste bereits anwendbar-zuerst
933
+ * (CR-GC-431), also ist Top-1 schlicht das erste Element. Was der Payload
934
+ * zusätzlich trägt, ist die EHRLICHKEIT der Zeile: `applicable` und
935
+ * `applicableCount` trennen „ausführbarer Zug" von „Fund ohne Zug", und
936
+ * `available: false` trennt beides von „kein Host" — drei Zustände, die als
937
+ * einer dargestellt eine Empfehlung vortäuschen würden, die es nicht gibt.
938
+ */
939
+ export function nextMovePayload(nextStep, suggest) {
940
+ const top = suggest?.suggestions?.[0] ?? null;
941
+ const edit = top?.edit ?? null;
942
+ return {
943
+ architecture: {
944
+ available: suggest != null,
945
+ // Anwendbar = vom Gate durchgelassen UND Δm trägt in die Zielrichtung.
946
+ // Ein positiver Score allein reicht nicht (der kann von der generischen
947
+ // Sonde stammen), das Gate-Urteil allein auch nicht (score ≤ 0 = weg vom Ziel).
948
+ applicableCount: (suggest?.suggestions ?? []).filter((s) => s.applicable === true && s.score > 0).length,
949
+ top: top && {
950
+ ruleId: top.ruleId,
951
+ elementId: top.elementId,
952
+ message: top.message,
953
+ score: top.score,
954
+ applicable: top.applicable === true,
955
+ // Der konkrete Zug, nicht nur der Fund. `retire` gesetzt heißt Umhängen:
956
+ // anwenden als EIN graph_mutate-Batch (CR-GC-435), `codeImpact` benennt
957
+ // die Code-Arbeit, die daran hängt.
958
+ edit: edit && {
959
+ source: edit.source,
960
+ target: edit.target,
961
+ type: edit.type,
962
+ retire: edit.retire ? { source: edit.retire.source, target: edit.retire.target, type: edit.retire.type } : null,
963
+ codeImpact: edit.codeImpact ?? null,
964
+ },
965
+ },
966
+ },
967
+ hygiene: {
968
+ available: nextStep != null,
969
+ // `nextStep: null` bei erreichbarem Host heißt „kein Schritt offen", NICHT
970
+ // „nicht messbar" — deshalb hängt `available` am Host, nicht am Schritt.
971
+ dimension: nextStep?.nextStep?.dimension ?? null,
972
+ deficit: nextStep?.nextStep?.deficit ?? null,
973
+ clears: nextStep?.nextStep?.clears ?? [],
974
+ action: nextStep?.nextStep?.action ?? null,
975
+ blockingErrors: nextStep?.blocking?.errors ?? null,
976
+ },
977
+ };
249
978
  }
250
979
 
251
980
  function dashboardApiPlugin() {
252
981
  const cwd = resolveRepoRoot();
253
982
  const GRAPH_DIR = join(cwd, 'docs', 'graph');
254
983
  const VIEWS_DIR = join(cwd, 'docs', 'views');
255
- const engine = new DefaultRuleEngine(SE_DESCRIPTOR.version);
256
- engine.register(SE_DESCRIPTOR.rules ?? []);
984
+ // CR-GVE-257: die Trajektorie liegt im Ziel-Repo, nicht im Viewer-Repo —
985
+ // derselbe resolveRepoRoot()-Anker wie GRAPH_DIR/host.sock.
986
+ const TRAJECTORY_FILE = join(cwd, '.graphcode', 'trajectory.jsonl');
987
+ const engine = createRuleEngine();
988
+ // CR-GC-410: Ergebnis je Commit-Hash — Stände sind unveränderlich, der
989
+ // zweite /api/flightrecorder-Aufruf misst nur neu Committetes nach.
990
+ const historyCache = new Map();
257
991
 
258
992
  function findGraphFile() {
259
993
  if (!existsSync(GRAPH_DIR)) return null;
@@ -315,22 +1049,104 @@ function dashboardApiPlugin() {
315
1049
  });
316
1050
  }
317
1051
 
318
- function buildDashboard() {
1052
+ async function buildDashboard() {
319
1053
  const file = findGraphFile();
320
1054
  if (!file) return { member: null, repoRoot: servedRepoRoot(), empty: true, error: 'no docs/graph/*.graph.json found', computedAt: new Date().toISOString() };
321
1055
  const graph = loadGraph(file);
322
- const violations = engine.evaluate(graph);
323
- const report = computeReadiness(violations, graph);
324
- return {
1056
+ // CR-GC-402 (Option C): die Readiness kommt VOM HOST — dieselbe Rechnung, die
1057
+ // der Chat zeigt, statt einer zweiten daneben. Nur ohne erreichbaren Host
1058
+ // rechnet der Viewer selbst, dann aber MIT der Policy des Repos und mit einem
1059
+ // sichtbaren Hinweis im Payload (`readinessSource`), den das Dashboard als
1060
+ // Banner zeigt. Zwei Wege, keiner davon still.
1061
+ // CR-GC-433: readiness UND next_step in EINER Welle. Beide sind reine
1062
+ // Lesezugriffe auf denselben Graphen und stören einander nicht; nacheinander
1063
+ // gerufen kostete der dritte Host-Roundtrip so viel, dass die Dashboard-Tests
1064
+ // in ihr 5-s-Fenster liefen (gemessen 4,1 s → 6 s). graph_suggest bleibt
1065
+ // BEWUSST danach und allein: es fährt je Kandidat einen dryRun und stellt die
1066
+ // In-Memory-Kopie danach wieder her — parallel dazu zu lesen hieße, den
1067
+ // Zwischenstand einer Sonde zu messen.
1068
+ // CR-GVE-272: graph_metrics reist in DERSELBEN Welle mit — auch ein reiner
1069
+ // Lesezugriff auf denselben Graphen, also derselbe Grund wie bei next_step.
1070
+ const [host, nextStep, hostMetrics] = await Promise.all([
1071
+ fetchHostReadiness(cwd),
1072
+ fetchNextStep(cwd),
1073
+ fetchHostMetrics(cwd),
1074
+ ]);
1075
+ const localPolicy = host.report ? null : readMetricPolicy(cwd);
1076
+ const report = host.report ?? computeReadiness(engine.evaluate(graph, localPolicy.policy), graph);
1077
+ // EINE Liste für Empfehlungen, Blocker-Gruppen und Architektur-Befunde — die
1078
+ // des Reports, egal aus welcher der beiden Quellen er stammt.
1079
+ const violations = report.violations ?? [];
1080
+ // CR-GVE-262: graph_suggest liefert das Anwendbarkeits-Urteil — nur mit
1081
+ // laufendem Host; ohne bleibt suggest null und die Karte sagt es.
1082
+ const suggest = await fetchSuggest(cwd);
1083
+ const payload = {
325
1084
  member: basename(file).replace(/\.graph\.json$/, ''),
326
1085
  repoRoot: servedRepoRoot(),
327
1086
  empty: graph.nodes.length === 0,
328
- readiness: readinessPanel(report),
1087
+ // CR-GC-402: woher die Zahlen stammen, reist MIT den Zahlen.
1088
+ readinessSource: host.report
1089
+ ? { origin: 'host', reason: null, policySource: null }
1090
+ : {
1091
+ origin: 'local',
1092
+ reason: host.error,
1093
+ policySource: localPolicy.source,
1094
+ // CR-GVE-280: `invalid` ohne Fundstelle waere eine Behauptung — der Grund reist mit,
1095
+ // an DERSELBEN Stelle wie die Quelle, damit ein Leser nicht zwei Felder korrelieren muss.
1096
+ ...(localPolicy.reason ? { policyError: localPolicy.reason } : {}),
1097
+ },
1098
+ readiness: readinessPayload(report),
329
1099
  recommendations: recommendationsPayload(violations, 50),
330
1100
  artifacts: scanArtifacts(file, graph),
331
1101
  health: synthHealth(graph),
1102
+ // CR-GVE-262: die zwei Architektur-Karten — Kohäsion aus der
1103
+ // graph_metrics-Referenzrechnung (contracts moduleMetrics, auf dem RAW
1104
+ // OntologyGraph der committeten Datei), Befunde aus demselben
1105
+ // Engine-Lauf wie rules_evaluate, applicable/Zielmarken aus
1106
+ // graph_suggest + Zielprofil.
1107
+ // CR-GVE-272: Modulzahlen, Schwellen und Zielmarke kommen aus EINER
1108
+ // Host-Antwort (graph_metrics) statt aus drei lokalen Pfaden; `origin`
1109
+ // sagt an, wenn stattdessen der Fallback gerechnet hat. `findings` bleibt,
1110
+ // was es war — ein Filter über die vorhandenen Violations, keine Rechnung.
1111
+ architecture: {
1112
+ ...architecturePayload({
1113
+ hostMetrics: hostMetrics.metrics,
1114
+ hostError: hostMetrics.error,
1115
+ ontologyJson: hostMetrics.metrics ? null : JSON.parse(readFileSync(file, 'utf8')),
1116
+ repoRoot: cwd,
1117
+ }),
1118
+ findings: architectureFindings(violations, suggest),
1119
+ suggestAvailable: suggest != null,
1120
+ },
1121
+ // CR-GC-433 (Trigger-Art 2+3): die permanente Zeile „Der nächste Zug" —
1122
+ // Architektur (graph_suggest Top-1) und Hygiene (graph_next_step)
1123
+ // nebeneinander, unabhängig davon, ob irgendein Gate-Endzustand erreicht ist.
1124
+ nextMove: nextMovePayload(nextStep, suggest),
1125
+ // CR-GVE-257: Repo ohne Trajektorie → null (Karte zeigt den Hinweis).
1126
+ autopilot: existsSync(TRAJECTORY_FILE) ? autopilotStats(readFileSync(TRAJECTORY_FILE, 'utf8')) : null,
332
1127
  computedAt: new Date().toISOString(),
333
1128
  };
1129
+ // CR-GVE-269: Erklärungen für GENAU die Regeln, die diese Antwort nennt —
1130
+ // nach dem Zusammenbau, damit keine Liste doppelt gepflegt wird.
1131
+ //
1132
+ // Nur fragen, wenn überhaupt ein Host geantwortet hat: ohne ihn kostet jeder
1133
+ // Aufruf einen Connect-Timeout, und die Antwort stünde ohnehin fest (leer).
1134
+ // Ohne diese Klammer lief die /api/dashboard-Antwort in einem Repo ohne Host
1135
+ // über ihr 5-s-Fenster (gemessen 4,0 s → 5,3 s).
1136
+ const hostAnswered = host.report != null || suggest != null || nextStep != null;
1137
+ payload.ruleHelp = !hostAnswered ? {} : await fetchRuleHelp(cwd, [
1138
+ ...(payload.recommendations?.groups ?? []).map((g) => g.ruleId),
1139
+ ...[...(payload.readiness?.phaseGates ?? []), ...(payload.readiness?.implGates ?? [])]
1140
+ .flatMap((g) => (g.blockerGroups ?? []).map((grp) => grp.ruleId)),
1141
+ ...(payload.readiness?.blockerRollup ?? []).map((g) => g.ruleId),
1142
+ ...payload.architecture.findings.map((f) => f.ruleId),
1143
+ payload.nextMove.architecture.top?.ruleId,
1144
+ // CR-GVE-273: die Kennzahl-Namen, die Karte 2 zeigt — dieselbe Quelle wie
1145
+ // die Regeltexte (CR-GC-458). Gefragt wird nach dem, was gezeichnet wird:
1146
+ // ohne Ist-Vektor gibt es die Karte nicht und damit auch nichts zu erklären.
1147
+ ...Object.keys(payload.architecture.fit?.metrics ?? {}),
1148
+ ]);
1149
+ return payload;
334
1150
  }
335
1151
 
336
1152
  /**
@@ -364,9 +1180,36 @@ function dashboardApiPlugin() {
364
1180
  res.end(markdown);
365
1181
  return;
366
1182
  }
1183
+ // CR-GC-410: eigener Endpoint statt Huckepack auf /api/dashboard — die
1184
+ // kalte History-Messung kostet Sekunden (78 Stände ≈ 3 s) und darf den
1185
+ // Rest des Dashboards nicht aufhalten; die Karte holt sie separat.
1186
+ if (url.pathname === '/api/flightrecorder') {
1187
+ // CR-GVE-272: gegen DIESELBEN Schwellen messen, die die Karte daneben zeigt.
1188
+ // Der Endpoint läuft eigenständig (CR-GC-410), also fragt er den Host selbst;
1189
+ // antwortet der nicht, fällt measureGraphHistory auf den Datei-Read zurück —
1190
+ // derselbe Fallback wie die Karte. `.then` statt async-Middleware: dasselbe
1191
+ // Muster wie /api/dashboard eine Zeile weiter unten.
1192
+ fetchHostMetrics(cwd).then((hm) => {
1193
+ let payload = null;
1194
+ try {
1195
+ const policy = hm.metrics
1196
+ ? { policy: hm.metrics.policy, source: hm.metrics.policySource ?? 'host' }
1197
+ : undefined;
1198
+ payload = flightRecorderPayload(measureGraphHistory(cwd, { cache: historyCache, policy }));
1199
+ } catch {
1200
+ payload = null; // Karte zeigt den Hinweis — kein 500 für eine Zusatzsicht
1201
+ }
1202
+ res.setHeader('Content-Type', 'application/json');
1203
+ res.end(JSON.stringify(payload));
1204
+ });
1205
+ return;
1206
+ }
367
1207
  if (url.pathname !== '/api/dashboard') return next();
368
- res.setHeader('Content-Type', 'application/json');
369
- res.end(JSON.stringify(buildDashboard()));
1208
+ // CR-GVE-262: buildDashboard ist async (graph_suggest über host.sock).
1209
+ buildDashboard().then((payload) => {
1210
+ res.setHeader('Content-Type', 'application/json');
1211
+ res.end(JSON.stringify(payload));
1212
+ });
370
1213
  };
371
1214
 
372
1215
  return {