@sigloch/contracts 10.1.0 → 10.3.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/se/rules.js CHANGED
@@ -7,7 +7,8 @@ import { z } from 'zod/v4';
7
7
  import { ElementType, TraceType, TestRefsSchema, RealRefSchema } from './ontology.js';
8
8
  import { traceRejection, BOUNDED_PATTERNS, REQUIRED_PATTERNS, maxOccurs } from './meta-model.js';
9
9
  import { indexOf } from './graph-index.js';
10
- import { crossingContractCount, subtreeFuncs } from './module-crossings.js';
10
+ import { moduleCrossings } from './module-crossings.js';
11
+ import { functionCriticality } from './function-criticality.js';
11
12
  export const RuleSeverity = z.enum(['error', 'warning', 'info']);
12
13
  /** Candidate target for resolving a violation (e.g. a REQ to satisfy, a TEST to link). */
13
14
  export const ViolationCandidate = z.object({
@@ -233,94 +234,65 @@ function funcMustSatisfyReq(graph) {
233
234
  // R-03: ASIL isolation
234
235
  // ---------------------------------------------------------------------------
235
236
  // ---------------------------------------------------------------------------
236
- // R-04: Modulgroesse GEGEN Kreuzungen (nicht „max module size")
237
+ // R-04: Randbreite des Moduls (CR-SM-312)
237
238
  //
238
- // CR-SM-236: die Regel waegt Groesse gegen kreuzende io-Flows ab ein grosses Modul ohne
239
- // Kreuzungen ist info („kohaesiv, nur gross"), dasselbe Modul mit Kreuzungen ist warning.
240
- // Der alte Name gab das nicht her, und die drei Schwellen (8/12/2) standen inline: eine
241
- // Urteilsschwelle, die sich weder per grep noch aus dem Regelnamen ablesen laesst, ist nicht
242
- // ueberpruefbar. Jetzt sind es `policy.moduleSize.{coupled,large,crossings}`.
243
- // `null` → messen, nicht urteilen: die Regel schweigt.
239
+ // Die Groessenfrage ist mit CR-SM-311 an RD-04 gegangen: eine Modul-Ebene zeigt Untermodule UND
240
+ // eigene Funktionen, das IST ihre Breite. Was bleibt, ist die Kopplung und die misst diese Regel
241
+ // jetzt allein: die VERSCHIEDENEN Vertraege, die den Modulrand queren (`moduleCrossings.byModule`,
242
+ // dieselbe Zaehlung wie CR-01 und BW-02, CR-SM-276).
244
243
  //
245
- // CR-SM-276: „Kreuzung" heisst hier dasselbe wie bei CR-01 ein Vertrag auf dem io-Pfad
246
- // `FUNC -io-> FLOW -io-> FUNC` UEBER die Modulgrenze (`module-crossings.ts`). Vorher zaehlte
247
- // die Regel jede io-Kante einer Modul-FUNC „mit einem Endpunkt ausserhalb der FUNC-Menge" —
248
- // FLOWs liegen nie in dieser Menge, also zaehlte auch jeder modulINTERNE Fluss mit. Das war der
249
- // io-Grad des Moduls und fuer jedes Modul mit Datenfluss trivial > `crossings`; damit war R-04
250
- // faktisch wieder „max module size", der Name, den CR-SM-236 gerade verworfen hatte.
251
- // ---------------------------------------------------------------------------
252
- function maxModuleSize(graph, policy) {
244
+ // Dieselbe Frage wie BW-02 auf dem zweiten Baum, also dieselbe Schwelle: `policy.boundaryWidth`.
245
+ // Damit faellt `policy.moduleSize` ersatzlos drei Zahlen fuer eine Frage, die es nicht mehr gibt.
246
+ //
247
+ // Das schliesst die Blindstelle, die `se-engine/steer.ts` seit CR-SM-292 benennt: ein KLEINES
248
+ // Modul mit breitem Rand war stumm, weil R-04 den Rand nur zusammen mit der Groesse ansah
249
+ // (gemessen: MOD-kernel-measure 9 Vertraege bei 7 Funktionen). Seit diesem CR steuert R-04 mit
250
+ // (`STEER_RULES`), denn der Befund ist lokal, gerichtet und budgetiert.
251
+ // ---------------------------------------------------------------------------
252
+ function moduleBoundaryWidth(graph, policy) {
253
+ const steps = policy.boundaryWidth;
254
+ if (steps === null)
255
+ return [];
253
256
  const idx = indexOf(graph);
254
257
  const violations = [];
255
- const steps = policy.moduleSize;
256
- if (steps === null)
257
- return violations;
258
- const modules = idx.elementsOfType('MOD');
259
- for (const mod of modules) {
260
- // CR-SM-282: die Groesse ist die des TEILBAUMS, nicht der direkten Allokation. Ein
261
- // Eltern-MOD (`MOD -compose-> MOD`) hat keine direkt allozierten FUNCs; `funcCount`
262
- // war dort 0 und die Regel schwieg an genau der Whitebox, in die man hineinklickt.
263
- // Fuer ein Blatt-MOD ist die Menge zeichengleich mit der bisherigen (nur `FUNC
264
- // -allocate-> MOD` existiert als Pattern) — Regressions-Invariante.
265
- const allocatedIds = [...subtreeFuncs(graph, mod.id)];
266
- const allocated = allocatedIds.map(id => idx.byId.get(id)).filter((e) => !!e);
267
- const funcCount = allocated.length;
268
- if (funcCount <= steps.coupled)
258
+ for (const [modId, contracts] of moduleCrossings(graph).byModule) {
259
+ if (contracts.size < steps.warning)
269
260
  continue;
270
- // Verschiedene Vertraege (SCHEMA) auf io-Pfaden ueber DIESEN Modulrand — CR-SM-276.
271
- const crossings = crossingContractCount(graph, mod.id);
272
- if (funcCount > steps.large && crossings === 0) {
273
- violations.push({
274
- rule_id: 'R-04',
275
- severity: 'info',
276
- element_id: mod.id,
277
- message: `${mod.id} has ${funcCount} functions but 0 contracts crossing its module boundary (cohesive, just large)`,
278
- fix_hint: `Size alone is not the finding: > ${steps.large} functions without crossing flows is cohesive. Split only if crossings appear`,
279
- context: { element_type: mod.type, element_name: mod.name, candidate_targets: toCandidates(allocated) },
280
- });
281
- }
282
- else if (funcCount > steps.large && crossings > 0) {
283
- violations.push({
284
- rule_id: 'R-04',
285
- severity: 'warning',
286
- element_id: mod.id,
287
- message: `${mod.id} has ${funcCount} functions and ${crossings} distinct contract(s) crossing its module boundary (split recommended)`,
288
- fix_hint: `Split module into smaller units to reduce coupling (> ${steps.large} functions AND crossing flows)`,
289
- context: { element_type: mod.type, element_name: mod.name, candidate_targets: toCandidates(allocated) },
290
- });
291
- }
292
- else if (funcCount > steps.coupled && crossings > steps.crossings) {
293
- violations.push({
294
- rule_id: 'R-04',
295
- severity: 'warning',
296
- element_id: mod.id,
297
- message: `${mod.id} has ${funcCount} functions and ${crossings} distinct contract(s) crossing its module boundary (high coupling)`,
298
- fix_hint: 'Reduce crossing flows or split module',
299
- context: { element_type: mod.type, element_name: mod.name, candidate_targets: toCandidates(allocated) },
300
- });
301
- }
261
+ const mod = idx.byId.get(modId);
262
+ violations.push({
263
+ rule_id: 'R-04',
264
+ severity: 'warning',
265
+ element_id: modId,
266
+ message: `${modId} exposes ${contracts.size} distinct contract(s) at its module boundary (>= ${steps.warning})`,
267
+ fix_hint: 'Reduce the contracts crossing this module boundary — merge contracts, or move the functions that carry them',
268
+ // CR-SM-288: `>= steps.warning` ist inklusiv, also ist `steps.warning - 1` der groesste Wert
269
+ // im Budget; die Masse `value - threshold` ist damit fuer jeden Befund > 0. Gleiche Konvention
270
+ // wie BW-02, dieselbe Schwelle.
271
+ context: { element_type: mod?.type, element_name: mod?.name, value: contracts.size, threshold: steps.warning - 1 },
272
+ });
302
273
  }
303
- return violations;
274
+ // Kanonische Reihenfolge wie bei BW-02 — sonst haengt die Befund-Sequenz an der Trace-Reihenfolge.
275
+ return violations.sort((a, b) => (a.element_id < b.element_id ? -1 : a.element_id > b.element_id ? 1 : 0));
304
276
  }
305
277
  // ---------------------------------------------------------------------------
306
- // R-05: Every TEST must verify at least one REQ
278
+ // R-05: Every TEST must verify at least one REQ — or, as a contract test, a SCHEMA (CR-SM-317)
307
279
  // ---------------------------------------------------------------------------
308
280
  function testMustVerifyReq(graph) {
309
281
  const idx = indexOf(graph);
310
282
  const tests = idx.elementsOfType('TEST');
311
- const allReqs = idx.elementsOfType('REQ');
283
+ const candidates = [...idx.elementsOfType('REQ'), ...idx.elementsOfType('SCHEMA')];
312
284
  return tests
313
285
  .filter(test => idx.out(test.id, 'verify').length === 0)
314
286
  .map(test => ({
315
287
  rule_id: 'R-05',
316
288
  severity: 'warning',
317
289
  element_id: test.id,
318
- message: `${test.id} does not verify any requirement`,
319
- fix_hint: 'Link to a REQ via verify trace',
290
+ message: `${test.id} does not verify any requirement or schema`,
291
+ fix_hint: 'Link to a REQ via verify trace — or, for a contract test, to the SCHEMA it exercises',
320
292
  context: {
321
293
  element_type: test.type,
322
294
  element_name: test.name,
323
- candidate_targets: toCandidates(allReqs),
295
+ candidate_targets: toCandidates(candidates),
324
296
  },
325
297
  }));
326
298
  }
@@ -396,6 +368,95 @@ function flowCompleteness(graph) {
396
368
  return violations;
397
369
  }
398
370
  // ---------------------------------------------------------------------------
371
+ // IO-02: ein FLOW hat genau EINEN Produzenten (CR-SM-307).
372
+ //
373
+ // R-10 prueft die UNTERgrenze (mindestens ein Ende), IO-02 die OBERgrenze der
374
+ // Produzentenseite. Die beiden koennen am selben FLOW nie zugleich feuern — |P| = 0
375
+ // gegen |P| > 1 ist disjunkt — also keine Doppelzaehlung im Sinne von Gate 4. Getrennte
376
+ // IDs, weil es getrennte Fragen sind (CR-SM-296, "je Frage eine Regel"): R-10 fragt
377
+ // "hat dieser Fluss ueberhaupt Enden?", IO-02 fragt "kommt sein Inhalt von einer Stelle?".
378
+ //
379
+ // WARUM NUR DIE PRODUZENTENSEITE. Der Inhalt eines FLOW kommt von einer Stelle; haengen
380
+ // zwei Quellen dran, sind es zwei Fluesse unter einem Namen, und kein Leser kann wissen,
381
+ // welche Fassung er bekommt. Mehrere KONSUMENTEN sind dagegen normal — wer abholt, aendert
382
+ // am Fluss nichts. Das ist genau die zentrale Konfiguration: eine Quelle, viele Verbraucher.
383
+ // Eine beidseitige Fassung wurde gemessen und VERWORFEN: sie haette allein in graphcode
384
+ // 8 saubere 1:N-Muster bestraft (FLOW-dimension-readiness 1x7, FLOW-steering-snapshot 1x3,
385
+ // sechs weitere), ohne einen einzigen zusaetzlichen echten Fehler zu finden.
386
+ //
387
+ // BESTAND vor der Einfuehrung, fuenf Systeme (graphcode/rig/flow-cardinality):
388
+ // graphcode 20/42 · graph-view-edit 5/8 · bok 3/15 · moneyflow 0/220 · test_karp 0/21
389
+ // Die beiden Referenzen sind auch BEIDSEITIG sauber — sie erfuellen die strengere Fassung
390
+ // ohnehin, die Lockerung rettet sie nicht. graphcode wurde vor diesem CR auf 9 repariert
391
+ // (CR-GC-499/500); die 9 sind Busse mit 3 bis 19 Produzenten, un-modellierte Entwurfsarbeit.
392
+ //
393
+ // WARUM ES BIS HIERHER KEIN GATE PRUEFTE: die vier io-Zeilen in TRACE_PATTERNS tragen kein
394
+ // `cardinality`-Feld und fallen deshalb durch REQUIRED_PATTERNS
395
+ // (= TRACE_PATTERNS.filter(p => p.cardinality === '1')). Die SCHEMA-Haelfte IST Grammatik
396
+ // (`FLOW -relation-> SCHEMA [1..1]`, R-18 seit CR-SM-271 Teil 2) — die io-Haelfte war nie
397
+ // deklariert. Es ist kein Regel-Loch, das jemand geschlossen und wieder geoeffnet haette:
398
+ // die Frage wurde nie gestellt.
399
+ //
400
+ // EIN BEFUND JE FLOW, nicht je ueberzaehliger Kante (Klasse CR-SM-242): sonst uebertraefe
401
+ // der Zaehler seinen eigenen Nenner-Beitrag (`domain: ['FLOW']`). `value`/`threshold` nach
402
+ // CR-SM-288, damit der Befund budgetierbar ist.
403
+ //
404
+ // SEVERITY error, aber NICHT in STEER_RULES: das ist Hygiene, keine Steuerdimension
405
+ // (Entscheidung Auftraggeber 2026-09-10).
406
+ //
407
+ // KORREKTUR (CR-SM-308): Hier stand als Beleg, das Auftrennen eines Flusses hebe BW-02 ("ein
408
+ // aufgetrennter Fluss kreuzt zwei Grenzen statt einer"), eine rankende Regel zoege also gegen
409
+ // diese urteilende. Das stimmt nicht. moduleCrossings zaehlt je Rand VERSCHIEDENE SCHEMA; ein
410
+ // aufgetrennter FLOW behaelt sein SCHEMA und hat eine Teilmenge der Enden, er erzeugt keinen
411
+ // neuen Randdurchgang. Gemessen an graphcode v257: alle sechs Mehrfach-Produzenten-FLOW je
412
+ // Produzent getrennt, BW-02 15 -> 15, byFunc und byModule unveraendert, IO-02 6 -> 0. Der
413
+ // Anstieg bei CR-GC-499/500 (BW-02 13 -> 14, Chebyshev -0,25) kam von drei NEUEN SCHEMA und
414
+ // umgelegten Ketten, nicht vom Auftrennen. Die Entscheidung oben steht; ein gemessener
415
+ // Zielkonflikt mit BW-02 traegt sie nicht.
416
+ // ---------------------------------------------------------------------------
417
+ function flowSingleProducer(graph) {
418
+ const idx = indexOf(graph);
419
+ const flowIds = new Set(idx.idsOfType('FLOW'));
420
+ const isEndpoint = (id) => { const t = idx.typeOf(id); return t === 'FUNC' || t === 'ACTOR'; };
421
+ // Produzenten je FLOW als MENGE: zwei io-Kanten derselben Quelle auf denselben FLOW sind
422
+ // eine Quelle, kein Verstoss. Konsumenten werden bewusst nicht gezaehlt (s. Kopf).
423
+ const producers = new Map();
424
+ for (const t of idx.tracesOfType('io')) {
425
+ if (!flowIds.has(t.target) || !isEndpoint(t.source))
426
+ continue;
427
+ if (!producers.has(t.target))
428
+ producers.set(t.target, new Set());
429
+ producers.get(t.target).add(t.source);
430
+ }
431
+ const violations = [];
432
+ // Ueber die Elementliste laufen, nicht ueber die Map: die Reihenfolge eines Befundes darf
433
+ // nicht an der Trace-Reihenfolge haengen (CR-SM-244, Ordnung).
434
+ for (const f of idx.elementsOfType('FLOW')) {
435
+ const ps = producers.get(f.id);
436
+ if (!ps || ps.size <= 1)
437
+ continue;
438
+ const sorted = [...ps].sort();
439
+ violations.push({
440
+ rule_id: 'IO-02',
441
+ severity: 'error',
442
+ element_id: f.id,
443
+ message: `${f.id} has ${ps.size} producers (${sorted.join(', ')}) — the content of a FLOW comes from exactly one place`,
444
+ fix_hint: 'Give each source its own FLOW; the shared contract stays on the SCHEMA (n FLOW -> 1 SCHEMA is legal)',
445
+ context: {
446
+ element_type: f.type,
447
+ element_name: f.name,
448
+ value: ps.size,
449
+ threshold: 1,
450
+ candidate_targets: sorted.slice(0, 3).map(id => {
451
+ const e = idx.byId.get(id);
452
+ return { id, type: e?.type ?? 'FUNC', name: e?.name ?? id };
453
+ }),
454
+ },
455
+ });
456
+ }
457
+ return violations;
458
+ }
459
+ // ---------------------------------------------------------------------------
399
460
  // R-11: REMOVED — superseded by SC-02 (identical check, better severity).
400
461
  // ---------------------------------------------------------------------------
401
462
  // ---------------------------------------------------------------------------
@@ -564,54 +625,10 @@ function noPrematureDecomposition(graph) {
564
625
  context: { element_type: req.type, element_name: req.name },
565
626
  }));
566
627
  }
567
- // ---------------------------------------------------------------------------
568
- // RD-04: Decomposition breadth (CR-SM-221)
569
- // ---------------------------------------------------------------------------
570
- /**
571
- * Max children on one decomposition level. The 7±2 convention existed only as
572
- * prose ("darüber func-of-func"); below 7 is a guideline, not a violation, so only
573
- * the upper bound is a rule.
574
- *
575
- * Counted per (parent, kind) — a MOD that both holds 12 FUNCs and composes 12
576
- * sub-MODs has two breadth problems, not one. (The spike reference merged both into
577
- * a single counter on the MOD id and would have reported 24 under one kind.)
578
- *
579
- * CR-SM-282, zwei Aenderungen:
580
- *
581
- * 1. **Die Schwelle steht in `policy.decompositionBreadth`**, nicht mehr inline. `null`
582
- * schaltet die Regel ab (messen statt urteilen). Der Default war hier 11 (verhaltensgleich
583
- * zum vorherigen Literal) und ist seit CR-SM-296 **9** — die Obergrenze von 7±2.
584
- * 2. **Der FUNC-Wurzelwald zaehlt mit.** Die Regel zaehlt Kinder je *Parent*; die Wurzeln des
585
- * `FUNC -compose-> FUNC`-Waldes haben keinen — die oberste Funktionsebene war damit fuer
586
- * RD-04 unsichtbar. Gemessen: moneyflow hat 306 Wurzel-FUNCs und RD-04 meldete **0**.
587
- * Die MOD-Seite hatte das Problem nie, weil `SYS -compose-> MOD` eine echte Kante ist und
588
- * unten schon als `sub-MOD` gezaehlt wird; die FUNC-Seite hat keine solche Kante
589
- * (`se:top-level`: „the top FUNC set is a PROJECTION, not an edge").
590
- *
591
- * Anker ist der SYS-Knoten — determiniert, weil genau einer existiert; gibt es nicht genau
592
- * einen, schweigt dieser Zweig statt zu raten (R-17 deckt den Fall ab). Kein zweiter Weg zur
593
- * Wurzelmenge: es ist dieselbe Definition wie in `se:top-level` (FUNC ohne compose-Elternteil).
594
- */
595
- // CR-SM-296: das Bein `FUNC -allocate-> MOD` ist ENTFALLEN und an R-04 abgegeben.
596
- //
597
- // Es zaehlte die allozierten FUNCs je Modul — also die MODULGROESSE, und die misst R-04 auch.
598
- // Beide feuerten am selben Modul im selben Batch, mit zwei Schwellen aus zwei Policy-Feldern:
599
- // RD-04 > 11 allozierte FUNC-Kinder policy.decompositionBreadth
600
- // R-04 > 12 FUNCs (bzw. 8-12 mit Kreuzungen) policy.moduleSize
601
- // Ein Sachverhalt, zwei Regeln, zwei Zahlen — und welche recht hat, stand nirgends. Schlimmer:
602
- // dieselbe Ursache ging doppelt in den readiness-Nenner. R-04 ist die reichere Aussage (Groesse
603
- // GEGEN Kreuzungen, drei Urteile) und liest ueber `moduleCrossings` ohnehin dieselben Daten.
604
- //
605
- // RD-04 bleibt fuer ZERLEGUNGSBREITE zustaendig: `FUNC compose FUNC`, `SYS/MOD compose MOD` und
606
- // der FUNC-Wurzelwald am SYS (CR-SM-282). Je Frage eine Regel, je Frage eine Schwelle.
607
- function decompositionBreadth(graph, policy) {
608
- const max = policy.decompositionBreadth;
609
- if (max === null)
610
- return [];
628
+ function breadthCounts(graph) {
611
629
  const idx = indexOf(graph);
612
630
  // CR-SM-264: `typeOf` und `byId` baute diese Regel je Aufruf selbst — der Index hat beide.
613
631
  const typeOf = idx;
614
- const byId = idx.byId;
615
632
  const counts = new Map();
616
633
  const bump = (parentId, kind) => {
617
634
  const key = `${parentId}\u0000${kind}`;
@@ -627,9 +644,24 @@ function decompositionBreadth(graph, policy) {
627
644
  if (t.type === 'compose' && src === 'FUNC' && tgt === 'FUNC') {
628
645
  bump(t.source, 'sub-FUNC');
629
646
  }
630
- else if (t.type === 'compose' && (src === 'SYS' || src === 'MOD') && tgt === 'MOD') {
647
+ else if (t.type === 'compose' && src === 'SYS' && tgt === 'MOD') {
631
648
  bump(t.source, 'sub-MOD');
632
649
  }
650
+ else if (t.type === 'compose' && src === 'MOD' && tgt === 'MOD') {
651
+ bump(t.source, 'module element');
652
+ }
653
+ }
654
+ // CR-SM-311: eine MOD-Ebene liest sich aus Untermodulen UND eigenen Funktionen. Gezaehlt werden
655
+ // die BLAETTER — ein zerlegter FUNC ist ein Wertbaum-Knoten; seine Blaetter tragen den Code und
656
+ // stehen selbst im Modul, ihn mitzuzaehlen hiesse dieselbe Funktion zweimal.
657
+ const decomposedFuncs = new Set(graph.traces
658
+ .filter(t => t.type === 'compose' && typeOf.typeOf(t.source) === 'FUNC' && typeOf.typeOf(t.target) === 'FUNC')
659
+ .map(t => t.source));
660
+ for (const t of graph.traces) {
661
+ if (t.type !== 'allocate' || typeOf.typeOf(t.source) !== 'FUNC' || typeOf.typeOf(t.target) !== 'MOD')
662
+ continue;
663
+ if (!decomposedFuncs.has(t.source))
664
+ bump(t.target, 'module element');
633
665
  }
634
666
  // CR-SM-282: der FUNC-Wurzelwald als eigene Blackbox, verankert am SYS-Knoten.
635
667
  const composedFuncs = new Set(graph.traces
@@ -641,18 +673,56 @@ function decompositionBreadth(graph, policy) {
641
673
  if (rootFuncs > 0)
642
674
  counts.set(`${systems[0].id}\u0000root FUNC`, { parentId: systems[0].id, kind: 'root FUNC', n: rootFuncs });
643
675
  }
644
- return [...counts.values()]
645
- .filter(c => c.n > max.warning)
676
+ return [...counts.values()];
677
+ }
678
+ function decompositionBreadth(graph, policy) {
679
+ const band = policy.decompositionBreadth;
680
+ if (band === null)
681
+ return [];
682
+ const byId = indexOf(graph).byId;
683
+ return breadthCounts(graph)
684
+ .filter(c => c.n > band.warning)
646
685
  .map(c => {
647
686
  const parent = byId.get(c.parentId);
648
687
  return {
649
688
  rule_id: 'RD-04',
650
689
  severity: 'warning',
651
690
  element_id: c.parentId,
652
- message: `${c.parentId} has ${c.n} ${c.kind} children on one level (>${max.warning})`,
691
+ message: `${c.parentId} has ${c.n} ${c.kind} children on one level (>${band.warning})`,
653
692
  fix_hint: 'Introduce an intermediate level (func-of-func / sub-MOD)',
654
- // CR-SM-288: `> max.warning` ist exklusiv, also ist `max.warning` selbst noch im Budget.
655
- context: { element_type: parent?.type, element_name: parent?.name, value: c.n, threshold: max.warning },
693
+ // CR-SM-288: `> band.warning` ist exklusiv, also ist `band.warning` selbst noch im Budget.
694
+ context: { element_type: parent?.type, element_name: parent?.name, value: c.n, threshold: band.warning },
695
+ };
696
+ });
697
+ }
698
+ // ---------------------------------------------------------------------------
699
+ // RD-05: Zerlegung zu SCHMAL — eine Ebene mit weniger als `decompositionBreadth.min` Elementen
700
+ // abstrahiert nichts (CR-SM-311, Z3 aus ITEM-2026-053). Dieselbe Zaehlung wie RD-04.
701
+ //
702
+ // Eine eigene Regel und nicht die untere Haelfte von RD-04: RD-04 ist eine der messenden Regeln
703
+ // (CR-SM-288) — jeder ihrer Befunde traegt `value` und `threshold`, und `value > threshold`
704
+ // heisst warning. Eine Unterschreitung hat keinen Ueberschuss; als RD-04 haette sie diese Zusage
705
+ // fuer alle Konsumenten gebrochen und waere in `steerScore` als Masse 0 mitgelaufen. RD-05 fuehrt
706
+ // deshalb keine Zahl im Kontext und steuert nicht — sie ist Lesbarkeit, nicht Randbreite.
707
+ // Eine Ebene ohne jedes Kind ist nicht ihr Fall: ein leeres MOD meldet R-23, eine FUNC ohne Kinder
708
+ // ist ein Blatt.
709
+ // ---------------------------------------------------------------------------
710
+ function decompositionNarrow(graph, policy) {
711
+ const band = policy.decompositionBreadth;
712
+ if (band === null)
713
+ return [];
714
+ const byId = indexOf(graph).byId;
715
+ return breadthCounts(graph)
716
+ .filter(c => c.n < band.min)
717
+ .map(c => {
718
+ const parent = byId.get(c.parentId);
719
+ return {
720
+ rule_id: 'RD-05',
721
+ severity: 'warning',
722
+ element_id: c.parentId,
723
+ message: `${c.parentId} has only ${c.n} ${c.kind} children on one level (<${band.min})`,
724
+ fix_hint: 'Dissolve the level into its parent, or gather related elements under it',
725
+ context: { element_type: parent?.type, element_name: parent?.name },
656
726
  };
657
727
  });
658
728
  }
@@ -1171,12 +1241,12 @@ function funcMustHaveCodeBinding(graph) {
1171
1241
  // as one produced P·C findings per hub FLOW and made reuse the expensive
1172
1242
  // choice. The FCHAIN is the modelled claim; the test is owed on the claim.
1173
1243
  // ---------------------------------------------------------------------------
1174
- function fchainMustHaveIntegrationTest(graph) {
1244
+ function fchainMustHaveIntegrationTest(graph, policy) {
1175
1245
  const idx = indexOf(graph);
1176
1246
  const typeOf = new Map(graph.elements.map(e => [e.id, e.type]));
1177
1247
  const isFunc = (id) => typeOf.get(id) === 'FUNC';
1178
1248
  const io = idx.tracesOfType('io');
1179
- // FUNC↔FUNC connections via the FLOW hop: (producer FUNC) → FLOW → (consumer FUNC).
1249
+ // FUNC↔FUNC handovers via the FLOW hop: (producer FUNC) → FLOW → (consumer FUNC).
1180
1250
  const connections = [];
1181
1251
  for (const e of graph.elements) {
1182
1252
  if (e.type !== 'FLOW')
@@ -1190,7 +1260,8 @@ function fchainMustHaveIntegrationTest(graph) {
1190
1260
  }
1191
1261
  if (connections.length === 0)
1192
1262
  return [];
1193
- // FUNC → set of FCHAINs composing it.
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).
1194
1265
  const chainsOfFunc = new Map();
1195
1266
  for (const t of graph.traces) {
1196
1267
  if (t.type === 'compose' && typeOf.get(t.source) === 'FCHAIN' && isFunc(t.target)) {
@@ -1205,16 +1276,46 @@ function fchainMustHaveIntegrationTest(graph) {
1205
1276
  testedChains.add(t.source);
1206
1277
  }
1207
1278
  }
1279
+ // CR-SM-313: die Infrastruktur-Ausnahme. Die ZAHL kommt aus der Kennzahl (CR-SM-314), nicht
1280
+ // aus einer zweiten Rechnung — eine Uebergabe an eine Funktion, die quer durch alle Ketten
1281
+ // laeuft, ist kein fehlender Integrationstest, sondern ein Bus. `null` = keine Ausnahme.
1282
+ const infra = policy.criticality?.infrastructure ?? null;
1283
+ const chainCount = infra === null ? null : new Map(functionCriticality(graph).map(r => [r.funcId, r.chains]));
1284
+ const isInfrastructure = (id) => infra !== null && chainCount !== null && (chainCount.get(id) ?? 0) >= infra;
1208
1285
  const violations = [];
1209
1286
  const seen = new Set();
1210
1287
  for (const [p, c] of connections) {
1211
- const shared = [...(chainsOfFunc.get(p) ?? [])].filter(ch => chainsOfFunc.get(c)?.has(ch));
1212
- // No shared FCHAIN = no asserted integration (CR-GC-315). Sharing one FLOW
1213
- // between P producers and C consumers does not mean P·C interfaces exist
1214
- // deriving connections from FLOW adjacency alone taxed reuse quadratically.
1215
- // The FCHAIN is the declared integration scope; only that is held to a test.
1216
- if (shared.length === 0)
1288
+ const pChains = chainsOfFunc.get(p) ?? new Set();
1289
+ const cChains = chainsOfFunc.get(c) ?? new Set();
1290
+ // ZWEIG 1 ein Ende in GAR KEINER Kette: still. Das ist R-30s Aussage, nicht R-21s.
1291
+ // Ohne diese Klausel feuert die Regel an einem code-importierten Graphen wie moneyflow
1292
+ // 219 von 219 Mal und meldet in Wahrheit "dieses Repo hat keine Wirkketten", einmal je Kante.
1293
+ if (pChains.size === 0 || cChains.size === 0)
1294
+ continue;
1295
+ const shared = [...pChains].filter(ch => cChains.has(ch));
1296
+ // ZWEIG 3 — beide in Ketten, aber KEINE gemeinsame (CR-SM-313). CR-GC-315 hatte diesen Fall
1297
+ // stillgelegt, weil ein FLOW damals viele Produzenten haben durfte und die P·C-Ableitung
1298
+ // Wiederverwendung quadratisch besteuerte; seit IO-02 (CR-SM-307) hat er genau EINEN.
1299
+ // Befund am SENDER: ohne gemeinsame Kette gibt es keinen Kettenanker.
1300
+ if (shared.length === 0) {
1301
+ if (isInfrastructure(p) || isInfrastructure(c))
1302
+ continue;
1303
+ const key = `pair|${p}>${c}`;
1304
+ if (seen.has(key))
1305
+ continue;
1306
+ seen.add(key);
1307
+ const sender = idx.byId.get(p);
1308
+ violations.push({
1309
+ rule_id: 'R-21',
1310
+ severity: 'warning',
1311
+ element_id: p,
1312
+ message: `${p} hands over to ${c}, but the two share no FCHAIN — no integration scope is declared`,
1313
+ fix_hint: 'Put both functions into one FCHAIN, or route the handover through a function of an existing chain',
1314
+ context: { element_type: sender?.type, element_name: sender?.name },
1315
+ });
1217
1316
  continue;
1317
+ }
1318
+ // ZWEIG 2 — gemeinsame Kette, aber keine davon geprueft: Befund an der Kette. Unveraendert.
1218
1319
  if (shared.some(ch => testedChains.has(ch)))
1219
1320
  continue;
1220
1321
  const anchor = shared[0];
@@ -1320,6 +1421,32 @@ function schemaMustHaveSchemaRef(graph) {
1320
1421
  }));
1321
1422
  }
1322
1423
  // ---------------------------------------------------------------------------
1424
+ // R-32: Every realized SCHEMA must have a contract TEST (CR-SM-319, ITEM-2026-064).
1425
+ // A SCHEMA with a symbol-bearing realRef is a Zod contract in code. RC-04 checks that it is
1426
+ // PARSED at its boundary; this rule checks that it is TESTED as a contract — every variant
1427
+ // incl. rejection, independent of any scenario — via `TEST -verify-> SCHEMA` (CR-SM-317).
1428
+ // Chain tests (R-21) cover meaning end-to-end; they do not cover the contract's variants.
1429
+ // Owner's decision 2026-09-11: no exception, no KPI — a rule. concept-only SCHEMAs and
1430
+ // SCHEMAs without a symbol realRef are out of scope (presence is R-26's concern).
1431
+ // ---------------------------------------------------------------------------
1432
+ function schemaMustHaveContractTest(graph) {
1433
+ const idx = indexOf(graph);
1434
+ const typeOf = new Map(graph.elements.map(e => [e.id, e.type]));
1435
+ const tests = idx.elementsOfType('TEST');
1436
+ return idx.elementsOfType('SCHEMA')
1437
+ .filter(sc => sc.attributes?.concept !== true)
1438
+ .filter(sc => { const r = RealRefSchema.safeParse(sc.attributes?.realRef); return r.success && r.data.symbol !== undefined; })
1439
+ .filter(sc => !graph.traces.some(t => t.type === 'verify' && t.target === sc.id && typeOf.get(t.source) === 'TEST'))
1440
+ .map(sc => ({
1441
+ rule_id: 'R-32',
1442
+ severity: 'warning',
1443
+ element_id: sc.id,
1444
+ message: `${sc.id} is a realized SCHEMA without a contract TEST (no TEST -verify-> SCHEMA)`,
1445
+ fix_hint: 'Add a TEST that exercises every variant of the contract incl. rejection, and link it via verify trace to this SCHEMA',
1446
+ context: { element_type: sc.type, element_name: sc.name, candidate_targets: toCandidates(tests) },
1447
+ }));
1448
+ }
1449
+ // ---------------------------------------------------------------------------
1323
1450
  // R-27: physical MOD realRef presence (CR-228) — the MOD arm of the unified
1324
1451
  // "element must have a realRef" rule (FUNC=R-20, SCHEMA=R-26, physical MOD=R-27).
1325
1452
  // A physical MOD (kind='physical') is a Bauteil realized by a CAD/geometry
@@ -1444,7 +1571,7 @@ export const V3_RULES = [
1444
1571
  { id: 'R-02', name: 'FUNC must satisfy REQ', severity: 'warning', evaluate: funcMustSatisfyReq, domain: ['FUNC'] },
1445
1572
  // CR-SM-243: domain ist MOD, nicht FUNC — die Regel iteriert Module und meldet am Modul,
1446
1573
  // das ASIL-D und QM mischt. Die FUNCs sind der Anlass des Urteils, nicht seine Traeger.
1447
- { id: 'R-04', name: 'Module size relative to crossing flows', severity: 'warning', evaluate: maxModuleSize, domain: ['MOD'] },
1574
+ { id: 'R-04', name: 'Module boundary width', severity: 'warning', evaluate: moduleBoundaryWidth, domain: ['MOD'] },
1448
1575
  { id: 'R-05', name: 'TEST must verify REQ', severity: 'warning', evaluate: testMustVerifyReq, domain: ['TEST'] },
1449
1576
  { id: 'R-15', name: 'FCHAIN completeness', severity: 'warning', evaluate: fchainCompleteness, domain: ['FCHAIN'] },
1450
1577
  { id: 'R-16', name: 'ACTOR must have io', severity: 'warning', evaluate: actorMustHaveTrace, domain: ['ACTOR'] },
@@ -1454,19 +1581,23 @@ export const V3_RULES = [
1454
1581
  // Als ['FUNC'] deklariert erhoehte sie den Zaehler einer Grundgesamtheit, zu der ihre
1455
1582
  // Verstoesse nicht gehoerten (bis zu 2 Legs je FLOW gegen einen FUNC-Nenner).
1456
1583
  { id: 'R-10', name: 'FLOW completeness', severity: 'warning', evaluate: flowCompleteness, domain: ['FLOW'] },
1584
+ // Obergrenze zu R-10s Untergrenze — disjunkt, deshalb zwei IDs (CR-SM-307).
1585
+ { id: 'IO-02', name: 'FLOW single producer', severity: 'error', evaluate: flowSingleProducer, domain: ['FLOW'] },
1457
1586
  { id: 'R-12', name: 'No circular dependencies', severity: 'warning', evaluate: noDirectCircular, domain: ['FUNC'] },
1458
1587
  { id: 'R-18', name: 'Valid trace pattern', severity: 'error', evaluate: validTracePattern, domain: ['all'] },
1459
1588
  { id: 'R-19', name: 'Runnable TEST binding', severity: 'warning', evaluate: testMustHaveRunnableBinding, domain: ['TEST'] },
1460
1589
  { id: 'R-29', name: 'Test file exclusivity', severity: 'error', evaluate: testFileExclusivity, domain: ['TEST'] },
1461
1590
  { id: 'R-20', name: 'FUNC realRef binding', severity: 'warning', evaluate: funcMustHaveCodeBinding, domain: ['FUNC'] },
1462
- { id: 'R-21', name: 'FUNC↔FUNC connection needs integration test', severity: 'warning', evaluate: fchainMustHaveIntegrationTest, domain: ['FCHAIN'] },
1591
+ { id: 'R-21', name: 'FUNC↔FUNC handover needs a shared chain and an integration test', severity: 'warning', evaluate: fchainMustHaveIntegrationTest, domain: ['FCHAIN', 'FUNC'] },
1463
1592
  { id: 'R-22', name: 'FUNC must be allocated to MOD', severity: 'warning', evaluate: funcMustBeAllocated, domain: ['FUNC'] },
1464
1593
  { id: 'R-23', name: 'MOD must have allocated FUNC', severity: 'warning', evaluate: modMustHaveAllocatedFunc, domain: ['MOD'] },
1465
1594
  { id: 'R-26', name: 'SCHEMA must have realRef', severity: 'warning', evaluate: schemaMustHaveSchemaRef, domain: ['SCHEMA'] },
1595
+ { id: 'R-32', name: 'SCHEMA must have contract TEST', severity: 'warning', evaluate: schemaMustHaveContractTest, domain: ['SCHEMA'] },
1466
1596
  { id: 'RD-01', name: 'Unresolved requirement', severity: 'warning', evaluate: unresolvedRequirement, domain: ['REQ'] },
1467
1597
  { id: 'RD-02', name: 'Decomposition consistency', severity: 'warning', evaluate: decompositionConsistency, domain: ['REQ'] },
1468
1598
  { id: 'RD-03', name: 'No premature decomposition', severity: 'info', evaluate: noPrematureDecomposition, domain: ['REQ'] },
1469
1599
  { id: 'RD-04', name: 'Decomposition breadth', severity: 'warning', evaluate: decompositionBreadth, domain: ['FUNC', 'MOD', 'SYS'] },
1600
+ { id: 'RD-05', name: 'Decomposition too narrow', severity: 'warning', evaluate: decompositionNarrow, domain: ['FUNC', 'MOD', 'SYS'] },
1470
1601
  { id: 'MS-01', name: 'Milestone empty scope', severity: 'warning', evaluate: msEmptyScope, domain: ['MS'] },
1471
1602
  { id: 'MS-02', name: 'Milestone dangling dependency', severity: 'error', evaluate: msDanglingDependency, domain: ['MS'] },
1472
1603
  { id: 'R-30', name: 'FUNC leaf must belong to a function chain', severity: 'warning', evaluate: funcMustBeInEffectChain, domain: ['FUNC'] },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sigloch/contracts",
3
- "version": "10.1.0",
3
+ "version": "10.3.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",