@sigloch/contracts 6.1.0 → 9.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.
package/dist/se/rules.js CHANGED
@@ -5,7 +5,8 @@
5
5
  */
6
6
  import { z } from 'zod/v4';
7
7
  import { ElementType, TraceType, TestRefsSchema, RealRefSchema } from './ontology.js';
8
- import { isValidTrace } from './meta-model.js';
8
+ import { isValidTrace, BOUNDED_PATTERNS, maxOccurs } from './meta-model.js';
9
+ import { indexOf } from './graph-index.js';
9
10
  export const RuleSeverity = z.enum(['error', 'warning', 'info']);
10
11
  /** Candidate target for resolving a violation (e.g. a REQ to satisfy, a TEST to link). */
11
12
  export const ViolationCandidate = z.object({
@@ -44,21 +45,25 @@ export const RuleViolation = z.object({
44
45
  * Blaettern (so schon R-30 seit CR-SM-249, so R-20 seit CR-210).
45
46
  *
46
47
  * Bewusst EIN Helper statt drei Kopien: eine zweite Definition von "Blatt" waere ein
47
- * zweiter Weg zu derselben Aussage, und der billigere gewinnt immer.
48
+ * zweiter Weg zu derselben Aussage, und der billigere gewinnt immer. CR-SM-263 haengt als
49
+ * vierter Nutzer daran (MT-02, `metric-rules.ts`) — deshalb exportiert.
48
50
  */
49
- function decomposedFuncs(graph) {
50
- const funcIds = new Set(graph.elements.filter(e => e.type === 'FUNC').map(f => f.id));
51
+ export function decomposedFuncs(graph) {
52
+ const idx = indexOf(graph);
53
+ const funcIds = new Set(idx.elementsOfType('FUNC').map(f => f.id));
51
54
  return new Set(graph.traces
52
55
  .filter(t => t.type === 'compose' && funcIds.has(t.source) && funcIds.has(t.target))
53
56
  .map(t => t.source));
54
57
  }
55
58
  function findParentModule(graph, elementId) {
56
- const trace = graph.traces.find(t => t.source === elementId && t.type === 'allocate');
59
+ const idx = indexOf(graph);
60
+ const trace = idx.out(elementId, 'allocate')[0];
57
61
  return trace?.target;
58
62
  }
59
63
  /** Get all outgoing traces of a given type from an element. */
60
64
  function outgoingTraces(graph, elementId, traceType) {
61
- return graph.traces.filter(t => t.source === elementId && (!traceType || t.type === traceType));
65
+ const idx = indexOf(graph);
66
+ return traceType === undefined ? idx.outAll(elementId) : idx.out(elementId, traceType);
62
67
  }
63
68
  /** Tokenize id/name/description into a lowercase token set (drops tokens < 3 chars). */
64
69
  function overlapTokens(...parts) {
@@ -69,35 +74,69 @@ function overlapTokens(...parts) {
69
74
  .split(/[^a-z0-9]+/)
70
75
  .filter(t => t.length >= 3));
71
76
  }
77
+ /**
78
+ * CR-SM-261: Tokenmenge je Element, einmal je Prozess statt einmal je Vergleich.
79
+ *
80
+ * `toCandidates` rief `overlapTokens` frueher INNERHALB des Sort-Komparators auf. Ein Sort
81
+ * ueber k Kandidaten macht rund k·log k Vergleiche, jeder Vergleich hat beide Seiten neu
82
+ * tokenisiert (join + toLowerCase + Regex-Split) — und das je meldendem Element. Bei R-31 mit
83
+ * 33 Befunden gegen 46 FLOWs sind das ueber 11 000 Tokenisierungen fuer 46 verschiedene
84
+ * Elemente. Das Profil aus CR-SM-260 hat R-31 deshalb mit 25,9 % und n^2,10 gemessen, obwohl
85
+ * die Regel selbst nur Mengen aufbaut: die Kosten lagen vollstaendig in der Kandidatenliste.
86
+ *
87
+ * Der Schluessel ist das Element-OBJEKT, nicht die id — zwei Graphen im selben Prozess (der
88
+ * Probegraph aus `applyRule` neben dem Original) teilen sich ids, aber nicht die Objekte.
89
+ * `WeakMap` gibt die Eintraege frei, sobald der Graph es tut; es entsteht kein Cache, den
90
+ * jemand invalidieren muesste, und damit auch keine zweite Wahrheit ueber ein Element.
91
+ */
92
+ const TOKEN_CACHE = new WeakMap();
93
+ function tokensOf(e) {
94
+ let t = TOKEN_CACHE.get(e);
95
+ if (t === undefined) {
96
+ t = overlapTokens(e.id, e.name, e.description);
97
+ TOKEN_CACHE.set(e, t);
98
+ }
99
+ return t;
100
+ }
72
101
  /**
73
102
  * Map elements to ViolationCandidate format. When `ref` is given, rank candidates
74
103
  * by id/name/description token overlap with `ref` (descending) so the most relevant
75
104
  * target is first — e.g. REQ-bootstrap → TEST-bootstrap (CR-GC-203 item 3). Ranking
76
105
  * is a hint, not an auto-link; semantic confirmation stays with the caller.
77
106
  */
78
- function toCandidates(elements, ref) {
107
+ function toCandidates(
108
+ // CR-SM-264: `readonly`, weil die Kandidaten jetzt aus `idx.elementsOfType()` kommen. Seit
109
+ // CR-SM-261 sortiert diese Funktion ohnehin eine eigene Liste (decorate-sort-undecorate) und
110
+ // fasst die Eingabe nicht mehr an — der mutable Typ war schon vorher zu weit.
111
+ elements, ref) {
79
112
  if (!ref)
80
113
  return elements.map(e => ({ id: e.id, type: e.type, name: e.name }));
81
- const refTokens = overlapTokens(ref.id, ref.name, ref.description);
114
+ const refTokens = tokensOf(ref);
82
115
  const score = (e) => {
83
116
  let n = 0;
84
- for (const tok of overlapTokens(e.id, e.name, e.description))
117
+ for (const tok of tokensOf(e))
85
118
  if (refTokens.has(tok))
86
119
  n++;
87
120
  return n;
88
121
  };
89
- return [...elements]
90
- .sort((a, b) => score(b) - score(a))
91
- .map(e => ({ id: e.id, type: e.type, name: e.name }));
122
+ // CR-SM-261: EINMAL bewerten, dann sortieren. Vorher stand `score` im Komparator und lief
123
+ // damit rund 2·k·log k mal statt k mal. Der Index ist der Gleichstands-Tiebreak und haelt die
124
+ // Ausgabe byte-identisch: `Array.prototype.sort` ist seit ES2019 stabil, die alte Fassung hat
125
+ // bei gleichem Score also die Eingangsreihenfolge behalten — diese tut es explizit.
126
+ return elements
127
+ .map((e, i) => ({ e, i, s: score(e) }))
128
+ .sort((a, b) => b.s - a.s || a.i - b.i)
129
+ .map(({ e }) => ({ id: e.id, type: e.type, name: e.name }));
92
130
  }
93
131
  // ---------------------------------------------------------------------------
94
132
  // R-01: Every REQ must have at least one verify trace
95
133
  // ---------------------------------------------------------------------------
96
134
  function reqMustHaveVerification(graph) {
97
- const reqs = graph.elements.filter(e => e.type === 'REQ');
98
- const tests = graph.elements.filter(e => e.type === 'TEST');
135
+ const idx = indexOf(graph);
136
+ const reqs = idx.elementsOfType('REQ');
137
+ const tests = idx.elementsOfType('TEST');
99
138
  return reqs
100
- .filter(req => !graph.traces.some(t => t.target === req.id && t.type === 'verify'))
139
+ .filter(req => idx.in(req.id, 'verify').length === 0)
101
140
  .map(req => ({
102
141
  rule_id: 'R-01',
103
142
  severity: 'error',
@@ -116,14 +155,15 @@ function reqMustHaveVerification(graph) {
116
155
  // R-02: Every FUNC must satisfy at least one REQ
117
156
  // ---------------------------------------------------------------------------
118
157
  function funcMustSatisfyReq(graph) {
158
+ const idx = indexOf(graph);
119
159
  // CR-SM-256: Grundgesamtheit sind die BLAETTER. Ein zerlegter FUNC erfuellt keine REQ,
120
160
  // die nicht schon eine seiner Blatt-Funktionen erfuellt — wer ihn befriedigen will,
121
161
  // muss eine satisfy-Kante erfinden, die dieselbe REQ ein zweites Mal beansprucht.
122
162
  // Am graphcode-Selbstmodell (graphVersion 171) melden 12 der 13 Bloecke R-02 UND R-31,
123
163
  // also dieselbe Ursache zweimal gezaehlt — die Klasse CR-SM-235/-242.
124
164
  const decomposed = decomposedFuncs(graph);
125
- const funcs = graph.elements.filter(e => e.type === 'FUNC' && !decomposed.has(e.id));
126
- const allReqs = graph.elements.filter(e => e.type === 'REQ');
165
+ const funcs = idx.elementsOfType('FUNC').filter(e => !decomposed.has(e.id));
166
+ const allReqs = idx.elementsOfType('REQ');
127
167
  // CR-GC-366: der Filter prueft den ZIELTYP. Vorher war er zieltyp-blind (`type === 'satisfy'`),
128
168
  // womit ein `FUNC -satisfy-> UC` die Regel stumm schaltete, obwohl kein Requirement erfuellt war —
129
169
  // der Regelname versprach mehr als der Code pruefte. Am graphcode-Selbstmodell verdeckte das 6 von
@@ -132,15 +172,15 @@ function funcMustSatisfyReq(graph) {
132
172
  // Ziele das Meta-Modell gerade zulaesst.
133
173
  const reqIds = new Set(allReqs.map(r => r.id));
134
174
  return funcs
135
- .filter(fn => !graph.traces.some(t => t.source === fn.id && t.type === 'satisfy' && reqIds.has(t.target)))
175
+ .filter(fn => !idx.out(fn.id, 'satisfy').some(t => reqIds.has(t.target)))
136
176
  .map(fn => {
137
177
  const modId = findParentModule(graph, fn.id);
138
178
  // Candidate REQs: same module's other FUNCs satisfy these, or constraints referencing this func
139
179
  const siblingReqs = modId
140
180
  ? graph.traces
141
181
  .filter(t => t.type === 'allocate' && t.target === modId && t.source !== fn.id)
142
- .flatMap(t => graph.traces.filter(st => st.source === t.source && st.type === 'satisfy'))
143
- .map(st => graph.elements.find(e => e.id === st.target))
182
+ .flatMap(t => idx.out(t.source, 'satisfy'))
183
+ .map(st => idx.byId.get(st.target))
144
184
  .filter((e) => !!e)
145
185
  : [];
146
186
  // Also include constraints whose description mentions this func's name
@@ -167,12 +207,13 @@ function funcMustSatisfyReq(graph) {
167
207
  // R-03: ASIL isolation
168
208
  // ---------------------------------------------------------------------------
169
209
  function asilIsolation(graph) {
210
+ const idx = indexOf(graph);
170
211
  const violations = [];
171
- const modules = graph.elements.filter(e => e.type === 'MOD');
212
+ const modules = idx.elementsOfType('MOD');
172
213
  for (const mod of modules) {
173
214
  const allocated = graph.traces
174
215
  .filter(t => t.target === mod.id && t.type === 'allocate')
175
- .map(t => graph.elements.find(e => e.id === t.source))
216
+ .map(t => idx.byId.get(t.source))
176
217
  .filter((e) => !!e);
177
218
  const hasD = allocated.some(e => e.asil === 'D');
178
219
  const hasQM = allocated.some(e => e.asil === 'QM');
@@ -206,22 +247,22 @@ function asilIsolation(graph) {
206
247
  // `null` → messen, nicht urteilen: die Regel schweigt.
207
248
  // ---------------------------------------------------------------------------
208
249
  function maxModuleSize(graph, policy) {
250
+ const idx = indexOf(graph);
209
251
  const violations = [];
210
252
  const steps = policy.moduleSize;
211
253
  if (steps === null)
212
254
  return violations;
213
- const modules = graph.elements.filter(e => e.type === 'MOD');
255
+ const modules = idx.elementsOfType('MOD');
214
256
  for (const mod of modules) {
215
- const allocatedIds = graph.traces.filter(t => t.target === mod.id && t.type === 'allocate').map(t => t.source);
216
- const allocated = allocatedIds.map(id => graph.elements.find(e => e.id === id)).filter((e) => !!e);
257
+ const allocatedIds = idx.in(mod.id, 'allocate').map(t => t.source);
258
+ const allocated = allocatedIds.map(id => idx.byId.get(id)).filter((e) => !!e);
217
259
  const funcCount = allocated.length;
218
260
  if (funcCount <= steps.coupled)
219
261
  continue;
220
262
  // Count crossing flows: io paths from FUNCs in this module to FUNCs in other modules
221
263
  const funcIds = new Set(allocatedIds);
222
- const crossings = graph.traces.filter(t => {
223
- if (t.type !== 'io')
224
- return false;
264
+ // CR-SM-264: nur die io-Kanten, nicht alle Traces je MOD.
265
+ const crossings = idx.tracesOfType('io').filter(t => {
225
266
  const srcIn = funcIds.has(t.source);
226
267
  const tgtIn = funcIds.has(t.target);
227
268
  return (srcIn && !tgtIn) || (!srcIn && tgtIn);
@@ -263,10 +304,11 @@ function maxModuleSize(graph, policy) {
263
304
  // R-05: Every TEST must verify at least one REQ
264
305
  // ---------------------------------------------------------------------------
265
306
  function testMustVerifyReq(graph) {
266
- const tests = graph.elements.filter(e => e.type === 'TEST');
267
- const allReqs = graph.elements.filter(e => e.type === 'REQ');
307
+ const idx = indexOf(graph);
308
+ const tests = idx.elementsOfType('TEST');
309
+ const allReqs = idx.elementsOfType('REQ');
268
310
  return tests
269
- .filter(test => !graph.traces.some(t => t.source === test.id && t.type === 'verify'))
311
+ .filter(test => idx.out(test.id, 'verify').length === 0)
270
312
  .map(test => ({
271
313
  rule_id: 'R-05',
272
314
  severity: 'warning',
@@ -290,11 +332,13 @@ function testMustVerifyReq(graph) {
290
332
  // R-08: Bidirectional trace consistency
291
333
  // ---------------------------------------------------------------------------
292
334
  function traceConsistency(graph) {
335
+ const idx = indexOf(graph);
336
+ // CR-SM-266 D5: kein `category !== 'audit'`-Filter mehr — das Attribut ist weg, und mit ihm
337
+ // die Moeglichkeit, eine Kante per Selbstauskunft aus der Referenzintegritaet zu nehmen.
293
338
  return graph.traces
294
- .filter(t => t.category !== 'audit')
295
339
  .filter(t => {
296
- const sourceExists = graph.elements.some(e => e.id === t.source);
297
- const targetExists = graph.elements.some(e => e.id === t.target);
340
+ const sourceExists = idx.byId.has(t.source);
341
+ const targetExists = idx.byId.has(t.target);
298
342
  return !sourceExists || !targetExists;
299
343
  })
300
344
  .map(t => ({
@@ -315,11 +359,14 @@ function traceConsistency(graph) {
315
359
  // concern, stricter check — no parallel path); up to two warnings per FLOW.
316
360
  // ---------------------------------------------------------------------------
317
361
  function flowCompleteness(graph) {
318
- const flows = graph.elements.filter(e => e.type === 'FLOW');
362
+ const idx = indexOf(graph);
363
+ const flows = idx.elementsOfType('FLOW');
319
364
  // Producers/consumers of a FLOW are FUNCs or ACTORs (io endpoints on the
320
- // upstream/downstream side); UC may consume too. Offer both as candidates.
321
- const sources = graph.elements.filter(e => e.type === 'FUNC' || e.type === 'ACTOR');
322
- const io = graph.traces.filter(t => t.type === 'io');
365
+ // upstream/downstream side). CR-SM-266 D4: UC ist KEIN Konsument mehr das Pattern
366
+ // `FLOW -io-> UC` ist entfallen, ein FLOW erreicht einen UC nur noch ueber ein Kettenglied.
367
+ // Der fix_hint hat den UC vorher noch angeboten und haette in eine R-18-Ablehnung gefuehrt.
368
+ const sources = graph.elements.filter(e => e.type === 'FUNC' || e.type === 'ACTOR'); // Mehrtyp: ein Durchlauf ist hier billiger als vier Index-Listen zu mischen (Reihenfolge!)
369
+ const io = idx.tracesOfType('io');
323
370
  const violations = [];
324
371
  for (const f of flows) {
325
372
  const ctx = { element_type: f.type, element_name: f.name, candidate_targets: toCandidates(sources) };
@@ -339,7 +386,7 @@ function flowCompleteness(graph) {
339
386
  severity: 'warning',
340
387
  element_id: f.id,
341
388
  message: `${f.id} has no consumer (outgoing io)`,
342
- fix_hint: 'Link a FUNC, ACTOR, or UC as the target via io trace',
389
+ fix_hint: 'Link a FUNC or ACTOR as the target via io trace',
343
390
  context: ctx,
344
391
  });
345
392
  }
@@ -361,6 +408,7 @@ function flowCompleteness(graph) {
361
408
  /** Trace types on which a direct 2-cycle is a real finding (CR-GC-315). */
362
409
  const CIRCULAR_TRACE_TYPES = new Set(['compose', 'allocate', 'relation']);
363
410
  function noDirectCircular(graph) {
411
+ const idx = indexOf(graph);
364
412
  const violations = [];
365
413
  // Direction-independent key: A↔B is ONE cycle, reported once. Keying on the
366
414
  // message (CR-GC-315 predecessor) never collapsed anything — the two
@@ -369,7 +417,7 @@ function noDirectCircular(graph) {
369
417
  for (const t of graph.traces) {
370
418
  if (!CIRCULAR_TRACE_TYPES.has(t.type))
371
419
  continue;
372
- if (!graph.traces.some(other => other.source === t.target && other.target === t.source && other.type === t.type))
420
+ if (!idx.out(t.target, t.type).some(other => other.target === t.source))
373
421
  continue;
374
422
  const key = `${t.type}|${[t.source, t.target].sort().join('|')}`;
375
423
  if (seen.has(key))
@@ -391,12 +439,12 @@ function noDirectCircular(graph) {
391
439
  // RD-01: Unresolved requirement (leaf REQ without satisfy)
392
440
  // ---------------------------------------------------------------------------
393
441
  function unresolvedRequirement(graph) {
394
- const reqs = graph.elements.filter(e => e.type === 'REQ');
442
+ const idx = indexOf(graph);
443
+ const reqs = idx.elementsOfType('REQ');
395
444
  // Leaf REQs: no compose→REQ children
396
- const leafReqs = reqs.filter(req => !graph.traces.some(t => t.source === req.id && t.type === 'compose' &&
397
- graph.elements.some(e => e.id === t.target && e.type === 'REQ')));
445
+ const leafReqs = reqs.filter(req => !idx.out(req.id, 'compose').some(t => idx.typeOf(t.target) === 'REQ'));
398
446
  return leafReqs
399
- .filter(req => !graph.traces.some(t => t.target === req.id && t.type === 'satisfy'))
447
+ .filter(req => idx.in(req.id, 'satisfy').length === 0)
400
448
  .map(req => ({
401
449
  rule_id: 'RD-01',
402
450
  severity: 'warning',
@@ -415,12 +463,11 @@ function unresolvedRequirement(graph) {
415
463
  // RD-02: Decomposition consistency (parent REQ should not have FUNC satisfy)
416
464
  // ---------------------------------------------------------------------------
417
465
  function decompositionConsistency(graph) {
418
- const reqs = graph.elements.filter(e => e.type === 'REQ');
419
- const parentReqs = reqs.filter(req => graph.traces.some(t => t.source === req.id && t.type === 'compose' &&
420
- graph.elements.some(e => e.id === t.target && e.type === 'REQ')));
466
+ const idx = indexOf(graph);
467
+ const reqs = idx.elementsOfType('REQ');
468
+ const parentReqs = reqs.filter(req => idx.out(req.id, 'compose').some(t => idx.typeOf(t.target) === 'REQ'));
421
469
  return parentReqs
422
- .filter(req => graph.traces.some(t => t.target === req.id && t.type === 'satisfy' &&
423
- graph.elements.some(e => e.id === t.source && e.type === 'FUNC')))
470
+ .filter(req => idx.in(req.id, 'satisfy').some(t => idx.typeOf(t.source) === 'FUNC'))
424
471
  .map(req => ({
425
472
  rule_id: 'RD-02',
426
473
  severity: 'warning',
@@ -434,18 +481,18 @@ function decompositionConsistency(graph) {
434
481
  // RD-03: No premature decomposition (all children same satisfy target)
435
482
  // ---------------------------------------------------------------------------
436
483
  function noPrematureDecomposition(graph) {
437
- const reqs = graph.elements.filter(e => e.type === 'REQ');
438
- const parentReqs = reqs.filter(req => graph.traces.some(t => t.source === req.id && t.type === 'compose' &&
439
- graph.elements.some(e => e.id === t.target && e.type === 'REQ')));
484
+ const idx = indexOf(graph);
485
+ const reqs = idx.elementsOfType('REQ');
486
+ const parentReqs = reqs.filter(req => idx.out(req.id, 'compose').some(t => idx.typeOf(t.target) === 'REQ'));
440
487
  return parentReqs
441
488
  .filter(parent => {
442
489
  const childIds = graph.traces
443
490
  .filter(t => t.source === parent.id && t.type === 'compose' &&
444
- graph.elements.some(e => e.id === t.target && e.type === 'REQ'))
491
+ idx.typeOf(t.target) === 'REQ')
445
492
  .map(t => t.target);
446
493
  if (childIds.length < 2)
447
494
  return false;
448
- const satisfyTargets = childIds.map(cid => graph.traces.filter(t => t.source !== cid && t.target === cid && t.type === 'satisfy').map(t => t.source));
495
+ const satisfyTargets = childIds.map(cid => idx.in(cid, 'satisfy').filter(t => t.source !== cid).map(t => t.source));
449
496
  // All children satisfied by the same single source
450
497
  const allSame = satisfyTargets.every(targets => targets.length === 1 && targets[0] === satisfyTargets[0]?.[0]);
451
498
  return allSame && satisfyTargets[0]?.length === 1;
@@ -473,8 +520,10 @@ function noPrematureDecomposition(graph) {
473
520
  */
474
521
  const DECOMPOSITION_BREADTH_MAX = 11;
475
522
  function decompositionBreadth(graph) {
476
- const typeOf = new Map(graph.elements.map(e => [e.id, e.type]));
477
- const byId = new Map(graph.elements.map(e => [e.id, e]));
523
+ const idx = indexOf(graph);
524
+ // CR-SM-264: `typeOf` und `byId` baute diese Regel je Aufruf selbst — der Index hat beide.
525
+ const typeOf = idx;
526
+ const byId = idx.byId;
478
527
  const counts = new Map();
479
528
  const bump = (parentId, kind) => {
480
529
  const key = `${parentId}\u0000${kind}`;
@@ -485,8 +534,8 @@ function decompositionBreadth(graph) {
485
534
  counts.set(key, { parentId, kind, n: 1 });
486
535
  };
487
536
  for (const t of graph.traces) {
488
- const src = typeOf.get(t.source);
489
- const tgt = typeOf.get(t.target);
537
+ const src = typeOf.typeOf(t.source);
538
+ const tgt = typeOf.typeOf(t.target);
490
539
  if (t.type === 'compose' && src === 'FUNC' && tgt === 'FUNC') {
491
540
  bump(t.source, 'sub-FUNC');
492
541
  }
@@ -515,9 +564,10 @@ function decompositionBreadth(graph) {
515
564
  // R-14: UC must have at least 1 compose trace (→ FCHAIN or REQ) (CR-117)
516
565
  // ---------------------------------------------------------------------------
517
566
  function ucMustHaveCompose(graph) {
518
- const ucs = graph.elements.filter(e => e.type === 'UC');
567
+ const idx = indexOf(graph);
568
+ const ucs = idx.elementsOfType('UC');
519
569
  return ucs
520
- .filter(uc => !graph.traces.some(t => t.source === uc.id && t.type === 'compose'))
570
+ .filter(uc => idx.out(uc.id, 'compose').length === 0)
521
571
  .map(uc => ({
522
572
  rule_id: 'R-14',
523
573
  severity: 'warning',
@@ -549,14 +599,15 @@ function ucMustHaveCompose(graph) {
549
599
  // einen einzigen Befund ueber beide Selbstmodelle.
550
600
  // ---------------------------------------------------------------------------
551
601
  function fchainCompleteness(graph) {
552
- const fchains = graph.elements.filter(e => e.type === 'FCHAIN');
553
- const funcs = graph.elements.filter(e => e.type === 'FUNC');
554
- const ucs = graph.elements.filter(e => e.type === 'UC');
602
+ const idx = indexOf(graph);
603
+ const fchains = idx.elementsOfType('FCHAIN');
604
+ const funcs = idx.elementsOfType('FUNC');
605
+ const ucs = idx.elementsOfType('UC');
555
606
  const ucIds = new Set(ucs.map(u => u.id));
556
607
  const violations = [];
557
608
  for (const fc of fchains) {
558
609
  const ctx = { element_type: fc.type, element_name: fc.name };
559
- if (!graph.traces.some(t => t.source === fc.id && t.type === 'compose')) {
610
+ if (idx.out(fc.id, 'compose').length === 0) {
560
611
  violations.push({
561
612
  rule_id: 'R-15',
562
613
  severity: 'warning',
@@ -566,7 +617,7 @@ function fchainCompleteness(graph) {
566
617
  context: { ...ctx, candidate_targets: toCandidates(funcs) },
567
618
  });
568
619
  }
569
- if (!graph.traces.some(t => t.target === fc.id && t.type === 'compose' && ucIds.has(t.source))) {
620
+ if (!idx.in(fc.id, 'compose').some(t => ucIds.has(t.source))) {
570
621
  violations.push({
571
622
  rule_id: 'R-15',
572
623
  severity: 'warning',
@@ -583,9 +634,10 @@ function fchainCompleteness(graph) {
583
634
  // R-16: ACTOR must have at least 1 io trace (CR-117)
584
635
  // ---------------------------------------------------------------------------
585
636
  function actorMustHaveTrace(graph) {
586
- const actors = graph.elements.filter(e => e.type === 'ACTOR');
637
+ const idx = indexOf(graph);
638
+ const actors = idx.elementsOfType('ACTOR');
587
639
  return actors
588
- .filter(a => !graph.traces.some(t => (t.source === a.id || t.target === a.id) && t.type === 'io'))
640
+ .filter(a => idx.out(a.id, 'io').length === 0 && idx.in(a.id, 'io').length === 0)
589
641
  .map(a => ({
590
642
  rule_id: 'R-16',
591
643
  severity: 'warning',
@@ -602,9 +654,10 @@ function actorMustHaveTrace(graph) {
602
654
  // R-17: SYS must have at least 1 compose trace (CR-117)
603
655
  // ---------------------------------------------------------------------------
604
656
  function sysMustHaveCompose(graph) {
605
- const systems = graph.elements.filter(e => e.type === 'SYS');
657
+ const idx = indexOf(graph);
658
+ const systems = idx.elementsOfType('SYS');
606
659
  return systems
607
- .filter(sys => !graph.traces.some(t => t.source === sys.id && t.type === 'compose'))
660
+ .filter(sys => idx.out(sys.id, 'compose').length === 0)
608
661
  .map(sys => ({
609
662
  rule_id: 'R-17',
610
663
  severity: 'warning',
@@ -629,10 +682,10 @@ function sysMustHaveCompose(graph) {
629
682
  // zusaetzlich compose) — genau deshalb ist jetzt der Zeitpunkt.
630
683
  // ---------------------------------------------------------------------------
631
684
  function msEmptyScope(graph) {
632
- const crIds = new Set(graph.elements.filter(e => e.type === 'CR').map(e => e.id));
633
- return graph.elements
634
- .filter(e => e.type === 'MS')
635
- .filter(ms => !graph.traces.some(t => t.type === 'relation' && t.target === ms.id && crIds.has(t.source)))
685
+ const idx = indexOf(graph);
686
+ const crIds = new Set(idx.elementsOfType('CR').map(e => e.id));
687
+ return idx.elementsOfType('MS')
688
+ .filter(ms => !idx.in(ms.id, 'relation').some(t => crIds.has(t.source)))
636
689
  .map(ms => ({
637
690
  rule_id: 'MS-01',
638
691
  severity: 'warning',
@@ -646,7 +699,8 @@ function msEmptyScope(graph) {
646
699
  // MS-02: Milestone depends-on dangling target
647
700
  // ---------------------------------------------------------------------------
648
701
  function msDanglingDependency(graph) {
649
- const msIds = new Set(graph.elements.filter(e => e.type === 'MS').map(e => e.id));
702
+ const idx = indexOf(graph);
703
+ const msIds = new Set(idx.elementsOfType('MS').map(e => e.id));
650
704
  return graph.traces
651
705
  .filter(t => t.type === 'relation' && t.label === 'depends-on' && msIds.has(t.source))
652
706
  .filter(t => !msIds.has(t.target))
@@ -667,24 +721,75 @@ function msDanglingDependency(graph) {
667
721
  // here we only judge pairs whose endpoints both resolve. Audit traces are exempt.
668
722
  // ---------------------------------------------------------------------------
669
723
  function validTracePattern(graph) {
670
- const typeById = new Map(graph.elements.map(e => [e.id, e.type]));
724
+ const idx = indexOf(graph);
725
+ // CR-SM-264: die Typ-Map kommt aus dem Index statt je Aufruf neu gebaut zu werden.
726
+ const typeById = { get: (id) => idx.typeOf(id) };
727
+ // CR-SM-266 D5: der `category !== 'audit'`-Filter ist WEG. Er war die Ausnahme fuer das
728
+ // einzige audit-Pattern (`SESSION -produces-> *`); mit dessen Entfernung waere er zum
729
+ // Schlupfloch geworden — eine Kante mit `category: 'audit'` haette die Matrix komplett
730
+ // umgangen. Das Attribut existiert nicht mehr (ontology.ts), hier faellt der Leser mit.
671
731
  return graph.traces
672
- .filter(t => t.category !== 'audit')
673
732
  .filter(t => {
674
733
  const src = typeById.get(t.source);
675
734
  const tgt = typeById.get(t.target);
676
735
  if (!src || !tgt)
677
736
  return false; // dangling endpoint → R-08, not R-18
678
- return !isValidTrace({ source: src, target: tgt, type: t.type, label: t.label });
737
+ // CR-SM-266 B: die where-Patterns lesen die Kinds des betroffenen Endes mit.
738
+ return !isValidTrace({
739
+ source: src, target: tgt, type: t.type, label: t.label,
740
+ sourceKinds: idx.byId.get(t.source)?.kinds,
741
+ targetKinds: idx.byId.get(t.target)?.kinds,
742
+ });
679
743
  })
680
- .map(t => ({
744
+ .map((t) => ({
681
745
  rule_id: 'R-18',
682
746
  severity: 'error',
683
747
  element_id: t.source,
684
748
  message: `Invalid trace ${t.source} -${t.type}-> ${t.target}: ` +
685
749
  `${typeById.get(t.source)} → ${typeById.get(t.target)} is not a valid ${t.type} pattern`,
686
750
  fix_hint: 'Use a trace type whose TRACE_PATTERNS allows this source/target element-type pair',
687
- }));
751
+ }))
752
+ .concat(cardinalityViolations(graph));
753
+ }
754
+ /**
755
+ * CR-SM-266b — das ZWEITE BEIN von R-18: die Kardinalitaets-Obergrenzen.
756
+ *
757
+ * Bewusst dieselbe `rule_id` und keine eigene, nach dem Muster von R-10 (Produzent/Konsument)
758
+ * und R-15 seit CR-SM-249: die Aussage ist dieselbe — "diese Kante ist nach dem Meta-Modell
759
+ * nicht zulaessig" —, nur ist die Bedingung hier eine ueber die MENGE statt ueber das Paar.
760
+ * Eine eigene ID kostete dauerhaft einen Nenner-Anteil, eine Katalogzeile, eine
761
+ * readiness-Zuordnung und einen Golden-File-Eintrag (Gate 6), und ein Leser muesste sie von
762
+ * R-18 unterscheiden, obwohl der naechste Schritt derselbe ist: Kante weg.
763
+ *
764
+ * EIN Befund je Quellknoten, nicht je ueberzaehliger Kante. Bei n Allokationen sind nicht
765
+ * n-1 Kanten "falsch" — es ist EINE Entscheidung offen (welches Modul), und n-1 Befunde
766
+ * wuerden den Zaehler ueber den Nenner-Beitrag treiben, die Fehlmessung aus CR-SM-242.
767
+ */
768
+ function cardinalityViolations(graph) {
769
+ const idx = indexOf(graph);
770
+ const violations = [];
771
+ for (const p of BOUNDED_PATTERNS) {
772
+ const max = maxOccurs(p.cardinality);
773
+ for (const el of idx.elementsOfType(p.source)) {
774
+ const matching = idx.out(el.id, p.type).filter(t => idx.typeOf(t.target) === p.target);
775
+ if (matching.length <= max)
776
+ continue;
777
+ violations.push({
778
+ rule_id: 'R-18',
779
+ severity: 'error',
780
+ element_id: el.id,
781
+ message: `${el.id} has ${matching.length} ${p.type} traces to ${p.target} — ` +
782
+ `the meta-model allows at most ${max} (${matching.map(t => t.target).join(', ')})`,
783
+ fix_hint: `Keep exactly one ${p.type} trace from ${el.id} to a ${p.target} and delete the others`,
784
+ context: {
785
+ element_type: el.type,
786
+ element_name: el.name,
787
+ existing_traces: matching.map(t => ({ source: t.source, target: t.target, type: t.type })),
788
+ },
789
+ });
790
+ }
791
+ }
792
+ return violations;
688
793
  }
689
794
  // ---------------------------------------------------------------------------
690
795
  // R-19: Runnable TEST binding (CR-GC-205 Item 4) — a TEST that is not explicitly
@@ -697,8 +802,8 @@ function validTracePattern(graph) {
697
802
  // only and surfaces an unbound runnable TEST in rules_evaluate / readiness.
698
803
  // ---------------------------------------------------------------------------
699
804
  function testMustHaveRunnableBinding(graph) {
700
- return graph.elements
701
- .filter(e => e.type === 'TEST')
805
+ const idx = indexOf(graph);
806
+ return idx.elementsOfType('TEST')
702
807
  .filter(e => e.attributes?.concept !== true) // concept-only TESTs are exempt
703
808
  // CR-SM-231: `testRefs` (Array, min 1) statt `testRef` (Objekt). Semantik unveraendert —
704
809
  // Praesenz und Form, keine Datei-Existenz; die pruefst RC-02.
@@ -729,6 +834,7 @@ function testMustHaveRunnableBinding(graph) {
729
834
  // zu sein.
730
835
  // ---------------------------------------------------------------------------
731
836
  function testFileExclusivity(graph) {
837
+ const idx = indexOf(graph);
732
838
  const claimedBy = new Map();
733
839
  for (const el of graph.elements) {
734
840
  if (el.type !== 'TEST')
@@ -758,7 +864,7 @@ function testFileExclusivity(graph) {
758
864
  element_id: owner,
759
865
  message: `${owner} claims test file '${file}', which is also claimed by ${sorted.filter(o => o !== owner).join(', ')}`,
760
866
  fix_hint: 'A test file belongs to at most one TEST. Split the file, or drop the entry from all but the one acceptance it really evidences',
761
- context: { element_type: 'TEST', element_name: graph.elements.find(e => e.id === owner)?.name },
867
+ context: { element_type: 'TEST', element_name: idx.byId.get(owner)?.name },
762
868
  });
763
869
  }
764
870
  }
@@ -778,7 +884,8 @@ function testFileExclusivity(graph) {
778
884
  // RC-01's job); this guards presence/shape only. Cycle-safe.
779
885
  // ---------------------------------------------------------------------------
780
886
  function funcMustHaveCodeBinding(graph) {
781
- const funcById = new Map(graph.elements.filter(e => e.type === 'FUNC').map(e => [e.id, e]));
887
+ const idx = indexOf(graph);
888
+ const funcById = new Map(idx.elementsOfType('FUNC').map(e => [e.id, e]));
782
889
  const composeFuncChildren = (id) => graph.traces
783
890
  .filter(t => t.source === id && t.type === 'compose' && funcById.has(t.target))
784
891
  .map(t => t.target);
@@ -869,9 +976,10 @@ function funcMustHaveCodeBinding(graph) {
869
976
  // choice. The FCHAIN is the modelled claim; the test is owed on the claim.
870
977
  // ---------------------------------------------------------------------------
871
978
  function fchainMustHaveIntegrationTest(graph) {
979
+ const idx = indexOf(graph);
872
980
  const typeOf = new Map(graph.elements.map(e => [e.id, e.type]));
873
981
  const isFunc = (id) => typeOf.get(id) === 'FUNC';
874
- const io = graph.traces.filter(t => t.type === 'io');
982
+ const io = idx.tracesOfType('io');
875
983
  // FUNC↔FUNC connections via the FLOW hop: (producer FUNC) → FLOW → (consumer FUNC).
876
984
  const connections = [];
877
985
  for (const e of graph.elements) {
@@ -894,7 +1002,7 @@ function fchainMustHaveIntegrationTest(graph) {
894
1002
  }
895
1003
  }
896
1004
  // FCHAINs whose satisfy-REQ is verified by a TEST = chains with an integration test.
897
- const verifiedReqs = new Set(graph.traces.filter(t => t.type === 'verify').map(t => t.target));
1005
+ const verifiedReqs = new Set(idx.tracesOfType('verify').map(t => t.target));
898
1006
  const testedChains = new Set();
899
1007
  for (const t of graph.traces) {
900
1008
  if (t.type === 'satisfy' && typeOf.get(t.source) === 'FCHAIN' && verifiedReqs.has(t.target)) {
@@ -918,7 +1026,7 @@ function fchainMustHaveIntegrationTest(graph) {
918
1026
  if (seen.has(key))
919
1027
  continue;
920
1028
  seen.add(key);
921
- const anchorEl = graph.elements.find(e => e.id === anchor);
1029
+ const anchorEl = idx.byId.get(anchor);
922
1030
  violations.push({
923
1031
  rule_id: 'R-21',
924
1032
  severity: 'warning',
@@ -940,11 +1048,12 @@ function fchainMustHaveIntegrationTest(graph) {
940
1048
  // deliberately not checked (CR-GVE-173/174).
941
1049
  // ---------------------------------------------------------------------------
942
1050
  function funcMustBeAllocated(graph) {
943
- const funcs = graph.elements.filter(e => e.type === 'FUNC');
944
- const mods = graph.elements.filter(e => e.type === 'MOD');
1051
+ const idx = indexOf(graph);
1052
+ const funcs = idx.elementsOfType('FUNC');
1053
+ const mods = idx.elementsOfType('MOD');
945
1054
  const modIds = new Set(mods.map(m => m.id));
946
1055
  return funcs
947
- .filter(fn => !graph.traces.some(t => t.source === fn.id && t.type === 'allocate' && modIds.has(t.target)))
1056
+ .filter(fn => !idx.out(fn.id, 'allocate').some(t => modIds.has(t.target)))
948
1057
  .map(fn => ({
949
1058
  rule_id: 'R-22',
950
1059
  severity: 'warning',
@@ -967,13 +1076,13 @@ function funcMustBeAllocated(graph) {
967
1076
  // R-22: one rule per element perspective (like R-01 REQ-side beside R-02/R-05).
968
1077
  // ---------------------------------------------------------------------------
969
1078
  function modMustHaveAllocatedFunc(graph) {
970
- const mods = graph.elements.filter(e => e.type === 'MOD');
971
- const funcs = graph.elements.filter(e => e.type === 'FUNC');
1079
+ const idx = indexOf(graph);
1080
+ const mods = idx.elementsOfType('MOD');
1081
+ const funcs = idx.elementsOfType('FUNC');
972
1082
  const modIds = new Set(mods.map(m => m.id));
973
- const unallocatedFuncs = funcs.filter(fn => !graph.traces.some(t => t.source === fn.id && t.type === 'allocate' && modIds.has(t.target)));
1083
+ const unallocatedFuncs = funcs.filter(fn => !idx.out(fn.id, 'allocate').some(t => modIds.has(t.target)));
974
1084
  return mods
975
- .filter(mod => !graph.traces.some(t => t.target === mod.id && t.type === 'allocate' &&
976
- graph.elements.some(e => e.id === t.source && e.type === 'FUNC')))
1085
+ .filter(mod => !idx.in(mod.id, 'allocate').some(t => idx.typeOf(t.source) === 'FUNC'))
977
1086
  .map(mod => ({
978
1087
  rule_id: 'R-23',
979
1088
  severity: 'warning',
@@ -1001,8 +1110,8 @@ function modMustHaveAllocatedFunc(graph) {
1001
1110
  // out of scope here (pure, no I/O — that is RC-03's job).
1002
1111
  // ---------------------------------------------------------------------------
1003
1112
  function schemaMustHaveSchemaRef(graph) {
1004
- return graph.elements
1005
- .filter(e => e.type === 'SCHEMA')
1113
+ const idx = indexOf(graph);
1114
+ return idx.elementsOfType('SCHEMA')
1006
1115
  .filter(e => e.attributes?.concept !== true && e.attributes?.external !== true)
1007
1116
  .filter(e => !RealRefSchema.safeParse(e.attributes?.realRef).success)
1008
1117
  .map(sc => ({
@@ -1025,8 +1134,9 @@ function schemaMustHaveSchemaRef(graph) {
1025
1134
  // realRef with a file. RESOLUTION (file on disk) is a consumer/RC concern.
1026
1135
  // ---------------------------------------------------------------------------
1027
1136
  function physicalModMustHaveRealRef(graph) {
1028
- return graph.elements
1029
- .filter(e => e.type === 'MOD' && e.attributes?.kind === 'physical')
1137
+ const idx = indexOf(graph);
1138
+ return idx.elementsOfType('MOD')
1139
+ .filter(e => e.attributes?.kind === 'physical')
1030
1140
  .filter(e => e.attributes?.concept !== true && e.attributes?.external !== true)
1031
1141
  .filter(e => !RealRefSchema.safeParse(e.attributes?.realRef).success)
1032
1142
  .map(mod => ({
@@ -1077,8 +1187,9 @@ function physicalModMustHaveRealRef(graph) {
1077
1187
  // waere Neubau-Zwang statt Steuerung.
1078
1188
  // ---------------------------------------------------------------------------
1079
1189
  function funcMustBeInEffectChain(graph) {
1080
- const funcs = graph.elements.filter(e => e.type === 'FUNC');
1081
- const fchains = graph.elements.filter(e => e.type === 'FCHAIN');
1190
+ const idx = indexOf(graph);
1191
+ const funcs = idx.elementsOfType('FUNC');
1192
+ const fchains = idx.elementsOfType('FCHAIN');
1082
1193
  const funcIds = new Set(funcs.map(f => f.id));
1083
1194
  const fchainIds = new Set(fchains.map(fc => fc.id));
1084
1195
  // CR-SM-256: dieselbe Definition wie R-02 und R-31, nicht mehr die lokale Kopie.
@@ -1112,15 +1223,16 @@ function funcMustBeInEffectChain(graph) {
1112
1223
  // eigenen Nenner-Beitrag, was CR-SM-242 fuer IO-01 als Fehlmessung nachgewiesen hat.
1113
1224
  // ---------------------------------------------------------------------------
1114
1225
  function funcMustBeWired(graph) {
1226
+ const idx = indexOf(graph);
1115
1227
  // CR-SM-256: Grundgesamtheit sind die BLAETTER, wie bei R-02 und R-30. Durch einen
1116
1228
  // Blackbox-Block fliessen keine Daten — die io-Kanten haengen an seinen Kindern.
1117
1229
  // CR-GC-375 hat das fuer R-31 ausdruecklich offengelassen ("eine eigene Frage, hier
1118
1230
  // bewusst nicht"); die Messung liegt jetzt vor: 12 von 13 Bloecken am
1119
1231
  // graphcode-Selbstmodell melden nur deshalb, und keiner aus einem anderen Grund.
1120
1232
  const decomposed = decomposedFuncs(graph);
1121
- const funcs = graph.elements.filter(e => e.type === 'FUNC' && !decomposed.has(e.id));
1122
- const flows = graph.elements.filter(e => e.type === 'FLOW');
1123
- const io = graph.traces.filter(t => t.type === 'io');
1233
+ const funcs = idx.elementsOfType('FUNC').filter(e => !decomposed.has(e.id));
1234
+ const flows = idx.elementsOfType('FLOW');
1235
+ const io = idx.tracesOfType('io');
1124
1236
  const hasInput = new Set(io.map(t => t.target)); // FLOW -io-> FUNC
1125
1237
  const hasOutput = new Set(io.map(t => t.source)); // FUNC -io-> FLOW
1126
1238
  return funcs