@sigloch/contracts 10.4.0 → 10.6.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.
@@ -1,6 +1,6 @@
1
1
  import { decomposedFuncs } from './rules.js';
2
2
  import { indexOf } from './graph-index.js';
3
- import { moduleCrossings } from './module-crossings.js';
3
+ import { moduleCrossings, subtreeFuncs, funcSubtree, boxContracts } from './module-crossings.js';
4
4
  /** Geteilte leere Menge — spart je Modul zwei Allokationen in `measureModulesUncached`. */
5
5
  const EMPTY_SET = new Set();
6
6
  /**
@@ -53,16 +53,13 @@ export function mt02Lcom4(graph, policy) {
53
53
  return violations;
54
54
  // CR-SM-232: die Union-Find-Rechnung steht in `measureModules`; hier nur die Stufen.
55
55
  for (const m of measureModules(graph)) {
56
+ // CR-SM-326: `null` deckt jetzt beide Faelle, in denen nichts zu urteilen ist — unter zwei
57
+ // Boxen, und ohne einen einzigen Vertrag im ganzen Modul. Die Ausnahme aus CR-SM-263
58
+ // (reiner Container-MOD) ist damit erledigt: seit die Box ihren Teilbaum erbt, traegt auch
59
+ // ein Rollup Vertraege, und die Regel ist dort erfuellbar statt still.
56
60
  if (m.lcom4 === null)
57
61
  continue;
58
- // CR-SM-263: ein MOD aus lauter zerlegten Bloecken ist per Konstruktion zerfallen —
59
- // LCOM4 verbindet ueber geteilte io-ZIELE, und CR-SM-256 hat festgehalten, dass
60
- // ein Rollup genau die nicht traegt. Die Regel war dort nicht streng, sondern
61
- // unerfuellbar: gruen wird sie erst mit der Kante, die CR-SM-256 fuer falsch erklaert.
62
- // Die MESSUNG bleibt (`moduleMetrics` liefert `lcom4` unveraendert) — nur das Urteil faellt.
63
- if (m.rollupContainer)
64
- continue;
65
- const message = `${m.moduleName} has LCOM4=${m.lcom4} (${m.allocatedFuncs} FUNCs in ${m.lcom4} disconnected groups)`;
62
+ const message = `${m.moduleName} has LCOM4=${m.lcom4} (${m.lcom4Boxes} boxes in ${m.lcom4} disconnected groups)`;
66
63
  // CR-SM-288: EIN Budget fuer beide Stufen — `steps.warning - 1` ist der groesste LCOM4,
67
64
  // der noch drin liegt. Die `info`-Stufe meldet frueher, aber sie meldet keine Ueberschreitung;
68
65
  // ihre Masse ist 0.
@@ -76,6 +73,65 @@ export function mt02Lcom4(graph, policy) {
76
73
  }
77
74
  return violations;
78
75
  }
76
+ /**
77
+ * MT-04: LCOM4 der FUNC-WHITEBOX (CR-SM-327).
78
+ *
79
+ * Dasselbe Prinzip wie MT-02, im anderen Baum: die Kinder eines zerlegten FUNC sind die Boxen
80
+ * seiner Ebene, jede mit den Vertraegen ihres Teilbaums, verbunden ueber den geteilten Vertrag.
81
+ * BW-02 misst am selben Element den RAND (CR-SM-283) — wie breit die Whitebox nach aussen ist;
82
+ * hier steht, ob sie innen zusammenhaengt. Zwei Aussagen, zwei Fixes: BW-02 heisst „schneide
83
+ * die Schnittstelle", MT-04 heisst „das sind zwei Bloecke".
84
+ *
85
+ * EIGENE REGEL statt `domain: ['MOD','FUNC']` an MT-02, und das ist keine Geschmacksfrage: die
86
+ * Grundgesamtheit einer Regel geht in den NENNER ihrer Readiness-Dimension (CR-SM-235/239).
87
+ * MT-02 liegt in `alloc` (Kern MOD); haengte man FUNC an sie, waechse `alloc.applicable` um
88
+ * jede FUNC des Graphen — gemessen ueber sieben Familiengraphen steigt der alloc-Score dadurch
89
+ * um bis zu 0,027 (graphcode 0,932 -> 0,955), ohne dass sich am Graphen etwas verbessert haette.
90
+ * Genau der Nenner-Defekt der Klasse CR-SM-235/270. Als eigene Regel in `arch` (Kern FUNC) ist
91
+ * die Bewegung <= 0,008, und die Befunde landen dort, wo FUNC der Gegenstand ist.
92
+ * Praezedenz fuer den Schnitt: R-20/R-26/R-27 — dieselbe Aussage („braucht einen realRef"),
93
+ * drei Populationen, drei IDs.
94
+ *
95
+ * Die Schwellen sind dieselben (`policy.lcom4`): dieselbe Masszahl, dieselbe Skala. Ein eigenes
96
+ * Policy-Feld waere eine zweite Schwelle fuer dieselbe Frage.
97
+ */
98
+ export function mt04WhiteboxLcom4(graph, policy) {
99
+ const violations = [];
100
+ const steps = policy.lcom4;
101
+ if (steps === null)
102
+ return violations;
103
+ const idx = indexOf(graph);
104
+ const decomposed = decomposedFuncs(graph);
105
+ // In GRAPH-Reihenfolge, nicht in der Reihenfolge der `compose`-Kanten: der Anker eines
106
+ // Befundes darf nicht an der Kantenreihenfolge haengen (Gate 5).
107
+ for (const fn of idx.elementsOfType('FUNC')) {
108
+ if (!decomposed.has(fn.id))
109
+ continue;
110
+ const kids = idx.out(fn.id, 'compose')
111
+ .filter(t => idx.typeOf(t.target) === 'FUNC')
112
+ .map(t => t.target);
113
+ const boxes = kids.map(kid => boxContracts(graph, funcSubtree(graph, kid)));
114
+ const lcom4 = lcom4OfBoxes(boxes);
115
+ if (lcom4 === null || lcom4 < steps.info)
116
+ continue;
117
+ const message = `${fn.name} has LCOM4=${lcom4} (${boxes.length} boxes in ${lcom4} disconnected groups)`;
118
+ const context = {
119
+ element_type: fn.type,
120
+ element_name: fn.name,
121
+ value: lcom4,
122
+ threshold: steps.warning - 1,
123
+ };
124
+ violations.push({
125
+ rule_id: 'MT-04',
126
+ severity: lcom4 >= steps.warning ? 'warning' : 'info',
127
+ element_id: fn.id,
128
+ message,
129
+ fix_hint: 'Split the whitebox along its groups, or wire the groups together where they really share data — a block whose children share no contract is two blocks',
130
+ context,
131
+ });
132
+ }
133
+ return violations;
134
+ }
79
135
  /**
80
136
  * CR-SM-221: connection pairs between non-FLOW elements, FLOW-transitive.
81
137
  *
@@ -196,6 +252,7 @@ function measureModulesUncached(graph) {
196
252
  for (const mod of mods) {
197
253
  const allocated = idx.in(mod.id, 'allocate').map(t => t.source);
198
254
  const modFuncIds = new Set(allocated);
255
+ const boxes = boxesOfModule(graph, mod.id, allocated);
199
256
  // --- MT-01: fan-in / fan-out als querende VERTRAEGE je Richtung (CR-SM-293) ---
200
257
  const fanIn = (crossings.afferentContracts.get(mod.id) ?? EMPTY_SET).size;
201
258
  const fanOut = (crossings.efferentContracts.get(mod.id) ?? EMPTY_SET).size;
@@ -207,7 +264,8 @@ function measureModulesUncached(graph) {
207
264
  fanIn,
208
265
  fanOut,
209
266
  instability,
210
- lcom4: lcom4Of(graph, allocated),
267
+ lcom4: lcom4OfBoxes(boxes),
268
+ lcom4Boxes: boxes.length,
211
269
  rollupContainer: allocated.length >= 2 && allocated.every(f => decomposed.has(f)),
212
270
  cohesion: cohesionOf(pairs, modFuncIds),
213
271
  // CR-SM-301: braucht die Instabilitaet ALLER Module, also zweiter Durchgang unten.
@@ -256,88 +314,82 @@ function measureModulesUncached(graph) {
256
314
  }
257
315
  return rows;
258
316
  }
259
- /** MT-02 core: connected components over the allocated FUNCs. null below 2 FUNCs. */
260
- function lcom4Of(graph, funcIds) {
261
- if (funcIds.length < 2)
262
- return null;
263
- // Zwei FUNCs sind verbunden, wenn sie ein gemeinsames `io`-Ziel haben und NUR dann.
264
- //
265
- // CR-SM-297: `satisfy` ist aus der Verbindung gefallen. LCOM4 misst Datenkopplung; `satisfy`
266
- // ist eine SPEZIFIKATIONS-Beziehung, und zwei FUNCs, die dasselbe REQ erfuellen, koennen zur
267
- // Laufzeit vollstaendig entkoppelt sein. Die Wirkung ging dabei nur in eine Richtung:
268
- // `satisfy` VERBINDET Gruppen, senkte also LCOM4 und liess Module kohaesiver aussehen, als
269
- // ihr Datenfluss hergibt.
270
- //
271
- // Der Modellierungsgrund wiegt schwerer als der Messgrund: teilen sich zwei Funktionen ein
272
- // Requirement, ist das REQUIREMENT zu zerlegen — nicht die Kohaesionsmessung zu beschoenigen.
273
- // RD-02 sagt bereits das Verwandte (ein Parent-REQ mit direktem FUNC-satisfy; der satisfy
274
- // gehoert an die Kinder).
275
- //
276
- // CR-SM-264: die ausgehenden Kanten kommen aus dem Index. Vorher lief je alloziertem FUNC
277
- // ein Vollscan ueber alle Traces — MOD x FUNC x T.
317
+ /**
318
+ * CR-SM-326 — die BOXEN einer Modul-Whitebox: ihre Sub-MODs plus ihre direkt allozierten FUNC.
319
+ *
320
+ * Die Blackbox-Sicht verlangt genau das: ein zerlegtes Element steht nicht NEBEN seinen Kindern,
321
+ * es IST sie, von aussen gesehen und es traegt selbst keine Kante (CR-SM-256). Wer es als
322
+ * gleichrangigen Nachbarn mit leerer Kantenmenge zaehlt, misst die Zerlegungstiefe statt der
323
+ * Kohaesion: je besser zerlegt, desto schlechter die Zahl. Dieselbe Besitzkette benutzen R-04
324
+ * (CR-SM-282), BW-02 (CR-SM-283) und R-23 (CR-SM-321).
325
+ *
326
+ * Ein Sub-MOD bringt die FUNCs seines ganzen Modul-Teilbaums mit (`subtreeFuncs`), jede davon
327
+ * mit ihrem eigenen compose-Teilbaum — sonst fehlten die Blaetter, die gar nicht eigens
328
+ * alloziert sind.
329
+ */
330
+ function boxesOfModule(graph, modId, allocated) {
278
331
  const idx = indexOf(graph);
279
- const funcTargets = new Map();
280
- for (const fid of funcIds) {
281
- const targets = new Set();
282
- for (const t of idx.out(fid, 'io'))
283
- targets.add(t.target);
284
- funcTargets.set(fid, targets);
332
+ const boxes = [];
333
+ for (const t of idx.out(modId, 'compose')) {
334
+ if (idx.typeOf(t.target) !== 'MOD')
335
+ continue;
336
+ const funcs = new Set();
337
+ for (const f of subtreeFuncs(graph, t.target))
338
+ for (const x of funcSubtree(graph, f))
339
+ funcs.add(x);
340
+ boxes.push(boxContracts(graph, funcs));
285
341
  }
286
- const parent = new Map();
287
- for (const fid of funcIds)
288
- parent.set(fid, fid);
289
- function find(x) {
290
- while (parent.get(x) !== x) {
291
- x = parent.get(x);
342
+ for (const func of allocated)
343
+ boxes.push(boxContracts(graph, funcSubtree(graph, func)));
344
+ return boxes;
345
+ }
346
+ /**
347
+ * MT-02 core: die Zahl der Komponenten ueber den Boxen. Verbunden sind zwei Boxen, wenn sie
348
+ * einen VERTRAG teilen — dieselbe Zaehlbasis, mit der CR-01, R-04, BW-02 und MT-01 seit
349
+ * CR-SM-274/276/293 Kopplung zaehlen. Vorher lief die Verbindung ueber FLOW-Identitaet, also
350
+ * ueber einen zweiten Kopplungsbegriff im selben Katalog.
351
+ *
352
+ * CR-SM-297 bleibt in Kraft: `satisfy` verbindet nicht. LCOM4 misst Datenkopplung; teilen sich
353
+ * zwei Funktionen ein Requirement, gehoert das REQUIREMENT zerlegt.
354
+ *
355
+ * `null` heisst messen, nicht urteilen (CR-SM-233) — hier zweimal: unter zwei Boxen gibt es
356
+ * keine Zerfallenheit, und ohne einen einzigen Vertrag gibt es keine Kopplung, ueber die man
357
+ * urteilen koennte. Der zweite Fall gehoert R-31 (`FUNC must be wired`), das ihn am Element
358
+ * meldet; eine Zahl hier waere dieselbe Aussage ein zweites Mal.
359
+ */
360
+ function lcom4OfBoxes(boxes) {
361
+ if (boxes.length < 2)
362
+ return null;
363
+ if (boxes.every(b => b.size === 0))
364
+ return null;
365
+ const parent = boxes.map((_, i) => i);
366
+ const find = (x) => {
367
+ let cur = x;
368
+ while (parent[cur] !== cur) {
369
+ parent[cur] = parent[parent[cur]];
370
+ cur = parent[cur];
292
371
  }
293
- return x;
294
- }
295
- function union(a, b) {
372
+ return cur;
373
+ };
374
+ const union = (a, b) => {
296
375
  const ra = find(a), rb = find(b);
297
376
  if (ra !== rb)
298
- parent.set(ra, rb);
299
- }
300
- for (let i = 0; i < funcIds.length; i++) {
301
- for (let j = i + 1; j < funcIds.length; j++) {
302
- const ti = funcTargets.get(funcIds[i]);
303
- const tj = funcTargets.get(funcIds[j]);
304
- for (const t of ti) {
305
- if (tj.has(t)) {
306
- union(funcIds[i], funcIds[j]);
307
- break;
308
- }
309
- }
310
- }
311
- }
312
- // CR-165/CR-171: FUNCs sharing the same FLOW (via io in either direction) are connected.
313
- const funcIdSet = new Set(funcIds);
314
- const flowToFuncs = new Map();
315
- for (const t of graph.traces) {
316
- if (t.type !== 'io')
317
- continue;
318
- if (funcIdSet.has(t.target)) {
319
- const srcEl = graph.elements.find(e => e.id === t.source);
320
- if (srcEl?.type === 'FLOW') {
321
- if (!flowToFuncs.has(t.source))
322
- flowToFuncs.set(t.source, new Set());
323
- flowToFuncs.get(t.source).add(t.target);
324
- }
325
- }
326
- if (funcIdSet.has(t.source)) {
327
- const tgtEl = graph.elements.find(e => e.id === t.target);
328
- if (tgtEl?.type === 'FLOW') {
329
- if (!flowToFuncs.has(t.target))
330
- flowToFuncs.set(t.target, new Set());
331
- flowToFuncs.get(t.target).add(t.source);
332
- }
377
+ parent[ra] = rb;
378
+ };
379
+ // Ein Durchgang ueber alle Vertraege statt des Paarvergleichs: der erste Traeger eines
380
+ // Vertrags zieht jeden weiteren an sich. Linear in der Summe der Boxgroessen — und das
381
+ // Ergebnis haengt nicht an der Reihenfolge, weil die Komponentenzahl es nicht tut.
382
+ const firstHolder = new Map();
383
+ for (let i = 0; i < boxes.length; i++) {
384
+ for (const contract of boxes[i]) {
385
+ const first = firstHolder.get(contract);
386
+ if (first === undefined)
387
+ firstHolder.set(contract, i);
388
+ else
389
+ union(first, i);
333
390
  }
334
391
  }
335
- for (const funcsInFlow of flowToFuncs.values()) {
336
- const arr = [...funcsInFlow];
337
- for (let i = 1; i < arr.length; i++)
338
- union(arr[0], arr[i]);
339
- }
340
- return new Set(funcIds.map(find)).size;
392
+ return new Set(boxes.map((_, i) => find(i))).size;
341
393
  }
342
394
  /** CR-SM-223 core: internal vs. external connection pairs. null where there is no signal. */
343
395
  function cohesionOf(pairs, funcIds) {
@@ -400,6 +452,9 @@ export function allocationCohesion(graph) {
400
452
  export const MT_RULES = [
401
453
  { id: 'MT-01', name: 'Module instability', severity: 'warning', evaluate: mt01Instability, domain: ['MOD'] },
402
454
  { id: 'MT-02', name: 'Module cohesion (LCOM4)', severity: 'info', evaluate: mt02Lcom4, domain: ['MOD'] },
455
+ // CR-SM-327: dieselbe Masszahl an der FUNC-Whitebox. Eigene ID, weil die Grundgesamtheit
456
+ // die Readiness-Dimension bestimmt (Begruendung an `mt04WhiteboxLcom4`).
457
+ { id: 'MT-04', name: 'Whitebox cohesion (LCOM4)', severity: 'info', evaluate: mt04WhiteboxLcom4, domain: ['FUNC'] },
403
458
  // MT-03 retired as a rule (CR-SM-223) — see `allocationCohesion` above.
404
459
  ];
405
460
  export function evaluateMTRules(graph, policy) {
@@ -100,3 +100,20 @@ export declare function crossingContractCount(graph: OntologyGraph, modId: strin
100
100
  export declare function whiteboxContractCount(graph: OntologyGraph, funcId: string): number;
101
101
  /** Die FUNCs im Teilbaum dieses Moduls (direkt alloziert + die seiner Sub-MODs) — CR-SM-282. */
102
102
  export declare function subtreeFuncs(graph: OntologyGraph, modId: string): ReadonlySet<string>;
103
+ /**
104
+ * CR-SM-326: `funcId` plus seine `compose`-Nachfahren im FUNC-Baum — der TEILBAUM, an dessen
105
+ * Blaettern die io-Kanten liegen (CR-SM-256). Fuer ein Blatt die Einermenge, also derselbe
106
+ * Aufruf fuer Box und Blatt statt einer Fallunterscheidung beim Aufrufer.
107
+ */
108
+ export declare function funcSubtree(graph: OntologyGraph, funcId: string): ReadonlySet<string>;
109
+ /**
110
+ * CR-SM-326: die Vertraege, die eine FUNC-Menge BERUEHRT — dieselbe Zaehlbasis wie
111
+ * `byModule`/`byFunc` (SCHEMA, sonst `UNBOUND:<flow>`), nur ohne die Rand-Frage.
112
+ *
113
+ * Warum ohne Rand: MT-02 fragt nicht, was die Box verlaesst, sondern ob zwei Boxen einen
114
+ * Vertrag TEILEN. Fuer diese Frage sind Rand- und Beruehrmenge dasselbe — ein Vertrag, den
115
+ * nur eine Box beruehrt, liegt in keiner zweiten und verbindet niemanden. Die Beruehrmenge
116
+ * ist die billigere von beiden und braucht die Whitebox-Bedingung aus `byFunc` nicht, die
117
+ * ein Blatt ausschliesst.
118
+ */
119
+ export declare function boxContracts(graph: OntologyGraph, funcIds: Iterable<string>): ReadonlySet<string>;
@@ -1,4 +1,16 @@
1
1
  import { indexOf } from './graph-index.js';
2
+ /**
3
+ * Die Vertraege EINES Flusses: seine SCHEMAs, sonst er selbst als untypisierter Vertrag.
4
+ *
5
+ * Eine Stelle fuer die Zaehlbasis aus CR-SM-274 — `build()` und `boxContracts()` lesen
6
+ * dieselbe Funktion, damit es nicht zwei Vorstellungen davon gibt, was ein Vertrag ist.
7
+ */
8
+ function contractsOfFlow(idx, flowId) {
9
+ const schemas = idx.out(flowId, 'relation')
10
+ .filter(t => idx.typeOf(t.target) === 'SCHEMA')
11
+ .map(t => t.target);
12
+ return schemas.length > 0 ? schemas : [`UNBOUND:${flowId}`];
13
+ }
2
14
  const CACHE = new WeakMap();
3
15
  function build(graph) {
4
16
  const idx = indexOf(graph);
@@ -85,10 +97,7 @@ function build(graph) {
85
97
  .filter(t => idx.typeOf(t.target) === 'FUNC')
86
98
  .map(t => modOfFunc.get(t.target))
87
99
  .filter((m) => !!m));
88
- const schemas = idx.out(flow.id, 'relation')
89
- .filter(t => idx.typeOf(t.target) === 'SCHEMA')
90
- .map(t => t.target);
91
- const contracts = schemas.length > 0 ? schemas : [`UNBOUND:${flow.id}`];
100
+ const contracts = contractsOfFlow(idx, flow.id);
92
101
  // CR-SM-283: derselbe Test auf dem compose-Baum. `allEndpoints` statt `endpoints` —
93
102
  // die FUNC-Whitebox kennt keine Modul-Zugehoerigkeit, eine unallozierte FUNC liegt
94
103
  // trotzdem drinnen oder draussen.
@@ -194,3 +203,48 @@ export function whiteboxContractCount(graph, funcId) {
194
203
  export function subtreeFuncs(graph, modId) {
195
204
  return moduleCrossings(graph).funcsByModule.get(modId) ?? new Set();
196
205
  }
206
+ /**
207
+ * CR-SM-326: `funcId` plus seine `compose`-Nachfahren im FUNC-Baum — der TEILBAUM, an dessen
208
+ * Blaettern die io-Kanten liegen (CR-SM-256). Fuer ein Blatt die Einermenge, also derselbe
209
+ * Aufruf fuer Box und Blatt statt einer Fallunterscheidung beim Aufrufer.
210
+ */
211
+ export function funcSubtree(graph, funcId) {
212
+ const idx = indexOf(graph);
213
+ const out = new Set();
214
+ const stack = [funcId];
215
+ while (stack.length > 0) {
216
+ const cur = stack.pop();
217
+ if (out.has(cur))
218
+ continue;
219
+ out.add(cur);
220
+ for (const t of idx.out(cur, 'compose')) {
221
+ if (idx.typeOf(t.target) === 'FUNC')
222
+ stack.push(t.target);
223
+ }
224
+ }
225
+ return out;
226
+ }
227
+ /**
228
+ * CR-SM-326: die Vertraege, die eine FUNC-Menge BERUEHRT — dieselbe Zaehlbasis wie
229
+ * `byModule`/`byFunc` (SCHEMA, sonst `UNBOUND:<flow>`), nur ohne die Rand-Frage.
230
+ *
231
+ * Warum ohne Rand: MT-02 fragt nicht, was die Box verlaesst, sondern ob zwei Boxen einen
232
+ * Vertrag TEILEN. Fuer diese Frage sind Rand- und Beruehrmenge dasselbe — ein Vertrag, den
233
+ * nur eine Box beruehrt, liegt in keiner zweiten und verbindet niemanden. Die Beruehrmenge
234
+ * ist die billigere von beiden und braucht die Whitebox-Bedingung aus `byFunc` nicht, die
235
+ * ein Blatt ausschliesst.
236
+ */
237
+ export function boxContracts(graph, funcIds) {
238
+ const idx = indexOf(graph);
239
+ const out = new Set();
240
+ for (const func of funcIds) {
241
+ const flows = [
242
+ ...idx.out(func, 'io').filter(t => idx.typeOf(t.target) === 'FLOW').map(t => t.target),
243
+ ...idx.in(func, 'io').filter(t => idx.typeOf(t.source) === 'FLOW').map(t => t.source),
244
+ ];
245
+ for (const flow of flows)
246
+ for (const c of contractsOfFlow(idx, flow))
247
+ out.add(c);
248
+ }
249
+ return out;
250
+ }
@@ -45,6 +45,51 @@ export type ReadinessScoreType = z.infer<typeof ReadinessScore>;
45
45
  * Ein interpretierbarer Ersatz (`1 − Σviolations / Σapplicable`) kommt, wenn er einen
46
46
  * Konsumenten hat — nicht auf Vorrat.
47
47
  */
48
+ /**
49
+ * CR-SM-337 (ITEM-2026-059) — der STEUERUNGSRAUM im Bericht, Stufe 1: der Vertrag.
50
+ *
51
+ * Readiness misst ABDECKUNG ("wie viele Stellen sind erledigt"), der Steuer-Score AUSPRAEGUNG
52
+ * ("wie schlimm ist die schlimmste offene"). Beide lesen denselben Regelstrom, und beide
53
+ * gehoeren in denselben Bericht — sonst rechnet der naechste Leser die zweite Haelfte selbst
54
+ * nach. Genau das drohte: das GVE-Dashboard (ITEM-2026-018) haette `steerScore` nachbauen
55
+ * muessen, weil die Zahlen bisher nur als `verdict.steer.improvement` je Suggestion sichtbar
56
+ * waren. Eine zweite Rechnung ist eine zweite Wahrheit.
57
+ *
58
+ * Die Form ist die von `SteerScore` in @sigloch/se-engine (CR-SM-292), ZEICHENGLEICH
59
+ * uebernommen — contracts darf se-engine nicht importieren (es ist die Basis), also steht hier
60
+ * der Vertrag und dort die Rechnung. Wer die Form aendert, aendert sie an beiden Stellen; der
61
+ * Vertragstest daneben haelt die Felder fest.
62
+ *
63
+ * `worst` = der schlimmste normierte Ueberschuss `(wert - budget) / budget`. Normiert wird
64
+ * gegen die REGELSCHWELLE selbst, nicht gegen eine eigene Zahl — deshalb gibt es hier keine
65
+ * freien Parameter (CR-SM-292: daran ist der Vorgaenger CR-SM-281 gestorben).
66
+ */
67
+ export declare const SteerTerm: z.ZodObject<{
68
+ ruleId: z.ZodString;
69
+ elementId: z.ZodString;
70
+ value: z.ZodNumber;
71
+ threshold: z.ZodNumber;
72
+ overshoot: z.ZodNumber;
73
+ }, z.core.$strip>;
74
+ export type SteerTermType = z.infer<typeof SteerTerm>;
75
+ export declare const SteerSpace: z.ZodObject<{
76
+ worst: z.ZodNumber;
77
+ worstAt: z.ZodNullable<z.ZodObject<{
78
+ ruleId: z.ZodString;
79
+ elementId: z.ZodString;
80
+ }, z.core.$strip>>;
81
+ mean: z.ZodNumber;
82
+ score: z.ZodNumber;
83
+ measured: z.ZodNumber;
84
+ terms: z.ZodArray<z.ZodObject<{
85
+ ruleId: z.ZodString;
86
+ elementId: z.ZodString;
87
+ value: z.ZodNumber;
88
+ threshold: z.ZodNumber;
89
+ overshoot: z.ZodNumber;
90
+ }, z.core.$strip>>;
91
+ }, z.core.$strip>;
92
+ export type SteerSpaceType = z.infer<typeof SteerSpace>;
48
93
  export declare const ReadinessReport: z.ZodObject<{
49
94
  scores: z.ZodArray<z.ZodObject<{
50
95
  dimension: z.ZodEnum<{
@@ -63,6 +108,23 @@ export declare const ReadinessReport: z.ZodObject<{
63
108
  coreApplicable: z.ZodNumber;
64
109
  }, z.core.$strip>>;
65
110
  timestamp: z.ZodISODateTime;
111
+ steer: z.ZodOptional<z.ZodObject<{
112
+ worst: z.ZodNumber;
113
+ worstAt: z.ZodNullable<z.ZodObject<{
114
+ ruleId: z.ZodString;
115
+ elementId: z.ZodString;
116
+ }, z.core.$strip>>;
117
+ mean: z.ZodNumber;
118
+ score: z.ZodNumber;
119
+ measured: z.ZodNumber;
120
+ terms: z.ZodArray<z.ZodObject<{
121
+ ruleId: z.ZodString;
122
+ elementId: z.ZodString;
123
+ value: z.ZodNumber;
124
+ threshold: z.ZodNumber;
125
+ overshoot: z.ZodNumber;
126
+ }, z.core.$strip>>;
127
+ }, z.core.$strip>>;
66
128
  }, z.core.$strip>;
67
129
  export type ReadinessReportType = z.infer<typeof ReadinessReport>;
68
130
  /**
@@ -57,9 +57,68 @@ export const ReadinessScore = z.object({
57
57
  * Ein interpretierbarer Ersatz (`1 − Σviolations / Σapplicable`) kommt, wenn er einen
58
58
  * Konsumenten hat — nicht auf Vorrat.
59
59
  */
60
+ /**
61
+ * CR-SM-337 (ITEM-2026-059) — der STEUERUNGSRAUM im Bericht, Stufe 1: der Vertrag.
62
+ *
63
+ * Readiness misst ABDECKUNG ("wie viele Stellen sind erledigt"), der Steuer-Score AUSPRAEGUNG
64
+ * ("wie schlimm ist die schlimmste offene"). Beide lesen denselben Regelstrom, und beide
65
+ * gehoeren in denselben Bericht — sonst rechnet der naechste Leser die zweite Haelfte selbst
66
+ * nach. Genau das drohte: das GVE-Dashboard (ITEM-2026-018) haette `steerScore` nachbauen
67
+ * muessen, weil die Zahlen bisher nur als `verdict.steer.improvement` je Suggestion sichtbar
68
+ * waren. Eine zweite Rechnung ist eine zweite Wahrheit.
69
+ *
70
+ * Die Form ist die von `SteerScore` in @sigloch/se-engine (CR-SM-292), ZEICHENGLEICH
71
+ * uebernommen — contracts darf se-engine nicht importieren (es ist die Basis), also steht hier
72
+ * der Vertrag und dort die Rechnung. Wer die Form aendert, aendert sie an beiden Stellen; der
73
+ * Vertragstest daneben haelt die Felder fest.
74
+ *
75
+ * `worst` = der schlimmste normierte Ueberschuss `(wert - budget) / budget`. Normiert wird
76
+ * gegen die REGELSCHWELLE selbst, nicht gegen eine eigene Zahl — deshalb gibt es hier keine
77
+ * freien Parameter (CR-SM-292: daran ist der Vorgaenger CR-SM-281 gestorben).
78
+ */
79
+ export const SteerTerm = z.object({
80
+ /** Eine der STEER_RULES — die messenden Regeln (se-engine `STEER_RULES`). */
81
+ ruleId: z.string(),
82
+ /** Die Blackbox, an der der Term haengt. */
83
+ elementId: z.string(),
84
+ /** Der gemessene Wert (`violation.context.value`). */
85
+ value: z.number(),
86
+ /** Das Budget, gegen das normiert wird (`violation.context.threshold`). */
87
+ threshold: z.number(),
88
+ /** `max(0, (value - threshold) / threshold)` — dimensionslos, damit vergleichbar. */
89
+ overshoot: z.number().min(0),
90
+ });
91
+ export const SteerSpace = z.object({
92
+ worst: z.number().min(0),
93
+ /**
94
+ * Wo der schlimmste Ueberschuss sitzt — die Begruendung, nicht nur die Zahl.
95
+ * `null`, wenn nichts ueberschreitet.
96
+ */
97
+ worstAt: z.object({ ruleId: z.string(), elementId: z.string() }).nullable(),
98
+ mean: z.number().min(0),
99
+ /** `worst + EPS_AUGMENT * mean`. **Kleiner ist besser**, 0 = alles im Budget. */
100
+ score: z.number().min(0),
101
+ /**
102
+ * Zahl der Blackboxes, die in die Rechnung eingegangen sind.
103
+ *
104
+ * DIE WICHTIGSTE ZAHL DES OBJEKTS, und zwar wegen `score: 0`: der steht sowohl fuer "alles
105
+ * innerhalb seiner Budgets" als auch fuer "es wurde nichts gemessen". `measured: 0`
106
+ * unterscheidet die beiden. Ohne sie waere eine stille Null nicht von einem guten Zustand zu
107
+ * trennen — dieselbe Konvention wie `policy.X = null` und `moduleMetrics.instability = null`.
108
+ */
109
+ measured: z.number().int().min(0),
110
+ /** Die Terme je Element, aus denen `worst` und `mean` entstehen. */
111
+ terms: z.array(SteerTerm),
112
+ });
60
113
  export const ReadinessReport = z.object({
61
114
  scores: z.array(ReadinessScore),
62
115
  timestamp: z.iso.datetime(),
116
+ /**
117
+ * CR-SM-337: OPTIONAL, damit ein aelterer Produzent gueltig bleibt — der Vertrag wandert vor
118
+ * dem Fueller (Stufe 2, graphcode CR-GC-537). Fehlt das Feld, heisst das "dieser Produzent
119
+ * kennt den Steuerungsraum noch nicht", und das ist etwas anderes als `measured: 0`.
120
+ */
121
+ steer: SteerSpace.optional(),
63
122
  });
64
123
  /**
65
124
  * CR-SM-305 — welche Profile in die readiness-Zahlen eingehen, und warum `conformance` nicht.
@@ -131,6 +190,10 @@ export const RULE_TO_DIMENSION = {
131
190
  // MT-03 is no longer here: it became a measurement (`allocationCohesion`), not a
132
191
  // rule (CR-SM-223) — a per-module advisory would depress this score permanently.
133
192
  'MT-01': 'alloc', 'MT-02': 'alloc',
193
+ // CR-SM-327: MT-04 misst die FUNC-Whitebox, nicht das Modul — Grundgesamtheit FUNC,
194
+ // also `arch` (Kern FUNC). In `alloc` waere sie eine Fremdtyp-Regel und wuerde deren
195
+ // Nenner um jede FUNC des Graphen aufblaehen (Klasse CR-SM-235/270, gemessen im CR).
196
+ 'MT-04': 'arch',
134
197
  // CR traceability
135
198
  'CR-R01': 'cr', 'CR-R02': 'cr', 'CR-R03': 'cr', // architecture optimization
136
199
  // CR-SM-283: BW-02 (Whitebox-Randbreite) ersetzt AO-D03 an dieser Stelle — dieselbe
@@ -199,7 +262,7 @@ export const RULE_TO_PHASE = {
199
262
  // CR-GC-366: Anschluss des Funktionsbaus — gehoert an dasselbe Gate wie R-15/IO-01,
200
263
  // die auf derselben Kette aufsetzen.
201
264
  'R-30': 'PDR', 'R-31': 'PDR',
202
- 'MT-01': 'PDR', 'MT-02': 'PDR',
265
+ 'MT-01': 'PDR', 'MT-02': 'PDR', 'MT-04': 'PDR', // CR-SM-327: dieselbe Reifephase wie MT-02
203
266
  'ND-01': 'PDR',
204
267
  'BW-02': 'PDR', 'CR-01': 'PDR', 'IO-01': 'PDR', 'IO-02': 'PDR', // CR-SM-226: extended to all FCHAIN FUNC-pairs, added to the phase axis.
205
268
  // CDR — critical design/schema completeness.
@@ -170,7 +170,11 @@ export const RULE_HELP = {
170
170
  },
171
171
  'MT-02': {
172
172
  plain: "The parts inside this module never talk to each other → it is really several modules in one.",
173
- se: "LCOM4: the allocated `FUNC`s fall into that many disconnected groups. Connected means shared DATA: a common outgoing `io` target, or touching the same `FLOW` in either direction. CR-SM-297 dropped `satisfy` from that union — two FUNCs meeting the same requirement can be fully decoupled at runtime, and if they share one, the REQUIREMENT is what needs decomposing. `info` from `metricPolicy.lcom4.info`, `warning` from `.warning` (CR-GC-329).",
173
+ se: "LCOM4 over the BOXES of this module whitebox: its sub-MODs (`MOD -compose-> MOD`) plus its directly allocated `FUNC`s. A box owns its whole subtree, so a decomposed block carries the contracts of its leaves — the same ownership chain R-04 (CR-SM-282), BW-02 (CR-SM-283) and R-23 (CR-SM-321) use; without it a container counted as an empty group of its own and the number grew with decomposition DEPTH (CR-SM-326). Connected means a shared CONTRACT (`FLOW -relation-> SCHEMA`, else the untyped flow) the same counting base as CR-01/R-04/MT-01 since CR-SM-274/276, not FLOW identity. CR-SM-297 dropped `satisfy`: two FUNCs meeting the same requirement can be fully decoupled at runtime, and if they share one, the REQUIREMENT is what needs decomposing. `null` (no judgement) below two boxes and where no box carries a contract at all — an unwired FUNC is R-31's statement, at the element. `info` from `metricPolicy.lcom4.info`, `warning` from `.warning` (CR-GC-329).",
174
+ },
175
+ 'MT-04': {
176
+ plain: "This block's parts never talk to each other → it is really two blocks that happen to sit under one name.",
177
+ se: "LCOM4 of a decomposed FUNC: its `compose` children are the boxes of that level, each owning its whole subtree, connected when they share a CONTRACT (`FLOW -relation-> SCHEMA`, else the untyped flow) — the same computation MT-02 runs on the module whitebox (CR-SM-326). BW-02 measures the OUTSIDE of the same element (how wide the interface is, CR-SM-283); this one says whether the inside hangs together. `null` (no judgement) below two boxes and where no box carries a contract. Thresholds shared with MT-02 (`metricPolicy.lcom4`) — same measure, same scale.",
174
178
  },
175
179
  'ND-01': {
176
180
  plain: "Two functions look like the same function written twice → merge them, or make clear what each one does differently.",
@@ -294,6 +298,10 @@ export const RULE_HELP = {
294
298
  plain: "This element points at code in a package your project does not actually install → either add the package, or point at the one that owns the symbol now.",
295
299
  se: "An `external: true` `realRef` names a package that is in neither `dependencies` nor `devDependencies` of the consumer. Absent dependency data is SILENCE, not a violation — the extractor never looked, and treating that as \"declares nothing\" would report every external binding at once (CR-SM-262).",
296
300
  },
301
+ 'RC-07': {
302
+ plain: "A change request is marked done in the model but still open in the folder (or the other way round), or an open change request has no entry in the model → the folder decides; fix the model.",
303
+ se: "`CR` node whose `status` (closed = done/dropped/rejected) contradicts `docs/cr/open|done/`, or an OPEN CR file with no `CR` node (anchored at the first `SYS`). Nodes without a file are not reported — history. Absent `crFiles` is SILENCE (CR-SM-329).",
304
+ },
297
305
  'RD-01': {
298
306
  plain: "A smallest-piece feature has nothing built to fulfil it → add what implements it.",
299
307
  se: "Leaf `REQ` (no `compose`→`REQ` children) with no `satisfy` from a `FUNC`/`FCHAIN`/`MOD`/`SYS`.",
package/dist/se/rules.js CHANGED
@@ -8,7 +8,7 @@ import { ElementType, TraceType, TestRefsSchema, RealRefSchema } from './ontolog
8
8
  import { traceRejection, BOUNDED_PATTERNS, REQUIRED_PATTERNS, maxOccurs } from './meta-model.js';
9
9
  import { indexOf } from './graph-index.js';
10
10
  import { moduleCrossings, subtreeFuncs } from './module-crossings.js';
11
- import { functionCriticality } from './function-criticality.js';
11
+ import { functionCriticality, chainsByFunc } from './function-criticality.js';
12
12
  export const RuleSeverity = z.enum(['error', 'warning', 'info']);
13
13
  /** Candidate target for resolving a violation (e.g. a REQ to satisfy, a TEST to link). */
14
14
  export const ViolationCandidate = z.object({
@@ -1260,14 +1260,11 @@ function fchainMustHaveIntegrationTest(graph, policy) {
1260
1260
  }
1261
1261
  if (connections.length === 0)
1262
1262
  return [];
1263
- // FUNC → set of FCHAINs composing it. LOKALER Index fuer eine andere Frage als die Kennzahl:
1264
- // "teilen diese beiden EINE Kette" braucht die Mengen, nicht die Zahl (CR-SM-313).
1265
- const chainsOfFunc = new Map();
1266
- for (const t of graph.traces) {
1267
- if (t.type === 'compose' && typeOf.get(t.source) === 'FCHAIN' && isFunc(t.target)) {
1268
- (chainsOfFunc.get(t.target) ?? chainsOfFunc.set(t.target, new Set()).get(t.target)).add(t.source);
1269
- }
1270
- }
1263
+ // FUNC → set of FCHAINs composing it. CR-SM-335: DIESELBE Rechnung, die auch die Kennzahl
1264
+ // speist vorher stand hier ein zweiter, lokaler Index ueber dieselbe Kantenmenge. "Teilen
1265
+ // diese beiden EINE Kette" braucht die Mengen, die Infrastruktur-Ausnahme die Zahl; beides
1266
+ // kommt jetzt aus `chainsByFunc` (CR-SM-313 hatte den Helfer zugesagt, CR-SM-314 die Zahl).
1267
+ const chainsOf = chainsByFunc(graph);
1271
1268
  // FCHAINs whose satisfy-REQ is verified by a TEST = chains with an integration test.
1272
1269
  const verifiedReqs = new Set(idx.tracesOfType('verify').map(t => t.target));
1273
1270
  const testedChains = new Set();
@@ -1285,8 +1282,8 @@ function fchainMustHaveIntegrationTest(graph, policy) {
1285
1282
  const violations = [];
1286
1283
  const seen = new Set();
1287
1284
  for (const [p, c] of connections) {
1288
- const pChains = chainsOfFunc.get(p) ?? new Set();
1289
- const cChains = chainsOfFunc.get(c) ?? new Set();
1285
+ const pChains = chainsOf.get(p) ?? new Set();
1286
+ const cChains = chainsOf.get(c) ?? new Set();
1290
1287
  // ZWEIG 1 — ein Ende in GAR KEINER Kette: still. Das ist R-30s Aussage, nicht R-21s.
1291
1288
  // Ohne diese Klausel feuert die Regel an einem code-importierten Graphen wie moneyflow
1292
1289
  // 219 von 219 Mal und meldet in Wahrheit "dieses Repo hat keine Wirkketten", einmal je Kante.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sigloch/contracts",
3
- "version": "10.4.0",
3
+ "version": "10.6.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",