@sigloch/graph-view-edit 0.7.2 → 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/dist/assets/index-BtUB8acj.css +1 -0
- package/dist/assets/index-CxkiGq8C.js +246 -0
- package/dist/index.html +2 -2
- package/package.json +5 -4
- package/vite.config.js +780 -9
- package/dist/assets/index-Cu_Z807i.css +0 -1
- package/dist/assets/index-Dbtyb0kh.js +0 -244
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
|
-
|
|
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 =
|
|
326
|
-
|
|
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
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
-
|
|
441
|
-
|
|
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 {
|