@sigloch/contracts 9.1.0 → 10.1.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.
Files changed (40) hide show
  1. package/dist/se/ao-rules.d.ts +12 -12
  2. package/dist/se/ao-rules.js +88 -225
  3. package/dist/se/conformance-rules.d.ts +12 -0
  4. package/dist/se/conformance-rules.js +6 -6
  5. package/dist/se/cr-quality-rules.js +75 -58
  6. package/dist/se/evaluate-all.d.ts +4 -2
  7. package/dist/se/evaluate-all.js +61 -24
  8. package/dist/se/fchain-quality-rules.d.ts +0 -1
  9. package/dist/se/fchain-quality-rules.js +0 -44
  10. package/dist/se/flat-graph.d.ts +9 -1
  11. package/dist/se/flat-graph.js +15 -6
  12. package/dist/se/grammar-snapshot.d.ts +9 -5
  13. package/dist/se/grammar-snapshot.js +250 -15
  14. package/dist/se/index.d.ts +6 -3
  15. package/dist/se/index.js +14 -3
  16. package/dist/se/meta-model.d.ts +47 -0
  17. package/dist/se/meta-model.js +52 -5
  18. package/dist/se/metric-rules.d.ts +20 -1
  19. package/dist/se/metric-rules.js +97 -58
  20. package/dist/se/module-crossings.d.ts +102 -0
  21. package/dist/se/module-crossings.js +196 -0
  22. package/dist/se/near-duplicate-rules.d.ts +16 -24
  23. package/dist/se/near-duplicate-rules.js +21 -92
  24. package/dist/se/ontology.d.ts +0 -22
  25. package/dist/se/ontology.js +0 -2
  26. package/dist/se/policy.d.ts +6 -0
  27. package/dist/se/policy.js +53 -2
  28. package/dist/se/quality-rules.d.ts +18 -0
  29. package/dist/se/quality-rules.js +51 -9
  30. package/dist/se/readiness.d.ts +25 -3
  31. package/dist/se/readiness.js +39 -21
  32. package/dist/se/rule-help.d.ts +52 -0
  33. package/dist/se/rule-help.js +342 -0
  34. package/dist/se/rules.d.ts +6 -0
  35. package/dist/se/rules.js +303 -125
  36. package/dist/se/schema-quality-rules.d.ts +0 -1
  37. package/dist/se/schema-quality-rules.js +7 -28
  38. package/dist/se/similarity.d.ts +61 -0
  39. package/dist/se/similarity.js +116 -0
  40. package/package.json +4 -2
package/dist/se/rules.js CHANGED
@@ -5,8 +5,9 @@
5
5
  */
6
6
  import { z } from 'zod/v4';
7
7
  import { ElementType, TraceType, TestRefsSchema, RealRefSchema } from './ontology.js';
8
- import { isValidTrace, BOUNDED_PATTERNS, maxOccurs } from './meta-model.js';
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
11
  export const RuleSeverity = z.enum(['error', 'warning', 'info']);
11
12
  /** Candidate target for resolving a violation (e.g. a REQ to satisfy, a TEST to link). */
12
13
  export const ViolationCandidate = z.object({
@@ -25,6 +26,31 @@ export const ViolationContext = z.object({
25
26
  })).optional(),
26
27
  parent_module: z.string().optional(),
27
28
  current_description: z.string().optional(),
29
+ /**
30
+ * CR-SM-304: die WEITEREN Elemente, die derselbe Befund betrifft — heute gesetzt von CR-R03,
31
+ * das einen Befund je Konflikt-MENGE meldet und ihn am kanonisch ersten Ziel verankert. Ohne
32
+ * dieses Feld verschwanden die uebrigen Ziele lautlos: der Leser erfuhr von genau einem der
33
+ * Knoten, an denen sich zwei offene CRs begegnen. Ein Befund darf zusammenfassen, aber nichts
34
+ * unterschlagen.
35
+ */
36
+ also_affects: z.array(z.string()).optional(),
37
+ /**
38
+ * CR-SM-288: die gemessene Zahl der Regel an diesem Element — und das Budget, gegen das sie
39
+ * geschwellt wurde. Zusammen ergeben sie die **Verstoßmasse** `d = max(0, value - threshold)`,
40
+ * die CR-SM-287 lexikographisch rankt. Ohne diese zwei Felder muesste ein Ranker den
41
+ * Meldungstext parsen; CR-SM-280 hat genau diese Texte umgeschrieben, ohne dass sich ein
42
+ * Urteil aenderte.
43
+ *
44
+ * `threshold` ist der **groesste Wert, der noch INNERHALB des Budgets liegt** — nicht der
45
+ * Vergleichswert der Regel. Nur so gilt ueber alle Regeln einheitlich `d > 0 <=> warning`,
46
+ * obwohl die einen bei `>` und die anderen bei `>=` melden (RD-04/MT-01 exklusiv,
47
+ * BW-02/CR-01/MT-02 inklusiv). Ein `info`-Befund innerhalb des Budgets hat damit `d = 0`.
48
+ *
49
+ * Nur die fuenf messenden Regeln setzen sie (RD-04, BW-02, CR-01, MT-01, MT-02); eine Regel
50
+ * ohne Zahl laesst sie weg, statt eine zu erfinden.
51
+ */
52
+ value: z.number().optional(),
53
+ threshold: z.number().optional(),
28
54
  });
