@sigloch/contracts 3.2.0 → 4.0.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 (42) hide show
  1. package/dist/harness/index.d.ts +2 -2
  2. package/dist/se/action-priority.d.ts +101 -0
  3. package/dist/se/action-priority.js +124 -0
  4. package/dist/se/analysis-freshness-rules.d.ts +5 -0
  5. package/dist/se/analysis-freshness-rules.js +5 -5
  6. package/dist/se/ao-rules.d.ts +5 -39
  7. package/dist/se/ao-rules.js +20 -13
  8. package/dist/se/conformance-rules.d.ts +2 -2
  9. package/dist/se/conformance-rules.js +36 -30
  10. package/dist/se/cr-quality-rules.d.ts +2 -1
  11. package/dist/se/cr-quality-rules.js +9 -7
  12. package/dist/se/evaluate-all.d.ts +18 -3
  13. package/dist/se/evaluate-all.js +29 -26
  14. package/dist/se/fchain-quality-rules.d.ts +2 -1
  15. package/dist/se/fchain-quality-rules.js +8 -6
  16. package/dist/se/fmea-rules.d.ts +3 -11
  17. package/dist/se/fmea-rules.js +53 -16
  18. package/dist/se/format-e-parser.d.ts +14 -1
  19. package/dist/se/format-e-parser.js +8 -3
  20. package/dist/se/index.d.ts +4 -2
  21. package/dist/se/index.js +4 -2
  22. package/dist/se/metric-rules.d.ts +47 -5
  23. package/dist/se/metric-rules.js +199 -171
  24. package/dist/se/near-duplicate-rules.d.ts +2 -0
  25. package/dist/se/near-duplicate-rules.js +2 -2
  26. package/dist/se/ontology.d.ts +55 -4
  27. package/dist/se/ontology.js +46 -6
  28. package/dist/se/policy.d.ts +65 -0
  29. package/dist/se/policy.js +100 -0
  30. package/dist/se/quality-rules.d.ts +2 -1
  31. package/dist/se/quality-rules.js +9 -7
  32. package/dist/se/readiness.d.ts +9 -1
  33. package/dist/se/readiness.js +18 -2
  34. package/dist/se/rules.d.ts +25 -5
  35. package/dist/se/rules.js +112 -47
  36. package/dist/se/schema-quality-rules.d.ts +2 -1
  37. package/dist/se/schema-quality-rules.js +6 -4
  38. package/dist/se/uc-quality-rules.d.ts +2 -1
  39. package/dist/se/uc-quality-rules.js +10 -8
  40. package/dist/se/view-rules.d.ts +2 -6
  41. package/dist/se/view-rules.js +40 -13
  42. package/package.json +2 -1
@@ -1,46 +1,30 @@
1
- const INSTABILITY_THRESHOLD = 0.7;
2
1
  /**
3
2
  * MT-01: Module Instability (CR-165: indirect via allocate-path).
4
- * I = fan_out / (fan_in + fan_out) > 0.7 → warning.
3
+ * I = fan_out / (fan_in + fan_out) > `policy.instability` → warning.
5
4
  * fan_out = traces from FUNCs-in-module pointing to elements OUTSIDE the module.
6
5
  * fan_in = traces from OUTSIDE pointing to FUNCs-in-module.
7
6
  * Direct MOD→MOD io/compose traces also count.
7
+ *
8
+ * CR-SM-233: die Schwelle ist Eingabe, nicht Konstante — `policy.instability === null`
9
+ * heißt messen, nicht urteilen (`moduleMetrics` liefert den Wert unverändert weiter).
8
10
  */
