@sigloch/graph-view-edit 0.7.2 → 0.9.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
7
  import { DefaultRuleEngine, SE_DESCRIPTOR, fromOntologyGraph } from '@sigloch/graph-api-core';
7
- import { ONTOLOGY_VERSION, RULES_VERSION } from '@sigloch/contracts/se';
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
@@ -125,12 +140,22 @@ function graphStaticPlugin() {
125
140
  * (a foreign repo without its own gve config.json), the first
126
141
  * docs/graph/*.graph.json wins — the same discovery the dashboard uses, so
127
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.
128
152
  */
129
153
  function configApiPlugin() {
130
154
  const middleware = (req, res, next) => {
131
155
  if (req.url !== '/api/config') return next();
132
156
  const repoRoot = resolveRepoRoot();
133
157
  const config = structuredClone(APP_CONFIG);
158
+ config.repoRoot = servedRepoRoot();
134
159
  if (!existsSync(join(repoRoot, config.graph.path))) {
135
160
  const graphDir = join(repoRoot, 'docs', 'graph');
136
161
  const hit = existsSync(graphDir)
@@ -315,6 +340,643 @@ export function autopilotStats(jsonlText) {
315
340
  };
316
341
  }
317
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.
348
+ *
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 absteigend — die
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.
366
+ *
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;
541
+ }
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;
546
+ }
547
+ return out.replace(/,(\s*[}\]])/g, '$1');
548
+ }
549
+
550
+ /**
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.
560
+ */
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
+ };
978
+ }
979
+
318
980
  function dashboardApiPlugin() {
319
981
  const cwd = resolveRepoRoot();
320
982
  const GRAPH_DIR = join(cwd, 'docs', 'graph');
@@ -322,8 +984,10 @@ function dashboardApiPlugin() {
322
984
  // CR-GVE-257: die Trajektorie liegt im Ziel-Repo, nicht im Viewer-Repo —
323
985
  // derselbe resolveRepoRoot()-Anker wie GRAPH_DIR/host.sock.
324
986
  const TRAJECTORY_FILE = join(cwd, '.graphcode', 'trajectory.jsonl');
325
- const engine = new DefaultRuleEngine(SE_DESCRIPTOR.version);
326
- engine.register(SE_DESCRIPTOR.rules ?? []);
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();
327
991
 
328
992
  function findGraphFile() {
329
993
  if (!existsSync(GRAPH_DIR)) return null;
@@ -385,24 +1049,104 @@ function dashboardApiPlugin() {
385
1049
  });
386
1050
  }
387
1051
 
388
- function buildDashboard() {
1052
+ async function buildDashboard() {
389
1053
  const file = findGraphFile();
390
1054
  if (!file) return { member: null, repoRoot: servedRepoRoot(), empty: true, error: 'no docs/graph/*.graph.json found', computedAt: new Date().toISOString() };
391
1055
  const graph = loadGraph(file);
392
- const violations = engine.evaluate(graph);
393
- const report = computeReadiness(violations, graph);
394
- 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 = {
395
1084
  member: basename(file).replace(/\.graph\.json$/, ''),
396
1085
  repoRoot: servedRepoRoot(),
397
1086
  empty: graph.nodes.length === 0,
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
+ },
398
1098
  readiness: readinessPayload(report),
399
1099
  recommendations: recommendationsPayload(violations, 50),
400
1100
  artifacts: scanArtifacts(file, graph),
401
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),
402
1125
  // CR-GVE-257: Repo ohne Trajektorie → null (Karte zeigt den Hinweis).
403
1126
  autopilot: existsSync(TRAJECTORY_FILE) ? autopilotStats(readFileSync(TRAJECTORY_FILE, 'utf8')) : null,
404
1127
  computedAt: new Date().toISOString(),
405
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;
406
1150
  }
407
1151
 
408
1152
  /**
@@ -436,9 +1180,36 @@ function dashboardApiPlugin() {
436
1180
  res.end(markdown);
437
1181
  return;
438
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
+ }
439
1207
  if (url.pathname !== '/api/dashboard') return next();
440
- res.setHeader('Content-Type', 'application/json');
441
- 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
+ });
442
1213
  };
443
1214
 
444
1215
  return {