29
55
  export const RuleViolation = z.object({
30
56
  rule_id: z.string(),
@@ -206,36 +232,6 @@ function funcMustSatisfyReq(graph) {
206
232
  // ---------------------------------------------------------------------------
207
233
  // R-03: ASIL isolation
208
234
  // ---------------------------------------------------------------------------
209
- function asilIsolation(graph) {
210
- const idx = indexOf(graph);
211
- const violations = [];
212
- const modules = idx.elementsOfType('MOD');
213
- for (const mod of modules) {
214
- const allocated = graph.traces
215
- .filter(t => t.target === mod.id && t.type === 'allocate')
216
- .map(t => idx.byId.get(t.source))
217
- .filter((e) => !!e);
218
- const hasD = allocated.some(e => e.asil === 'D');
219
- const hasQM = allocated.some(e => e.asil === 'QM');
220
- if (hasD && hasQM) {
221
- const dFuncs = allocated.filter(e => e.asil === 'D');
222
- const qmFuncs = allocated.filter(e => e.asil === 'QM');
223
- violations.push({
224
- rule_id: 'R-03',
225
- severity: 'error',
226
- element_id: mod.id,
227
- message: `${mod.id} mixes ASIL-D and ASIL-QM functions`,
228
- fix_hint: 'Separate ASIL-D functions into isolated module',
229
- context: {
230
- element_type: mod.type,
231
- element_name: mod.name,
232
- candidate_targets: toCandidates([...dFuncs, ...qmFuncs]),
233
- },
234
- });
235
- }
236
- }
237
- return violations;
238
- }
239
235
  // ---------------------------------------------------------------------------
240
236
  // R-04: Modulgroesse GEGEN Kreuzungen (nicht „max module size")
241
237
  //
@@ -245,6 +241,13 @@ function asilIsolation(graph) {
245
241
  // Urteilsschwelle, die sich weder per grep noch aus dem Regelnamen ablesen laesst, ist nicht
246
242
  // ueberpruefbar. Jetzt sind es `policy.moduleSize.{coupled,large,crossings}`.
247
243
  // `null` → messen, nicht urteilen: die Regel schweigt.
244
+ //
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.
248
251
  // ---------------------------------------------------------------------------
249
252
  function maxModuleSize(graph, policy) {
250
253
  const idx = indexOf(graph);
@@ -254,25 +257,24 @@ function maxModuleSize(graph, policy) {
254
257
  return violations;
255
258
  const modules = idx.elementsOfType('MOD');
256
259
  for (const mod of modules) {
257
- const allocatedIds = idx.in(mod.id, 'allocate').map(t => t.source);
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)];
258
266
  const allocated = allocatedIds.map(id => idx.byId.get(id)).filter((e) => !!e);
259
267
  const funcCount = allocated.length;
260
268
  if (funcCount <= steps.coupled)
261
269
  continue;
262
- // Count crossing flows: io paths from FUNCs in this module to FUNCs in other modules
263
- const funcIds = new Set(allocatedIds);
264
- // CR-SM-264: nur die io-Kanten, nicht alle Traces je MOD.
265
- const crossings = idx.tracesOfType('io').filter(t => {
266
- const srcIn = funcIds.has(t.source);
267
- const tgtIn = funcIds.has(t.target);
268
- return (srcIn && !tgtIn) || (!srcIn && tgtIn);
269
- }).length;
270
+ // Verschiedene Vertraege (SCHEMA) auf io-Pfaden ueber DIESEN Modulrand CR-SM-276.
271
+ const crossings = crossingContractCount(graph, mod.id);
270
272
  if (funcCount > steps.large && crossings === 0) {
271
273
  violations.push({
272
274
  rule_id: 'R-04',
273
275
  severity: 'info',
274
276
  element_id: mod.id,
275
- message: `${mod.id} has ${funcCount} functions but 0 crossings (cohesive, just large)`,
277
+ message: `${mod.id} has ${funcCount} functions but 0 contracts crossing its module boundary (cohesive, just large)`,
276
278
  fix_hint: `Size alone is not the finding: > ${steps.large} functions without crossing flows is cohesive. Split only if crossings appear`,
277
279
  context: { element_type: mod.type, element_name: mod.name, candidate_targets: toCandidates(allocated) },
278
280
  });
@@ -282,7 +284,7 @@ function maxModuleSize(graph, policy) {
282
284
  rule_id: 'R-04',
283
285
  severity: 'warning',
284
286
  element_id: mod.id,
285
- message: `${mod.id} has ${funcCount} functions and ${crossings} crossing flows (split recommended)`,
287
+ message: `${mod.id} has ${funcCount} functions and ${crossings} distinct contract(s) crossing its module boundary (split recommended)`,
286
288
  fix_hint: `Split module into smaller units to reduce coupling (> ${steps.large} functions AND crossing flows)`,
287
289
  context: { element_type: mod.type, element_name: mod.name, candidate_targets: toCandidates(allocated) },
288
290
  });
@@ -292,7 +294,7 @@ function maxModuleSize(graph, policy) {
292
294
  rule_id: 'R-04',
293
295
  severity: 'warning',
294
296
  element_id: mod.id,
295
- message: `${mod.id} has ${funcCount} functions and ${crossings} crossing flows (high coupling)`,
297
+ message: `${mod.id} has ${funcCount} functions and ${crossings} distinct contract(s) crossing its module boundary (high coupling)`,
296
298
  fix_hint: 'Reduce crossing flows or split module',
297
299
  context: { element_type: mod.type, element_name: mod.name, candidate_targets: toCandidates(allocated) },
298
300
  });
@@ -410,25 +412,81 @@ const CIRCULAR_TRACE_TYPES = new Set(['compose', 'allocate', 'relation']);
410
412
  function noDirectCircular(graph) {
411
413
  const idx = indexOf(graph);
412
414
  const violations = [];
413
- // Direction-independent key: A↔B is ONE cycle, reported once. Keying on the
414
- // message (CR-GC-315 predecessor) never collapsed anything — the two
415
- // directions render different messages, so every cycle was reported twice.
416
- const seen = new Set();
417
- for (const t of graph.traces) {
418
- if (!CIRCULAR_TRACE_TYPES.has(t.type))
419
- continue;
420
- if (!idx.out(t.target, t.type).some(other => other.target === t.source))
421
- continue;
422
- const key = `${t.type}|${[t.source, t.target].sort().join('|')}`;
423
- if (seen.has(key))
424
- continue;
425
- seen.add(key);
426
- violations.push({
427
- rule_id: 'R-12',
428
- severity: 'warning',
429
- element_id: t.source,
430
- message: `Circular ${t.type} between ${t.source} and ${t.target}`,
431
- });
415
+ // CR-SM-285: ECHTE Zyklenerkennung statt Rueckkanten-Test.
416
+ //
417
+ // Bis hierher pruefte die Regel nur, ob eine Kante DIREKT zurueckläuft (A->B und B->A). Ein
418
+ // Dreierzyklus A->B->C->A ging vollstaendig durch das Gate, mit null Fehlern — bei einem
419
+ // Regelnamen, der Azyklizitaet verspricht. Das ist die gefaehrlichere Haelfte: ein Absturz
420
+ // meldet sich, eine Pruefung, die ihren Gegenstand nicht erreicht, BESTAETIGT.
421
+ //
422
+ // Warum es zaehlt: jeder Rollup der Familie setzt Azyklizitaet voraus — `chainOf`/`funcChainOf`
423
+ // in `module-crossings.ts` (CR-SM-282/-283) tragen `seen`-Guards gegen genau diese Endlosschleife,
424
+ // `moduleMetrics`, die RD-04-Breitenzaehlung und gves Container-Sicht ebenso. Unter einem Zyklus
425
+ // ist ein Rollup nicht falsch, sondern UNDEFINIERT. Das R-18-Bein aus CR-SM-283 deckt es NICHT:
426
+ // bei A->B->C->A hat jeder Knoten genau EINEN compose-Elternteil, die Baum-Eigenschaft ist formal
427
+ // erfuellt und die Struktur trotzdem kein Baum.
428
+ //
429
+ // Gemessen ueber neun Familiengraphen: 0 Zyklen jeder Laenge, also 0 -> 0 Befunde. Die Schaerfung
430
+ // laesst keinen bestehenden Graphen fallen; sie schliesst eine Luecke, bevor sie jemand findet.
431
+ //
432
+ // EIN Befund je Zyklus, nicht je Kante (Klasse CR-SM-242): bei einem Ring der Laenge n sind nicht
433
+ // n Kanten falsch, es ist EINE Struktur. Verankert am kleinsten Knoten des Rings — determiniert,
434
+ // unabhaengig davon, wo die DFS eingestiegen ist (Gate 5).
435
+ for (const type of [...CIRCULAR_TRACE_TYPES].sort()) {
436
+ const adj = new Map();
437
+ for (const t of graph.traces) {
438
+ if (t.type !== type)
439
+ continue;
440
+ const list = adj.get(t.source);
441
+ if (list)
442
+ list.push(t.target);
443
+ else
444
+ adj.set(t.source, [t.target]);
445
+ }
446
+ for (const list of adj.values())
447
+ list.sort();
448
+ const WHITE = 0, GREY = 1, BLACK = 2;
449
+ const color = new Map();
450
+ const stack = [];
451
+ const found = new Map(); // kanonischer Schluessel -> Ring in Reihenfolge
452
+ const visit = (u) => {
453
+ color.set(u, GREY);
454
+ stack.push(u);
455
+ for (const v of adj.get(u) ?? []) {
456
+ if (color.get(v) === GREY) {
457
+ const ring = stack.slice(stack.indexOf(v));
458
+ // Kanonisch: am kleinsten Knoten rotiert, damit derselbe Ring aus jedem Einstieg
459
+ // denselben Schluessel und dieselbe Meldung ergibt.
460
+ let min = 0;
461
+ for (let i = 1; i < ring.length; i++)
462
+ if (ring[i] < ring[min])
463
+ min = i;
464
+ const rotated = [...ring.slice(min), ...ring.slice(0, min)];
465
+ found.set(rotated.join('|'), rotated);
466
+ }
467
+ else if (color.get(v) === undefined) {
468
+ visit(v);
469
+ }
470
+ }
471
+ color.set(u, BLACK);
472
+ stack.pop();
473
+ };
474
+ for (const u of [...adj.keys()].sort())
475
+ if (color.get(u) === undefined)
476
+ visit(u);
477
+ for (const ring of [...found.keys()].sort().map(k => found.get(k))) {
478
+ const el = idx.byId.get(ring[0]);
479
+ violations.push({
480
+ rule_id: 'R-12',
481
+ severity: 'warning',
482
+ element_id: ring[0],
483
+ message: ring.length === 2
484
+ ? `Circular ${type} between ${ring[0]} and ${ring[1]}`
485
+ : `Circular ${type} over ${ring.length} elements: ${[...ring, ring[0]].join(' -> ')}`,
486
+ fix_hint: `Break the ${type} ring — every rollup (module boundary, decomposition breadth, container view) is undefined while it exists`,
487
+ context: { element_type: el?.type, element_name: el?.name },
488
+ });
489
+ }
432
490
  }
433
491
  return violations;
434
492
  }
@@ -510,16 +568,46 @@ function noPrematureDecomposition(graph) {
510
568
  // RD-04: Decomposition breadth (CR-SM-221)
511
569
  // ---------------------------------------------------------------------------
512
570
  /**
513
- * Max children on one decomposition level. The 7–11 convention existed only as
571
+ * Max children on one decomposition level. The 7±2 convention existed only as
514
572
  * prose ("darüber func-of-func"); below 7 is a guideline, not a violation, so only
515
573
  * the upper bound is a rule.
516
574
  *
517
575
  * Counted per (parent, kind) — a MOD that both holds 12 FUNCs and composes 12
518
576
  * sub-MODs has two breadth problems, not one. (The spike reference merged both into
519
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).
520
594
  */
521
- const DECOMPOSITION_BREADTH_MAX = 11;
522
- function decompositionBreadth(graph) {
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 [];
523
611
  const idx = indexOf(graph);
524
612
  // CR-SM-264: `typeOf` und `byId` baute diese Regel je Aufruf selbst — der Index hat beide.
525
613
  const typeOf = idx;
@@ -539,48 +627,36 @@ function decompositionBreadth(graph) {
539
627
  if (t.type === 'compose' && src === 'FUNC' && tgt === 'FUNC') {
540
628
  bump(t.source, 'sub-FUNC');
541
629
  }
542
- else if (t.type === 'allocate' && tgt === 'MOD' && src === 'FUNC') {
543
- bump(t.target, 'allocated FUNC');
544
- }
545
630
  else if (t.type === 'compose' && (src === 'SYS' || src === 'MOD') && tgt === 'MOD') {
546
631
  bump(t.source, 'sub-MOD');
547
632
  }
548
633
  }
634
+ // CR-SM-282: der FUNC-Wurzelwald als eigene Blackbox, verankert am SYS-Knoten.
635
+ const composedFuncs = new Set(graph.traces
636
+ .filter(t => t.type === 'compose' && typeOf.typeOf(t.source) === 'FUNC' && typeOf.typeOf(t.target) === 'FUNC')
637
+ .map(t => t.target));
638
+ const systems = idx.elementsOfType('SYS');
639
+ if (systems.length === 1) {
640
+ const rootFuncs = idx.elementsOfType('FUNC').filter(f => !composedFuncs.has(f.id)).length;
641
+ if (rootFuncs > 0)
642
+ counts.set(`${systems[0].id}\u0000root FUNC`, { parentId: systems[0].id, kind: 'root FUNC', n: rootFuncs });
643
+ }
549
644
  return [...counts.values()]
550
- .filter(c => c.n > DECOMPOSITION_BREADTH_MAX)
645
+ .filter(c => c.n > max.warning)
551
646
  .map(c => {
552
647
  const parent = byId.get(c.parentId);
553
648
  return {
554
649
  rule_id: 'RD-04',
555
650
  severity: 'warning',
556
651
  element_id: c.parentId,
557
- message: `${c.parentId} has ${c.n} ${c.kind} children on one level (>${DECOMPOSITION_BREADTH_MAX})`,
652
+ message: `${c.parentId} has ${c.n} ${c.kind} children on one level (>${max.warning})`,
558
653
  fix_hint: 'Introduce an intermediate level (func-of-func / sub-MOD)',
559
- context: { element_type: parent?.type, element_name: parent?.name },
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 },
560
656
  };
561
657
  });
562
658
  }
563
659
  // ---------------------------------------------------------------------------
564
- // R-14: UC must have at least 1 compose trace (→ FCHAIN or REQ) (CR-117)
565
- // ---------------------------------------------------------------------------
566
- function ucMustHaveCompose(graph) {
567
- const idx = indexOf(graph);
568
- const ucs = idx.elementsOfType('UC');
569
- return ucs
570
- .filter(uc => idx.out(uc.id, 'compose').length === 0)
571
- .map(uc => ({
572
- rule_id: 'R-14',
573
- severity: 'warning',
574
- element_id: uc.id,
575
- message: `${uc.id} has no compose traces (no scenarios or requirements)`,
576
- fix_hint: 'Add FCHAIN or REQ via compose trace',
577
- context: {
578
- element_type: uc.type,
579
- element_name: uc.name,
580
- },
581
- }));
582
- }
583
- // ---------------------------------------------------------------------------
584
660
  // R-15: FCHAIN completeness — a chain needs functions BELOW it and a use case
585
661
  // ABOVE it (CR-117, second leg CR-SM-249).
586
662
  //
@@ -663,7 +739,7 @@ function sysMustHaveCompose(graph) {
663
739
  severity: 'warning',
664
740
  element_id: sys.id,
665
741
  message: `${sys.id} has no compose traces (empty system)`,
666
- fix_hint: 'Add UC, REQ, or MOD via compose trace',
742
+ fix_hint: 'Add at least one compose trace the system decomposes into nothing',
667
743
  context: {
668
744
  element_type: sys.type,
669
745
  element_name: sys.name,
@@ -729,30 +805,125 @@ function validTracePattern(graph) {
729
805
  // Schlupfloch geworden — eine Kante mit `category: 'audit'` haette die Matrix komplett
730
806
  // umgangen. Das Attribut existiert nicht mehr (ontology.ts), hier faellt der Leser mit.
731
807
  return graph.traces
732
- .filter(t => {
808
+ .flatMap((t) => {
733
809
  const src = typeById.get(t.source);
734
810
  const tgt = typeById.get(t.target);
735
811
  if (!src || !tgt)
736
- return false; // dangling endpoint → R-08, not R-18
812
+ return []; // dangling endpoint → R-08, not R-18
737
813
  // CR-SM-266 B: die where-Patterns lesen die Kinds des betroffenen Endes mit.
738
- return !isValidTrace({
814
+ // CR-SM-280: und der GRUND der Ablehnung kommt mit heraus, statt zu `false` zu kollabieren.
815
+ const why = traceRejection({
739
816
  source: src, target: tgt, type: t.type, label: t.label,
740
817
  sourceKinds: idx.byId.get(t.source)?.kinds,
741
818
  targetKinds: idx.byId.get(t.target)?.kinds,
742
819
  });
820
+ if (!why)
821
+ return [];
822
+ const { detail, hint } = explainRejection(why, t, src, tgt);
823
+ return [{
824
+ rule_id: 'R-18',
825
+ severity: 'error',
826
+ element_id: t.source,
827
+ message: `Invalid trace ${t.source} -${t.type}-> ${t.target}: ${detail}`,
828
+ fix_hint: hint,
829
+ }];
743
830
  })
744
- .map((t) => ({
745
- rule_id: 'R-18',
746
- severity: 'error',
747
- element_id: t.source,
748
- message: `Invalid trace ${t.source} -${t.type}-> ${t.target}: ` +
749
- `${typeById.get(t.source)} → ${typeById.get(t.target)} is not a valid ${t.type} pattern`,
750
- fix_hint: 'Use a trace type whose TRACE_PATTERNS allows this source/target element-type pair',
751
- }))
752
- .concat(cardinalityViolations(graph));
831
+ .concat(cardinalityViolations(graph))
832
+ .concat(singleComposeParent(graph));
833
+ }
834
+ /**
835
+ * CR-SM-280: aus dem Ablehnungsgrund den Satz bauen, den der Leser braucht.
836
+ *
837
+ * Der `no-pattern`-Zweig traegt den Wortlaut von vorher UNVERAENDERT dort war die alte
838
+ * Meldung richtig. Nur die beiden `where`-Zweige sind neu, und sie unterscheiden zwei
839
+ * Ursachen, die vorher zu einer verschmolzen waren: `kinds` fehlt (die BQ-07-Migration) vs.
840
+ * `kinds` steht da und passt nicht (Kante heben oder Kind korrigieren). Wer das nicht
841
+ * unterscheiden kann, sucht am falschen Ende.
842
+ */
843
+ function explainRejection(why, t, src, tgt) {
844
+ if (why.reason === 'no-pattern') {
845
+ return {
846
+ detail: `${src} → ${tgt} is not a valid ${t.type} pattern`,
847
+ hint: 'Use a trace type whose TRACE_PATTERNS allows this source/target element-type pair',
848
+ };
849
+ }
850
+ const { where } = why.pattern;
851
+ // Der Knoten wird NUR ueber seine Id benannt, nicht zusaetzlich ueber seinen Typ: der
852
+ // ElementUid-Kanon (`<TYPE>-<slug>`, ONTOLOGY 8.0.0) traegt den Typ bereits im Namen, und
853
+ // "REQ REQ-f2-riegel" liest sich wie ein Fehler im Werkzeug.
854
+ const end = where.on === 'source' ? t.source : t.target;
855
+ const allowed = [...where.allowed].sort().join(', ');
856
+ const admits = `the ${src} → ${tgt} ${t.type} pattern admits only ${where.field} {${allowed}}`;
857
+ return why.reason === 'kinds-undeclared'
858
+ ? {
859
+ detail: `${admits}, and ${end} declares no ${where.field}`,
860
+ hint: `Declare '${where.field}' on ${end} — undeclared is not "any" (CR-SM-266 B); it must be a subset of {${allowed}}`,
861
+ }
862
+ : {
863
+ detail: `${admits}, but ${end} declares {${[...why.declared].sort().join(', ')}}`,
864
+ hint: `Correct '${where.field}' on ${end}, or use a source type whose ${t.type} pattern admits them (FCHAIN carries every kind)`,
865
+ };
866
+ }
867
+ /**
868
+ * CR-SM-283 — das VIERTE BEIN von R-18: die `compose`-Baeume sind Baeume.
869
+ *
870
+ * `TRACE_PATTERNS` begrenzt heute nur die KINDER einer Kante, nie die ELTERN
871
+ * (`se:top-level`: „the cardinalities cap children, not parents"). Ein Element kann damit zwei
872
+ * `compose`-Elternteile bekommen, und `graph_mutate` legt so eine Kante anstandslos an.
873
+ *
874
+ * Warum das hart sein muss, obwohl es NIE vorgekommen ist (0 Verstoesse ueber fuenf
875
+ * Familiengraphen): jeder Rollup dieser Familie ist unter Verletzung nicht falsch, sondern
876
+ * UNDEFINIERT — `moduleCrossings.byModule`/`byFunc` (CR-SM-282/-283), die Breiten-Zaehlung von
877
+ * RD-04, die Container-Sicht von gve. Ein mehrdeutiges Rollup liefert keine schlechtere Zahl,
878
+ * sondern keine. Das ist der Unterschied zu einer Regel, die „etwas prueft, das nie vorkommt".
879
+ *
880
+ * Kein eigener Regel-Eintrag, aus demselben Grund wie beim zweiten und dritten Bein: die
881
+ * Aussage ist dieselbe — „diese Kante ist nach dem Meta-Modell nicht zulaessig" —, und der
882
+ * naechste Schritt ist derselbe: eine der beiden Kanten weg.
883
+ *
884
+ * EIN Befund je KIND, nicht je ueberzaehliger Kante: bei n Eltern sind nicht n-1 Kanten
885
+ * „falsch", es ist EINE Entscheidung offen (welcher Elternteil), und n-1 Befunde trieben den
886
+ * Zaehler ueber den Nenner-Beitrag (die Fehlmessung aus CR-SM-242).
887
+ */
888
+ function singleComposeParent(graph) {
889
+ const idx = indexOf(graph);
890
+ const parents = new Map();
891
+ for (const t of graph.traces) {
892
+ if (t.type !== 'compose')
893
+ continue;
894
+ const src = idx.typeOf(t.source);
895
+ const tgt = idx.typeOf(t.target);
896
+ if (!((src === 'FUNC' && tgt === 'FUNC') || (src === 'MOD' && tgt === 'MOD')))
897
+ continue;
898
+ let set = parents.get(t.target);
899
+ if (!set) {
900
+ set = new Set();
901
+ parents.set(t.target, set);
902
+ }
903
+ set.add(t.source);
904
+ }
905
+ return [...parents.entries()]
906
+ .filter(([, ps]) => ps.size > 1)
907
+ // Kanonisch statt in Trace-Reihenfolge — sonst haengt die Befund-Sequenz an der
908
+ // Eingabereihenfolge (Reihenfolgen-Invariante, se-grammar-invariant.test.ts).
909
+ .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
910
+ .map(([child, ps]) => {
911
+ const el = idx.byId.get(child);
912
+ const sorted = [...ps].sort();
913
+ return {
914
+ rule_id: 'R-18',
915
+ severity: 'error',
916
+ element_id: child,
917
+ message: `${child} has ${sorted.length} compose parents (${sorted.join(', ')}): the compose trees must stay trees`,
918
+ fix_hint: `Keep exactly one compose parent for ${child} and delete the other edge(s) — every rollup (module boundary, decomposition breadth, container view) is undefined while there are two`,
919
+ context: { element_type: el?.type, element_name: el?.name },
920
+ };
921
+ });
753
922
  }
754
923
  /**
755
924
  * CR-SM-266b — das ZWEITE BEIN von R-18: die Kardinalitaets-Obergrenzen.
925
+ * CR-SM-271 Teil 2 — das DRITTE: die durchgesetzten Untergrenzen (heute genau
926
+ * `FLOW -relation-> SCHEMA [1..1]`; SC-04 ist damit ersatzlos entfallen).
756
927
  *
757
928
  * Bewusst dieselbe `rule_id` und keine eigene, nach dem Muster von R-10 (Produzent/Konsument)
758
929
  * und R-15 seit CR-SM-249: die Aussage ist dieselbe — "diese Kante ist nach dem Meta-Modell
@@ -789,6 +960,31 @@ function cardinalityViolations(graph) {
789
960
  });
790
961
  }
791
962
  }
963
+ // Untergrenzen (CR-SM-271 Teil 2): ein FLOW ohne SCHEMA ist untypisiert, nicht unfertig —
964
+ // der frueher von SC-04 (warning) gemeldete Fall ist jetzt Grammatik und meldet als error.
965
+ // EIN Befund je Quellknoten, mit candidate_targets im Kontext (wie SC-04 sie trug), damit
966
+ // ein Agent die Kante aus dem Payload heraus ergaenzen kann.
967
+ for (const p of REQUIRED_PATTERNS) {
968
+ const candidates = idx.elementsOfType(p.target);
969
+ for (const el of idx.elementsOfType(p.source)) {
970
+ const matching = idx.out(el.id, p.type).filter(t => idx.typeOf(t.target) === p.target);
971
+ if (matching.length > 0)
972
+ continue;
973
+ violations.push({
974
+ rule_id: 'R-18',
975
+ severity: 'error',
976
+ element_id: el.id,
977
+ message: `${el.id} has no ${p.type} trace to a ${p.target} — the meta-model requires exactly one`,
978
+ fix_hint: `Add a ${p.type} trace from ${el.id} to the ${p.target} that defines its contract` +
979
+ ` (create the ${p.target} in the same batch if it does not exist yet)`,
980
+ context: {
981
+ element_type: el.type,
982
+ element_name: el.name,
983
+ candidate_targets: candidates.map(c => ({ id: c.id, type: c.type, name: c.name })),
984
+ },
985
+ });
986
+ }
987
+ }
792
988
  return violations;
793
989
  }
794
990
  // ---------------------------------------------------------------------------
@@ -1133,30 +1329,15 @@ function schemaMustHaveSchemaRef(graph) {
1133
1329
  // Symbol is optional on a realRef (a geometry file has none) — presence = a valid
1134
1330
  // realRef with a file. RESOLUTION (file on disk) is a consumer/RC concern.
1135
1331
  // ---------------------------------------------------------------------------
1136
- function physicalModMustHaveRealRef(graph) {
1137
- const idx = indexOf(graph);
1138
- return idx.elementsOfType('MOD')
1139
- .filter(e => e.attributes?.kind === 'physical')
1140
- .filter(e => e.attributes?.concept !== true && e.attributes?.external !== true)
1141
- .filter(e => !RealRefSchema.safeParse(e.attributes?.realRef).success)
1142
- .map(mod => ({
1143
- rule_id: 'R-27',
1144
- severity: 'warning',
1145
- element_id: mod.id,
1146
- message: `${mod.id} is a physical MOD without a valid realRef (CAD/geometry) binding`,
1147
- fix_hint: 'Add attributes.realRef {file, symbol?, lang?} pointing at the CAD/geometry artefact, or set attributes.concept:true / attributes.external:true',
1148
- context: { element_type: mod.type, element_name: mod.name },
1149
- }));
1150
- }
1151
1332
  // ---------------------------------------------------------------------------
1152
1333
  // R-28 (Ebenen-Präsenz) ist mit CR-SM-247 ersatzlos entfallen — nicht weil sie falsch war,
1153
1334
  // sondern weil sie seit CR-GC-366 doppelt ist. Sie schloss das Vacuous-Complete-Loch (mit 0/1
1154
1335
  // FUNC feuert keine Pro-Element-Regel, alles liest "fertig"); beide Hälften trägt heute je eine
1155
1336
  // präzisere Regel: kein FLOW → R-31 (io-Verdrahtung, je FUNC statt einmal je Graph) + IO-01,
1156
- // kein SCHEMA → SC-04 (je FLOW). Probe (SYS+UC+FCHAIN+3 FUNC, ohne FLOW/SCHEMA): 28 Befunde aus
1337
+ // kein SCHEMA → damals SC-04 (je FLOW), seit CR-SM-271 das R-18-Untergrenzen-Bein. Probe
1338
+ // (SYS+UC+FCHAIN+3 FUNC, ohne FLOW/SCHEMA): 28 Befunde aus
1157
1339
  // 18 Regeln, ohne R-28 bleiben 17 — das Loch bleibt zu. Gemessen feuerte sie 1× / 0× an den
1158
- // Selbstmodellen, dort neben 14× IO-01 und 10× R-31. Bewusst in Kauf genommen: die
1159
- // SCHEMA-Absenz rutscht von PDR nach CDR, weil SC-04 erst feuert, wenn FLOWs existieren.
1340
+ // Selbstmodellen, dort neben 14× IO-01 und 10× R-31.
1160
1341
  // ---------------------------------------------------------------------------
1161
1342
  // ---------------------------------------------------------------------------
1162
1343
  // R-30: jedes BLATT gehoert in eine Wirkkette (CR-GC-366, Grundgesamtheit
@@ -1263,10 +1444,8 @@ export const V3_RULES = [
1263
1444
  { id: 'R-02', name: 'FUNC must satisfy REQ', severity: 'warning', evaluate: funcMustSatisfyReq, domain: ['FUNC'] },
1264
1445
  // CR-SM-243: domain ist MOD, nicht FUNC — die Regel iteriert Module und meldet am Modul,
1265
1446
  // das ASIL-D und QM mischt. Die FUNCs sind der Anlass des Urteils, nicht seine Traeger.
1266
- { id: 'R-03', name: 'ASIL isolation', severity: 'error', evaluate: asilIsolation, domain: ['MOD'] },
1267
1447
  { id: 'R-04', name: 'Module size relative to crossing flows', severity: 'warning', evaluate: maxModuleSize, domain: ['MOD'] },
1268
1448
  { id: 'R-05', name: 'TEST must verify REQ', severity: 'warning', evaluate: testMustVerifyReq, domain: ['TEST'] },
1269
- { id: 'R-14', name: 'UC must have compose', severity: 'warning', evaluate: ucMustHaveCompose, domain: ['UC'] },
1270
1449
  { id: 'R-15', name: 'FCHAIN completeness', severity: 'warning', evaluate: fchainCompleteness, domain: ['FCHAIN'] },
1271
1450
  { id: 'R-16', name: 'ACTOR must have io', severity: 'warning', evaluate: actorMustHaveTrace, domain: ['ACTOR'] },
1272
1451
  { id: 'R-17', name: 'SYS must have compose', severity: 'warning', evaluate: sysMustHaveCompose, domain: ['SYS'] },
@@ -1284,7 +1463,6 @@ export const V3_RULES = [
1284
1463
  { id: 'R-22', name: 'FUNC must be allocated to MOD', severity: 'warning', evaluate: funcMustBeAllocated, domain: ['FUNC'] },
1285
1464
  { id: 'R-23', name: 'MOD must have allocated FUNC', severity: 'warning', evaluate: modMustHaveAllocatedFunc, domain: ['MOD'] },
1286
1465
  { id: 'R-26', name: 'SCHEMA must have realRef', severity: 'warning', evaluate: schemaMustHaveSchemaRef, domain: ['SCHEMA'] },
1287
- { id: 'R-27', name: 'Physical MOD must have realRef', severity: 'warning', evaluate: physicalModMustHaveRealRef, domain: ['MOD'] },
1288
1466
  { id: 'RD-01', name: 'Unresolved requirement', severity: 'warning', evaluate: unresolvedRequirement, domain: ['REQ'] },
1289
1467
  { id: 'RD-02', name: 'Decomposition consistency', severity: 'warning', evaluate: decompositionConsistency, domain: ['REQ'] },
1290
1468
  { id: 'RD-03', name: 'No premature decomposition', severity: 'info', evaluate: noPrematureDecomposition, domain: ['REQ'] },
@@ -16,6 +16,5 @@ import type { OntologyGraph } from './ontology.js';
16
16
  import type { RuleDefinition, RuleViolation } from './rules.js';
17
17
  import type { MetricPolicy } from './policy.js';
18
18
  export declare function sc02IsReferenced(graph: OntologyGraph): RuleViolation[];
19
- export declare function sc04FlowHasSchema(graph: OntologyGraph): RuleViolation[];
20
19
  export declare const SC_RULES: RuleDefinition[];
21
20
  export declare function evaluateSCRules(graph: OntologyGraph, policy: MetricPolicy): RuleViolation[];
@@ -20,40 +20,19 @@ export function sc02IsReferenced(graph) {
20
20
  }));
21
21
  }
22
22
  // ---------------------------------------------------------------------------
23
- // SC-04: FLOW must reference a SCHEMA (CR-SM-226) the sharp per-FLOW
24
- // inverse of SC-02. SC-02 catches an orphan SCHEMA (nobody uses it); SC-04
25
- // catches a FLOW with no data contract at all — until now only caught
26
- // loosely, around the SC-02-adjacent CDR completeness leg, never as its own
27
- // rule.
23
+ // SC-04 (FLOW must reference a SCHEMA, CR-SM-226) ist mit CR-SM-271 Teil 2 ersatzlos
24
+ // entfallen: die Untergrenze ist Grammatik geworden (`FLOW -relation-> SCHEMA [1..1]`,
25
+ // meta-model.ts REQUIRED_PATTERNS) und meldet als error aus dem R-18-Kardinalitaets-Bein
26
+ // (rules.ts cardinalityViolations) Grammatik ersetzt Regel, kein Regel-Loch.
28
27
  // ---------------------------------------------------------------------------
29
- export function sc04FlowHasSchema(graph) {
30
- const schemas = graph.elements.filter(e => e.type === 'SCHEMA');
31
- return graph.elements
32
- .filter(e => e.type === 'FLOW')
33
- .filter(f => !graph.traces.some(t => t.source === f.id && t.type === 'relation' &&
34
- graph.elements.some(e => e.id === t.target && e.type === 'SCHEMA')))
35
- .map(f => ({
36
- rule_id: 'SC-04',
37
- severity: 'warning',
38
- element_id: f.id,
39
- message: `${f.id} has no SCHEMA binding`,
40
- fix_hint: 'Link a SCHEMA via relation trace to define this FLOW\'s data contract',
41
- context: {
42
- element_type: f.type,
43
- element_name: f.name,
44
- candidate_targets: schemas.map(s => ({ id: s.id, type: s.type, name: s.name })),
45
- },
46
- }));
47
- }
48
28
  // ---------------------------------------------------------------------------
49
29
  // Aggregated array & convenience runner
50
30
  // ---------------------------------------------------------------------------
51
31
  export const SC_RULES = [
52
- // Die Asymmetrie ist gewollt (CR-SM-242): SC-02 iteriert ueber SCHEMA („wird dieses SCHEMA
53
- // benutzt"), SC-04 ueber FLOW („hat dieser FLOW einen Datenvertrag"). Gleiches Thema,
54
- // verschiedene Grundgesamtheit. SC-04 als ['SCHEMA'] zog 46 FLOW-Verstoesse gegen 20 SCHEMA.
32
+ // Die Asymmetrie zur FLOW-Seite ist gewollt (CR-SM-242): SC-02 iteriert ueber SCHEMA
33
+ // („wird dieses SCHEMA benutzt"); die Gegenrichtung je FLOW („hat dieser FLOW einen
34
+ // Datenvertrag") traegt seit CR-SM-271 das R-18-Untergrenzen-Bein.
55
35
  { id: 'SC-02', name: 'Schema referenced by FLOW', severity: 'warning', evaluate: sc02IsReferenced, domain: ['SCHEMA'] },
56
- { id: 'SC-04', name: 'FLOW has SCHEMA binding', severity: 'warning', evaluate: sc04FlowHasSchema, domain: ['FLOW'] },
57
36
  ];
58
37
  // CR-SM-236: `policy` wird durchgereicht, auch wo diese Familie heute keine Schwelle hat —
59
38
  // ein Sonderweg je Familie waere genau der zweite Pfad, den der Regelsatz verbietet.