9
- export function mt01Instability(graph) {
11
+ export function mt01Instability(graph, policy) {
10
12
  const violations = [];
11
- const mods = graph.elements.filter(e => e.type === 'MOD');
12
- for (const mod of mods) {
13
- // FUNCs allocated to this module
14
- const modFuncIds = new Set(graph.traces
15
- .filter(t => t.type === 'allocate' && t.target === mod.id)
16
- .map(t => t.source));
17
- // Direct MOD-level traces (MOD→MOD io/compose)
18
- const directOut = graph.traces.filter(t => t.source === mod.id && (t.type === 'io' || t.type === 'compose') && t.target !== mod.id).length;
19
- const directIn = graph.traces.filter(t => t.target === mod.id && (t.type === 'io' || t.type === 'compose' || t.type === 'allocate')).length;
20
- // Indirect: traces from module FUNCs to outside elements (and vice versa)
21
- let indirectOut = 0;
22
- let indirectIn = 0;
23
- for (const t of graph.traces) {
24
- if (t.type === 'allocate')
25
- continue; // allocate itself doesn't count as coupling
26
- if (modFuncIds.has(t.source) && !modFuncIds.has(t.target) && t.target !== mod.id) {
27
- indirectOut++;
28
- }
29
- if (modFuncIds.has(t.target) && !modFuncIds.has(t.source) && t.source !== mod.id) {
30
- indirectIn++;
31
- }
32
- }
33
- const fanOut = directOut + indirectOut;
34
- const fanIn = directIn + indirectIn;
35
- if (fanIn + fanOut === 0)
13
+ const threshold = policy.instability;
14
+ if (threshold === null)
15
+ return violations;
16
+ // CR-SM-232: EINE Rechnung, zwei Ausgaben. Die fan_in/fan_out-Formel steht in
17
+ // `measureModules` hier wird nur noch geschwellt. Sonst stünde die Formel
18
+ // doppelt im selben File und der Export wäre eine zweite Implementierung.
19
+ for (const m of measureModules(graph)) {
20
+ if (m.instability === null)
36
21
  continue;
37
- const instability = fanOut / (fanIn + fanOut);
38
- if (instability > INSTABILITY_THRESHOLD) {
22
+ if (m.instability > threshold) {
39
23
  violations.push({
40
24
  rule_id: 'MT-01',
41
25
  severity: 'warning',
42
- element_id: mod.id,
43
- message: `${mod.name} has instability ${Math.round(instability * 100)}% (>${INSTABILITY_THRESHOLD * 100}%). fan_in=${fanIn}, fan_out=${fanOut}`,
26
+ element_id: m.moduleId,
27
+ message: `${m.moduleName} has instability ${Math.round(m.instability * 100)}% (>${threshold * 100}%). fan_in=${m.fanIn}, fan_out=${m.fanOut}`,
44
28
  });
45
29
  }
46
30
  }
@@ -49,103 +33,25 @@ export function mt01Instability(graph) {
49
33
  /**
50
34
  * MT-02: LCOM4 (Lack of Cohesion — component count).
51
35
  * MOD with allocated FUNCs that share no common io/satisfy targets → cohesion problem.
52
- * Components > 1 → info.
36
+ * `policy.lcom4.info` LCOM4 < `policy.lcom4.warning` → info, darüber warning.
37
+ *
38
+ * CR-SM-233: die Stufen sind Eingabe, `null` heißt messen statt urteilen.
53
39
  */
54
- export function mt02Lcom4(graph) {
40
+ export function mt02Lcom4(graph, policy) {
55
41
  const violations = [];
56
- const mods = graph.elements.filter(e => e.type === 'MOD');
57
- for (const mod of mods) {
58
- // Find all FUNCs allocated to this MOD
59
- const allocTraces = graph.traces.filter(t => t.type === 'allocate' && t.target === mod.id);
60
- const funcIds = allocTraces.map(t => t.source);
61
- if (funcIds.length < 2)
42
+ const steps = policy.lcom4;
43
+ if (steps === null)
44
+ return violations;
45
+ // CR-SM-232: die Union-Find-Rechnung steht in `measureModules`; hier nur die Stufen.
46
+ for (const m of measureModules(graph)) {
47
+ if (m.lcom4 === null)
62
48
  continue;
63
- // Build adjacency: two FUNCs are connected if they share a common io/satisfy target
64
- const funcTargets = new Map();
65
- for (const fid of funcIds) {
66
- const targets = new Set();
67
- for (const t of graph.traces) {
68
- if (t.source === fid && (t.type === 'io' || t.type === 'satisfy')) {
69
- targets.add(t.target);
70
- }
71
- }
72
- funcTargets.set(fid, targets);
73
- }
74
- // Count connected components via union-find
75
- const parent = new Map();
76
- for (const fid of funcIds)
77
- parent.set(fid, fid);
78
- function find(x) {
79
- while (parent.get(x) !== x) {
80
- x = parent.get(x);
81
- }
82
- return x;
83
- }
84
- function union(a, b) {
85
- const ra = find(a), rb = find(b);
86
- if (ra !== rb)
87
- parent.set(ra, rb);
88
- }
89
- for (let i = 0; i < funcIds.length; i++) {
90
- for (let j = i + 1; j < funcIds.length; j++) {
91
- const ti = funcTargets.get(funcIds[i]);
92
- const tj = funcTargets.get(funcIds[j]);
93
- // Check if they share any target
94
- for (const t of ti) {
95
- if (tj.has(t)) {
96
- union(funcIds[i], funcIds[j]);
97
- break;
98
- }
99
- }
100
- }
101
- }
102
- // CR-165/CR-171: FUNCs sharing the same FLOW (via io in either direction) are connected
103
- const funcIdSet = new Set(funcIds);
104
- const flowToFuncs = new Map();
105
- for (const t of graph.traces) {
106
- if (t.type !== 'io')
107
- continue;
108
- // FLOW→FUNC direction
109
- if (funcIdSet.has(t.target)) {
110
- const srcEl = graph.elements.find(e => e.id === t.source);
111
- if (srcEl?.type === 'FLOW') {
112
- if (!flowToFuncs.has(t.source))
113
- flowToFuncs.set(t.source, new Set());
114
- flowToFuncs.get(t.source).add(t.target);
115
- }
116
- }
117
- // FUNC→FLOW direction (CR-171)
118
- if (funcIdSet.has(t.source)) {
119
- const tgtEl = graph.elements.find(e => e.id === t.target);
120
- if (tgtEl?.type === 'FLOW') {
121
- if (!flowToFuncs.has(t.target))
122
- flowToFuncs.set(t.target, new Set());
123
- flowToFuncs.get(t.target).add(t.source);
124
- }
125
- }
126
- }
127
- for (const funcsInFlow of flowToFuncs.values()) {
128
- const arr = [...funcsInFlow];
129
- for (let i = 1; i < arr.length; i++)
130
- union(arr[0], arr[i]);
49
+ const message = `${m.moduleName} has LCOM4=${m.lcom4} (${m.allocatedFuncs} FUNCs in ${m.lcom4} disconnected groups)`;
50
+ if (m.lcom4 >= steps.info && m.lcom4 < steps.warning) {
51
+ violations.push({ rule_id: 'MT-02', severity: 'info', element_id: m.moduleId, message });
131
52
  }
132
- const roots = new Set(funcIds.map(find));
133
- const lcom4 = roots.size;
134
- if (lcom4 >= 4 && lcom4 <= 5) {
135
- violations.push({
136
- rule_id: 'MT-02',
137
- severity: 'info',
138
- element_id: mod.id,
139
- message: `${mod.name} has LCOM4=${lcom4} (${funcIds.length} FUNCs in ${lcom4} disconnected groups)`,
140
- });
141
- }
142
- else if (lcom4 > 5) {
143
- violations.push({
144
- rule_id: 'MT-02',
145
- severity: 'warning',
146
- element_id: mod.id,
147
- message: `${mod.name} has LCOM4=${lcom4} (${funcIds.length} FUNCs in ${lcom4} disconnected groups)`,
148
- });
53
+ else if (m.lcom4 >= steps.warning) {
54
+ violations.push({ rule_id: 'MT-02', severity: 'warning', element_id: m.moduleId, message });
149
55
  }
150
56
  }
151
57
  return violations;
@@ -204,65 +110,187 @@ function connectionPairs(graph) {
204
110
  return pairs;
205
111
  }
206
112
  /**
207
- * Allocation cohesion a **measurement, not a rule** (CR-SM-223, decision 2026-07-29).
208
- *
209
- * It used to be MT-03 with an 80 % threshold, and it fired on nearly every module of
210
- * every real graph: 6 of 7 on graphcode, 4 of 4 on graph-view-edit, 10 of 11 on the
211
- * family graph. `CR-SM-221` first suspected the edge definition and made it
212
- * FLOW-transitive — the hit rate did not move. The threshold was the miscalibration:
213
- * in a flow-routed layered architecture, module interaction crosses boundaries by
214
- * design, so "80 % of interaction is internal" describes a monolith, not a healthy
215
- * module.
216
- *
217
- * Rather than fit a cut-off to 11 data points, this reports the number and lets the
218
- * architect judge. Returned worst-first, so the head of the list is where to look.
219
- * Modules with fewer than two allocated FUNCs, or with no external connection at all,
220
- * carry no signal and are omitted.
113
+ * The one per-module computation (CR-SM-232) — in GRAPH ORDER, unsorted.
221
114
  *
222
- * Deliberately NOT a `RuleDefinition`: `computeReadiness` counts every violation into
223
- * its dimension score regardless of severity, so a per-module advisory would depress
224
- * the `alloc` score permanently. A measurement must not masquerade as a defect.
115
+ * `mt01Instability` and `mt02Lcom4` consume this and only threshold; `moduleMetrics()`
116
+ * exports a sorted copy. Keeping the internal list in `graph.elements` order is what
117
+ * makes the violation ORDER of both rules byte-identical to the pre-CR implementation
118
+ * — the export's worst-first ranking must not leak into the rule output.
225
119
  *
226
- * Validation of this metric and of MT-01/MT-02, which are thresholded the same way
227
- * is deferred (CR-SM-223).
120
+ * Every value that is not measurable is `null`, never 0: a module with one allocated
121
+ * FUNC has no LCOM4, and a 1 there would be an invented statement about cohesion.
228
122
  */
229
- export function allocationCohesion(graph) {
230
- const measurements = [];
123
+ function measureModules(graph) {
231
124
  const mods = graph.elements.filter(e => e.type === 'MOD');
232
125
  const pairs = [...connectionPairs(graph)].map(p => p.split('|'));
126
+ const rows = [];
233
127
  for (const mod of mods) {
234
- const funcIds = new Set(graph.traces.filter(t => t.type === 'allocate' && t.target === mod.id).map(t => t.source));
235
- if (funcIds.size < 2)
236
- continue;
237
- let internal = 0;
238
- let external = 0;
239
- for (const [a, b] of pairs) {
240
- const aIn = funcIds.has(a);
241
- const bIn = funcIds.has(b);
242
- if (aIn && bIn)
243
- internal++;
244
- else if (aIn || bIn)
245
- external++;
128
+ const allocated = graph.traces
129
+ .filter(t => t.type === 'allocate' && t.target === mod.id)
130
+ .map(t => t.source);
131
+ const modFuncIds = new Set(allocated);
132
+ // --- MT-01: fan-in / fan-out (CR-165, indirect via the allocate path) ---
133
+ const directOut = graph.traces.filter(t => t.source === mod.id && (t.type === 'io' || t.type === 'compose') && t.target !== mod.id).length;
134
+ const directIn = graph.traces.filter(t => t.target === mod.id && (t.type === 'io' || t.type === 'compose' || t.type === 'allocate')).length;
135
+ let indirectOut = 0;
136
+ let indirectIn = 0;
137
+ for (const t of graph.traces) {
138
+ if (t.type === 'allocate')
139
+ continue; // allocate itself doesn't count as coupling
140
+ if (modFuncIds.has(t.source) && !modFuncIds.has(t.target) && t.target !== mod.id)
141
+ indirectOut++;
142
+ if (modFuncIds.has(t.target) && !modFuncIds.has(t.source) && t.source !== mod.id)
143
+ indirectIn++;
246
144
  }
247
- // No external connections nothing to compare against, no signal.
248
- if (external === 0)
249
- continue;
250
- measurements.push({
145
+ const fanOut = directOut + indirectOut;
146
+ const fanIn = directIn + indirectIn;
147
+ const instability = fanIn + fanOut === 0 ? null : fanOut / (fanIn + fanOut);
148
+ rows.push({
251
149
  moduleId: mod.id,
252
150
  moduleName: mod.name,
253
- internal,
254
- external,
255
- cohesion: internal / (internal + external),
151
+ allocatedFuncs: allocated.length,
152
+ fanIn,
153
+ fanOut,
154
+ instability,
155
+ lcom4: lcom4Of(graph, allocated),
156
+ cohesion: cohesionOf(pairs, modFuncIds),
256
157
  });
257
158
  }
258
- // Worst first; stable by id so the ranking is deterministic.
259
- return measurements.sort((a, b) => a.cohesion - b.cohesion || (a.moduleId < b.moduleId ? -1 : a.moduleId > b.moduleId ? 1 : 0));
159
+ return rows;
160
+ }
161
+ /** MT-02 core: connected components over the allocated FUNCs. null below 2 FUNCs. */
162
+ function lcom4Of(graph, funcIds) {
163
+ if (funcIds.length < 2)
164
+ return null;
165
+ // Two FUNCs are connected if they share a common io/satisfy target.
166
+ const funcTargets = new Map();
167
+ for (const fid of funcIds) {
168
+ const targets = new Set();
169
+ for (const t of graph.traces) {
170
+ if (t.source === fid && (t.type === 'io' || t.type === 'satisfy'))
171
+ targets.add(t.target);
172
+ }
173
+ funcTargets.set(fid, targets);
174
+ }
175
+ const parent = new Map();
176
+ for (const fid of funcIds)
177
+ parent.set(fid, fid);
178
+ function find(x) {
179
+ while (parent.get(x) !== x) {
180
+ x = parent.get(x);
181
+ }
182
+ return x;
183
+ }
184
+ function union(a, b) {
185
+ const ra = find(a), rb = find(b);
186
+ if (ra !== rb)
187
+ parent.set(ra, rb);
188
+ }
189
+ for (let i = 0; i < funcIds.length; i++) {
190
+ for (let j = i + 1; j < funcIds.length; j++) {
191
+ const ti = funcTargets.get(funcIds[i]);
192
+ const tj = funcTargets.get(funcIds[j]);
193
+ for (const t of ti) {
194
+ if (tj.has(t)) {
195
+ union(funcIds[i], funcIds[j]);
196
+ break;
197
+ }
198
+ }
199
+ }
200
+ }
201
+ // CR-165/CR-171: FUNCs sharing the same FLOW (via io in either direction) are connected.
202
+ const funcIdSet = new Set(funcIds);
203
+ const flowToFuncs = new Map();
204
+ for (const t of graph.traces) {
205
+ if (t.type !== 'io')
206
+ continue;
207
+ if (funcIdSet.has(t.target)) {
208
+ const srcEl = graph.elements.find(e => e.id === t.source);
209
+ if (srcEl?.type === 'FLOW') {
210
+ if (!flowToFuncs.has(t.source))
211
+ flowToFuncs.set(t.source, new Set());
212
+ flowToFuncs.get(t.source).add(t.target);
213
+ }
214
+ }
215
+ if (funcIdSet.has(t.source)) {
216
+ const tgtEl = graph.elements.find(e => e.id === t.target);
217
+ if (tgtEl?.type === 'FLOW') {
218
+ if (!flowToFuncs.has(t.target))
219
+ flowToFuncs.set(t.target, new Set());
220
+ flowToFuncs.get(t.target).add(t.source);
221
+ }
222
+ }
223
+ }
224
+ for (const funcsInFlow of flowToFuncs.values()) {
225
+ const arr = [...funcsInFlow];
226
+ for (let i = 1; i < arr.length; i++)
227
+ union(arr[0], arr[i]);
228
+ }
229
+ return new Set(funcIds.map(find)).size;
230
+ }
231
+ /** CR-SM-223 core: internal vs. external connection pairs. null where there is no signal. */
232
+ function cohesionOf(pairs, funcIds) {
233
+ if (funcIds.size < 2)
234
+ return null;
235
+ let internal = 0;
236
+ let external = 0;
237
+ for (const [a, b] of pairs) {
238
+ const aIn = funcIds.has(a);
239
+ const bIn = funcIds.has(b);
240
+ if (aIn && bIn)
241
+ internal++;
242
+ else if (aIn || bIn)
243
+ external++;
244
+ }
245
+ // No external connections → nothing to compare against, no signal.
246
+ if (external === 0)
247
+ return null;
248
+ return { internal, external, ratio: internal / (internal + external) };
249
+ }
250
+ /**
251
+ * Per-module architecture metrics as NUMBERS — one row per MOD, threshold or not
252
+ * (CR-SM-232).
253
+ *
254
+ * MT-01 only ever reported the modules above 70 %, MT-02 only those with ≥ 4
255
+ * components, and both only inside a prose `message`. For every module below the
256
+ * threshold there was no value, there was nothing — so a trend ("was 62 %, is 68 %"),
257
+ * the actual steering signal, was unobtainable. This exports what the rules already
258
+ * compute; it invents no metric and calibrates no threshold.
259
+ *
260
+ * Sorted worst cohesion first (as `allocationCohesion` does — the ranking IS the
261
+ * signal); modules without a cohesion measurement follow, stable by `moduleId`.
262
+ */
263
+ export function moduleMetrics(graph) {
264
+ const byId = (a, b) => a.moduleId < b.moduleId ? -1 : a.moduleId > b.moduleId ? 1 : 0;
265
+ return measureModules(graph).sort((a, b) => {
266
+ if (a.cohesion && b.cohesion)
267
+ return a.cohesion.ratio - b.cohesion.ratio || byId(a, b);
268
+ if (a.cohesion)
269
+ return -1;
270
+ if (b.cohesion)
271
+ return 1;
272
+ return byId(a, b);
273
+ });
274
+ }
275
+ export function allocationCohesion(graph) {
276
+ // CR-SM-232: dieselbe Rechnung wie `moduleMetrics` — hier nur die Projektion auf
277
+ // die Module MIT Messwert. Zwei Implementierungen derselben Kohäsion wären genau
278
+ // die Drift, gegen die dieser CR angetreten ist.
279
+ return moduleMetrics(graph)
280
+ .filter((m) => m.cohesion !== null)
281
+ .map((m) => ({
282
+ moduleId: m.moduleId,
283
+ moduleName: m.moduleName,
284
+ internal: m.cohesion.internal,
285
+ external: m.cohesion.external,
286
+ cohesion: m.cohesion.ratio,
287
+ }));
260
288
  }
261
289
  export const MT_RULES = [
262
- { id: 'MT-01', name: 'Module instability', severity: 'warning', evaluate: mt01Instability },
263
- { id: 'MT-02', name: 'Module cohesion (LCOM4)', severity: 'info', evaluate: mt02Lcom4 },
290
+ { id: 'MT-01', name: 'Module instability', severity: 'warning', evaluate: mt01Instability, domain: ['MOD'] },
291
+ { id: 'MT-02', name: 'Module cohesion (LCOM4)', severity: 'info', evaluate: mt02Lcom4, domain: ['MOD'] },
264
292
  // MT-03 retired as a rule (CR-SM-223) — see `allocationCohesion` above.
265
293
  ];
266
- export function evaluateMTRules(graph) {
267
- return MT_RULES.flatMap(r => r.evaluate(graph));
294
+ export function evaluateMTRules(graph, policy) {
295
+ return MT_RULES.flatMap(r => r.evaluate(graph, policy));
268
296
  }
@@ -34,11 +34,13 @@ export declare const ND_RULES: readonly [{
34
34
  readonly name: "FuncNearDuplicate";
35
35
  readonly severity: "error";
36
36
  readonly evaluate: typeof nd01FuncNearDuplicate;
37
+ readonly domain: readonly ["FUNC"];
37
38
  }, {
38
39
  readonly id: "ND-02";
39
40
  readonly name: "SchemaNearDuplicate";
40
41
  readonly severity: "error";
41
42
  readonly evaluate: typeof nd02SchemaNearDuplicate;
43
+ readonly domain: readonly ["SCHEMA"];
42
44
  }];
43
45
  /** Evaluate all near-duplicate rules. */
44
46
  export declare function evaluateNDRules(graph: OntologyGraph): RuleViolation[];
@@ -97,8 +97,8 @@ export function nd02SchemaNearDuplicate(graph) {
97
97
  // Aggregate
98
98
  // ---------------------------------------------------------------------------
99
99
  export const ND_RULES = [
100
- { id: 'ND-01', name: 'FuncNearDuplicate', severity: 'error', evaluate: nd01FuncNearDuplicate },
101
- { id: 'ND-02', name: 'SchemaNearDuplicate', severity: 'error', evaluate: nd02SchemaNearDuplicate },
100
+ { id: 'ND-01', name: 'FuncNearDuplicate', severity: 'error', evaluate: nd01FuncNearDuplicate, domain: ['FUNC'] },
101
+ { id: 'ND-02', name: 'SchemaNearDuplicate', severity: 'error', evaluate: nd02SchemaNearDuplicate, domain: ['SCHEMA'] },
102
102
  ];
103
103
  /** Evaluate all near-duplicate rules. */
104
104
  export function evaluateNDRules(graph) {
@@ -95,26 +95,72 @@ export type RepoRelativePath = z.infer<typeof RepoRelativePathSchema>;
95
95
  * Runnable binding for a TEST element (CR-GC-134, ontology bump 3.4.0).
96
96
  * Resolves a TEST node to the concrete artefact a runner can execute, enabling
97
97
  * bottom-up selective test deduction (`graph_tests`): change → impacted TESTs →
98
- * `testRef` → minimal selective run command.
98
+ * `testRefs` → minimal selective run command.
99
99
  * - `file` : test file path, e.g. `tests/foo.test.ts` (the runner target).
100
100
  * - `case` : optional named case/`describe`/`it` block within the file.
101
101
  * - `tool` : the runner, e.g. `vitest`, `playwright`, `pytest`.
102
102
  * - `level` : optional level, e.g. `unit`, `integration`, `validation`.
103
- * Stored under `OntologyElement.attributes.testRef` (additive, opt-in validation).
103
+ *
104
+ * `tool` steht bewusst **pro Eintrag**, nicht hochgezogen: eine Abnahme mischt real die
105
+ * Runner — ein TEST kann aus `tests/dashboard.test.mjs` (vitest) *und*
106
+ * `tests/visual/dashboard-render.spec.mjs` (playwright) bestehen. Ein Runner je TEST-Knoten
107
+ * wäre eine Lüge, die das alte 1:1-Schema nur deshalb nicht produzierte, weil es die zweite
108
+ * Datei gar nicht erst zuliess.
104
109
  */
105
110
  export declare const TestRefSchema: z.ZodObject<{
106
111
  file: z.ZodString;
107
112
  case: z.ZodOptional<z.ZodString>;
108
113
  tool: z.ZodString;
109
114
  level: z.ZodOptional<z.ZodString>;
115
+ result: z.ZodOptional<z.ZodEnum<{
116
+ passed: "passed";
117
+ failed: "failed";
118
+ skipped: "skipped";
119
+ pending: "pending";
120
+ }>>;
121
+ ranAt: z.ZodOptional<z.ZodISODateTime>;
122
+ evidence: z.ZodOptional<z.ZodString>;
110
123
  }, z.core.$strip>;
111
124
  export type TestRef = z.infer<typeof TestRefSchema>;
125
+ /**
126
+ * CR-SM-231: eine Abnahme, **n** Testdateien — 1:n, nie n:m.
127
+ *
128
+ * Vorher war `testRef` ein einzelnes Objekt: ein TEST-Knoten war an genau eine Datei bindbar.
129
+ * Eine Abnahme aus Unit- *und* Visual-Lauf konnte ihre Evidenz damit nicht vollstaendig
130
+ * deklarieren, und der Ausweichweg war belegt — dieselbe Spec-Datei stand im `testRef` zweier
131
+ * TEST-Knoten. Damit war die Relation faktisch n:m: ein roter Lauf war keiner Abnahme mehr
132
+ * eindeutig zuordenbar, waehrend der TRR-Gate dieselbe Evidenz doppelt zaehlte.
133
+ *
134
+ * **Warum n:m hier falsch ist.** n:m zwischen Abnahme und Anforderung ist bereits korrekt
135
+ * modelliert und in Gebrauch (`TEST -[verify]-> REQ`) — dafuer ist eine Kante da. Die Bindung
136
+ * an die Laufdatei ist etwas anderes: sie ist **Evidenz-Adresse**, kein Traceability-Link. Sie
137
+ * gehoert ins Attribut und bleibt 1:n — ein TEST kennt n Dateien, eine Datei gehoert zu
138
+ * hoechstens einem TEST. Die zweite Haelfte erzwingt R-29.
139
+ *
140
+ * Gespeichert unter `OntologyElement.attributes.testRefs`. Das alte `testRef` entfaellt
141
+ * ersatzlos — kein Union, kein Alias, keine parallelen Pfade.
142
+ */
143
+ export declare const TestRefsSchema: z.ZodArray<z.ZodObject<{
144
+ file: z.ZodString;
145
+ case: z.ZodOptional<z.ZodString>;
146
+ tool: z.ZodString;
147
+ level: z.ZodOptional<z.ZodString>;
148
+ result: z.ZodOptional<z.ZodEnum<{
149
+ passed: "passed";
150
+ failed: "failed";
151
+ skipped: "skipped";
152
+ pending: "pending";
153
+ }>>;
154
+ ranAt: z.ZodOptional<z.ZodISODateTime>;
155
+ evidence: z.ZodOptional<z.ZodString>;
156
+ }, z.core.$strip>>;
157
+ export type TestRefs = z.infer<typeof TestRefsSchema>;
112
158
  /**
113
159
  * Realization binding for an element (CR-228, ontology bump 3.9.0) — unifies the
114
160
  * byte-identical former `codeRef` (FUNC → code symbol) and `schemaRef` (SCHEMA →
115
161
  * Zod export), and extends to MOD (physical part → CAD/geometry artefact). The
116
162
  * *type* of the pointing element disambiguates what kind of realization it is:
117
- * FUNC→code, SCHEMA→Zod-def, physical MOD→CAD. TEST keeps its own `testRef`
163
+ * FUNC→code, SCHEMA→Zod-def, physical MOD→CAD. TEST keeps its own `testRefs`
118
164
  * (a TEST is not just located but *executed* — the runner is the extra value).
119
165
  * - `file` : realization file path, e.g. `src/harness.ts`, `part.step`.
120
166
  * - `symbol` : the realizing symbol (function/class/Zod export). Optional — a
@@ -330,7 +376,12 @@ export declare const ELEMENT_DESCRIPTIONS: Record<ElementType, string>;
330
376
  export declare const MODELING_ELEMENT_TYPES: ElementType[];
331
377
  export interface AttributeSpec {
332
378
  key: string;
333
- type: 'string' | 'number' | 'boolean' | 'enum' | 'object';
379
+ /**
380
+ * CR-SM-231: `array` kommt dazu — `testRefs` ist das erste Attribut, das eine Liste ist.
381
+ * Als `object` deklariert waere es fuer jeden Konsumenten, der diese Spezifikation liest
382
+ * (Format-E-Editor, Doku-Generator), formal falsch.
383
+ */
384
+ type: 'string' | 'number' | 'boolean' | 'enum' | 'object' | 'array';
334
385
  enumValues?: readonly string[];
335
386
  description: string;
336
387
  }
@@ -58,25 +58,65 @@ export const RepoRelativePathSchema = z
58
58
  * Runnable binding for a TEST element (CR-GC-134, ontology bump 3.4.0).
59
59
  * Resolves a TEST node to the concrete artefact a runner can execute, enabling
60
60
  * bottom-up selective test deduction (`graph_tests`): change → impacted TESTs →
61
- * `testRef` → minimal selective run command.
61
+ * `testRefs` → minimal selective run command.
62
62
  * - `file` : test file path, e.g. `tests/foo.test.ts` (the runner target).
63
63
  * - `case` : optional named case/`describe`/`it` block within the file.
64
64
  * - `tool` : the runner, e.g. `vitest`, `playwright`, `pytest`.
65
65
  * - `level` : optional level, e.g. `unit`, `integration`, `validation`.
66
- * Stored under `OntologyElement.attributes.testRef` (additive, opt-in validation).
66
+ *
67
+ * `tool` steht bewusst **pro Eintrag**, nicht hochgezogen: eine Abnahme mischt real die
68
+ * Runner — ein TEST kann aus `tests/dashboard.test.mjs` (vitest) *und*
69
+ * `tests/visual/dashboard-render.spec.mjs` (playwright) bestehen. Ein Runner je TEST-Knoten
70
+ * wäre eine Lüge, die das alte 1:1-Schema nur deshalb nicht produzierte, weil es die zweite
71
+ * Datei gar nicht erst zuliess.
67
72
  */
68
73
  export const TestRefSchema = z.object({
69
74
  file: RepoRelativePathSchema,
70
75
  case: z.string().optional(),
71
76
  tool: z.string(),
72
77
  level: z.string().optional(),
78
+ /**
79
+ * CR-SM-231b: das Ergebnis haengt **pro Eintrag**, aus demselben Grund wie `tool`.
80
+ *
81
+ * Vorher lag `testResult` am TEST-**Knoten**. Mit n Eintraegen ist das mehrdeutig: laeuft eine
82
+ * Abnahme als vitest *und* playwright, kann ein einzelnes Ergebnis nicht sagen, welcher Lauf
83
+ * gemeint ist — und „einer rot, einer gruen" ist gar nicht darstellbar. Genau der Zustand,
84
+ * den ein Gate wissen muss.
85
+ *
86
+ * Optional: ein Eintrag ohne Ergebnis ist noch nicht gelaufen, nicht „bestanden". VR-01
87
+ * meldet das.
88
+ */
89
+ result: TestResult.optional(),
90
+ /** ISO-Zeitstempel des Laufs, der `result` erzeugt hat. Ohne ihn ist ein Ergebnis undatiert. */
91
+ ranAt: z.iso.datetime().optional(),
92
+ /** Repo-relativer Pfad zum Lauf-Artefakt (Report, Screenshot, Trace) — die Evidenz zur Aussage. */
93
+ evidence: RepoRelativePathSchema.optional(),
73
94
  });
95
+ /**
96
+ * CR-SM-231: eine Abnahme, **n** Testdateien — 1:n, nie n:m.
97
+ *
98
+ * Vorher war `testRef` ein einzelnes Objekt: ein TEST-Knoten war an genau eine Datei bindbar.
99
+ * Eine Abnahme aus Unit- *und* Visual-Lauf konnte ihre Evidenz damit nicht vollstaendig
100
+ * deklarieren, und der Ausweichweg war belegt — dieselbe Spec-Datei stand im `testRef` zweier
101
+ * TEST-Knoten. Damit war die Relation faktisch n:m: ein roter Lauf war keiner Abnahme mehr
102
+ * eindeutig zuordenbar, waehrend der TRR-Gate dieselbe Evidenz doppelt zaehlte.
103
+ *
104
+ * **Warum n:m hier falsch ist.** n:m zwischen Abnahme und Anforderung ist bereits korrekt
105
+ * modelliert und in Gebrauch (`TEST -[verify]-> REQ`) — dafuer ist eine Kante da. Die Bindung
106
+ * an die Laufdatei ist etwas anderes: sie ist **Evidenz-Adresse**, kein Traceability-Link. Sie
107
+ * gehoert ins Attribut und bleibt 1:n — ein TEST kennt n Dateien, eine Datei gehoert zu
108
+ * hoechstens einem TEST. Die zweite Haelfte erzwingt R-29.
109
+ *
110
+ * Gespeichert unter `OntologyElement.attributes.testRefs`. Das alte `testRef` entfaellt
111
+ * ersatzlos — kein Union, kein Alias, keine parallelen Pfade.
112
+ */
113
+ export const TestRefsSchema = z.array(TestRefSchema).min(1);
74
114
  /**
75
115
  * Realization binding for an element (CR-228, ontology bump 3.9.0) — unifies the
76
116
  * byte-identical former `codeRef` (FUNC → code symbol) and `schemaRef` (SCHEMA →
77
117
  * Zod export), and extends to MOD (physical part → CAD/geometry artefact). The
78
118
  * *type* of the pointing element disambiguates what kind of realization it is:
79
- * FUNC→code, SCHEMA→Zod-def, physical MOD→CAD. TEST keeps its own `testRef`
119
+ * FUNC→code, SCHEMA→Zod-def, physical MOD→CAD. TEST keeps its own `testRefs`
80
120
  * (a TEST is not just located but *executed* — the runner is the extra value).
81
121
  * - `file` : realization file path, e.g. `src/harness.ts`, `part.step`.
82
122
  * - `symbol` : the realizing symbol (function/class/Zod export). Optional — a
@@ -181,10 +221,10 @@ export const MODELING_ELEMENT_TYPES = ElementType.options.filter(t => t !== 'SES
181
221
  */
182
222
  export const ELEMENT_ATTRIBUTES = {
183
223
  TEST: [
184
- { key: 'testResult', type: 'enum', enumValues: TestResult.options, description: 'Test execution outcome' },
224
+ // CR-SM-231b: das knotenweite `testResult` ist weg das Ergebnis haengt pro testRefs-Eintrag.
185
225
  { key: 'sourceFile', type: 'string', description: 'Test file path (e.g. tests/foo.test.ts)' },
186
- { key: 'testRef', type: 'object', description: 'Runnable binding {file, case?, tool, level?} — see TestRefSchema (CR-GC-134)' },
187
- { key: 'concept', type: 'boolean', description: 'Concept-only TEST: no run artifact yet; exempt from the R-19 testRef-binding requirement (CR-GC-205)' },
226
+ { key: 'testRefs', type: 'array', description: 'Runnable bindings [{file, case?, tool, level?, result?, ranAt?, evidence?}, …] one acceptance, n test files (1:n), each with its own outcome. See TestRefsSchema (CR-SM-231)' },
227
+ { key: 'concept', type: 'boolean', description: 'Concept-only TEST: no run artifact yet; exempt from the R-19 testRefs-binding requirement (CR-GC-205)' },
188
228
  ],
189
229
  REQ: [
190
230
  { key: 'severity', type: 'number', description: 'FMEA severity (1-10)' },