@sigloch/contracts 3.2.0 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -52,6 +52,40 @@ export interface AllocationCohesion {
52
52
  * Validation of this metric — and of MT-01/MT-02, which are thresholded the same way —
53
53
  * is deferred (CR-SM-223).
54
54
  */
55
+ /** Per-module architecture metrics — the numbers behind MT-01/MT-02 (CR-SM-232). */
56
+ export interface ModuleMetrics {
57
+ moduleId: string;
58
+ moduleName: string;
59
+ /** FUNCs allocated to this module (`FUNC —allocate→ MOD`). */
60
+ allocatedFuncs: number;
61
+ /** MT-01 core: coupling. `instability = fanOut / (fanIn + fanOut)`. */
62
+ fanIn: number;
63
+ fanOut: number;
64
+ /** null when fanIn + fanOut === 0 — no signal, no substitute value. */
65
+ instability: number | null;
66
+ /** MT-02 core: connected-component count. null below 2 allocated FUNCs (not measurable). */
67
+ lcom4: number | null;
68
+ /** The CR-SM-223 measurement, deliberately threshold-free. null where contracts omits it. */
69
+ cohesion: {
70
+ internal: number;
71
+ external: number;
72
+ ratio: number;
73
+ } | null;
74
+ }
75
+ /**
76
+ * Per-module architecture metrics as NUMBERS — one row per MOD, threshold or not
77
+ * (CR-SM-232).
78
+ *
79
+ * MT-01 only ever reported the modules above 70 %, MT-02 only those with ≥ 4
80
+ * components, and both only inside a prose `message`. For every module below the
81
+ * threshold there was no value, there was nothing — so a trend ("was 62 %, is 68 %"),
82
+ * the actual steering signal, was unobtainable. This exports what the rules already
83
+ * compute; it invents no metric and calibrates no threshold.
84
+ *
85
+ * Sorted worst cohesion first (as `allocationCohesion` does — the ranking IS the
86
+ * signal); modules without a cohesion measurement follow, stable by `moduleId`.
87
+ */
88
+ export declare function moduleMetrics(graph: OntologyGraph): ModuleMetrics[];
55
89
  export declare function allocationCohesion(graph: OntologyGraph): AllocationCohesion[];
56
90
  export declare const MT_RULES: readonly [{
57
91
  readonly id: "MT-01";
@@ -8,39 +8,18 @@ const INSTABILITY_THRESHOLD = 0.7;
8
8
  */
9
9
  export function mt01Instability(graph) {
10
10
  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)
11
+ // CR-SM-232: EINE Rechnung, zwei Ausgaben. Die fan_in/fan_out-Formel steht in
12
+ // `measureModules` hier wird nur noch geschwellt. Sonst stünde die Formel
13
+ // doppelt im selben File und der Export wäre eine zweite Implementierung.
14
+ for (const m of measureModules(graph)) {
15
+ if (m.instability === null)
36
16
  continue;
37
- const instability = fanOut / (fanIn + fanOut);
38
- if (instability > INSTABILITY_THRESHOLD) {
17
+ if (m.instability > INSTABILITY_THRESHOLD) {
39
18
  violations.push({
40
19
  rule_id: 'MT-01',
41
20
  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}`,
21
+ element_id: m.moduleId,
22
+ message: `${m.moduleName} has instability ${Math.round(m.instability * 100)}% (>${INSTABILITY_THRESHOLD * 100}%). fan_in=${m.fanIn}, fan_out=${m.fanOut}`,
44
23
  });
45
24
  }
46
25
  }
@@ -53,99 +32,16 @@ export function mt01Instability(graph) {
53
32
  */
54
33
  export function mt02Lcom4(graph) {
55
34
  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)
35
+ // CR-SM-232: die Union-Find-Rechnung steht in `measureModules`; hier nur die Stufen.
36
+ for (const m of measureModules(graph)) {
37
+ if (m.lcom4 === null)
62
38
  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);
39
+ const message = `${m.moduleName} has LCOM4=${m.lcom4} (${m.allocatedFuncs} FUNCs in ${m.lcom4} disconnected groups)`;
40
+ if (m.lcom4 >= 4 && m.lcom4 <= 5) {
41
+ violations.push({ rule_id: 'MT-02', severity: 'info', element_id: m.moduleId, message });
73
42
  }
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]);
131
- }
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
- });
43
+ else if (m.lcom4 > 5) {
44
+ violations.push({ rule_id: 'MT-02', severity: 'warning', element_id: m.moduleId, message });
149
45
  }
150
46
  }
151
47
  return violations;
@@ -204,59 +100,181 @@ function connectionPairs(graph) {
204
100
  return pairs;
205
101
  }
206
102
  /**
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.
103
+ * The one per-module computation (CR-SM-232) — in GRAPH ORDER, unsorted.
216
104
  *
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.
105
+ * `mt01Instability` and `mt02Lcom4` consume this and only threshold; `moduleMetrics()`
106
+ * exports a sorted copy. Keeping the internal list in `graph.elements` order is what
107
+ * makes the violation ORDER of both rules byte-identical to the pre-CR implementation
108
+ * the export's worst-first ranking must not leak into the rule output.
221
109
  *
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.
225
- *
226
- * Validation of this metric — and of MT-01/MT-02, which are thresholded the same way —
227
- * is deferred (CR-SM-223).
110
+ * Every value that is not measurable is `null`, never 0: a module with one allocated
111
+ * FUNC has no LCOM4, and a 1 there would be an invented statement about cohesion.
228
112
  */
229
- export function allocationCohesion(graph) {
230
- const measurements = [];
113
+ function measureModules(graph) {
231
114
  const mods = graph.elements.filter(e => e.type === 'MOD');
232
115
  const pairs = [...connectionPairs(graph)].map(p => p.split('|'));
116
+ const rows = [];
233
117
  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++;
118
+ const allocated = graph.traces
119
+ .filter(t => t.type === 'allocate' && t.target === mod.id)
120
+ .map(t => t.source);
121
+ const modFuncIds = new Set(allocated);
122
+ // --- MT-01: fan-in / fan-out (CR-165, indirect via the allocate path) ---
123
+ const directOut = graph.traces.filter(t => t.source === mod.id && (t.type === 'io' || t.type === 'compose') && t.target !== mod.id).length;
124
+ const directIn = graph.traces.filter(t => t.target === mod.id && (t.type === 'io' || t.type === 'compose' || t.type === 'allocate')).length;
125
+ let indirectOut = 0;
126
+ let indirectIn = 0;
127
+ for (const t of graph.traces) {
128
+ if (t.type === 'allocate')
129
+ continue; // allocate itself doesn't count as coupling
130
+ if (modFuncIds.has(t.source) && !modFuncIds.has(t.target) && t.target !== mod.id)
131
+ indirectOut++;
132
+ if (modFuncIds.has(t.target) && !modFuncIds.has(t.source) && t.source !== mod.id)
133
+ indirectIn++;
246
134
  }
247
- // No external connections nothing to compare against, no signal.
248
- if (external === 0)
249
- continue;
250
- measurements.push({
135
+ const fanOut = directOut + indirectOut;
136
+ const fanIn = directIn + indirectIn;
137
+ const instability = fanIn + fanOut === 0 ? null : fanOut / (fanIn + fanOut);
138
+ rows.push({
251
139
  moduleId: mod.id,
252
140
  moduleName: mod.name,
253
- internal,
254
- external,
255
- cohesion: internal / (internal + external),
141
+ allocatedFuncs: allocated.length,
142
+ fanIn,
143
+ fanOut,
144
+ instability,
145
+ lcom4: lcom4Of(graph, allocated),
146
+ cohesion: cohesionOf(pairs, modFuncIds),
256
147
  });
257
148
  }
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));
149
+ return rows;
150
+ }
151
+ /** MT-02 core: connected components over the allocated FUNCs. null below 2 FUNCs. */
152
+ function lcom4Of(graph, funcIds) {
153
+ if (funcIds.length < 2)
154
+ return null;
155
+ // Two FUNCs are connected if they share a common io/satisfy target.
156
+ const funcTargets = new Map();
157
+ for (const fid of funcIds) {
158
+ const targets = new Set();
159
+ for (const t of graph.traces) {
160
+ if (t.source === fid && (t.type === 'io' || t.type === 'satisfy'))
161
+ targets.add(t.target);
162
+ }
163
+ funcTargets.set(fid, targets);
164
+ }
165
+ const parent = new Map();
166
+ for (const fid of funcIds)
167
+ parent.set(fid, fid);
168
+ function find(x) {
169
+ while (parent.get(x) !== x) {
170
+ x = parent.get(x);
171
+ }
172
+ return x;
173
+ }
174
+ function union(a, b) {
175
+ const ra = find(a), rb = find(b);
176
+ if (ra !== rb)
177
+ parent.set(ra, rb);
178
+ }
179
+ for (let i = 0; i < funcIds.length; i++) {
180
+ for (let j = i + 1; j < funcIds.length; j++) {
181
+ const ti = funcTargets.get(funcIds[i]);
182
+ const tj = funcTargets.get(funcIds[j]);
183
+ for (const t of ti) {
184
+ if (tj.has(t)) {
185
+ union(funcIds[i], funcIds[j]);
186
+ break;
187
+ }
188
+ }
189
+ }
190
+ }
191
+ // CR-165/CR-171: FUNCs sharing the same FLOW (via io in either direction) are connected.
192
+ const funcIdSet = new Set(funcIds);
193
+ const flowToFuncs = new Map();
194
+ for (const t of graph.traces) {
195
+ if (t.type !== 'io')
196
+ continue;
197
+ if (funcIdSet.has(t.target)) {
198
+ const srcEl = graph.elements.find(e => e.id === t.source);
199
+ if (srcEl?.type === 'FLOW') {
200
+ if (!flowToFuncs.has(t.source))
201
+ flowToFuncs.set(t.source, new Set());
202
+ flowToFuncs.get(t.source).add(t.target);
203
+ }
204
+ }
205
+ if (funcIdSet.has(t.source)) {
206
+ const tgtEl = graph.elements.find(e => e.id === t.target);
207
+ if (tgtEl?.type === 'FLOW') {
208
+ if (!flowToFuncs.has(t.target))
209
+ flowToFuncs.set(t.target, new Set());
210
+ flowToFuncs.get(t.target).add(t.source);
211
+ }
212
+ }
213
+ }
214
+ for (const funcsInFlow of flowToFuncs.values()) {
215
+ const arr = [...funcsInFlow];
216
+ for (let i = 1; i < arr.length; i++)
217
+ union(arr[0], arr[i]);
218
+ }
219
+ return new Set(funcIds.map(find)).size;
220
+ }
221
+ /** CR-SM-223 core: internal vs. external connection pairs. null where there is no signal. */
222
+ function cohesionOf(pairs, funcIds) {
223
+ if (funcIds.size < 2)
224
+ return null;
225
+ let internal = 0;
226
+ let external = 0;
227
+ for (const [a, b] of pairs) {
228
+ const aIn = funcIds.has(a);
229
+ const bIn = funcIds.has(b);
230
+ if (aIn && bIn)
231
+ internal++;
232
+ else if (aIn || bIn)
233
+ external++;
234
+ }
235
+ // No external connections → nothing to compare against, no signal.
236
+ if (external === 0)
237
+ return null;
238
+ return { internal, external, ratio: internal / (internal + external) };
239
+ }
240
+ /**
241
+ * Per-module architecture metrics as NUMBERS — one row per MOD, threshold or not
242
+ * (CR-SM-232).
243
+ *
244
+ * MT-01 only ever reported the modules above 70 %, MT-02 only those with ≥ 4
245
+ * components, and both only inside a prose `message`. For every module below the
246
+ * threshold there was no value, there was nothing — so a trend ("was 62 %, is 68 %"),
247
+ * the actual steering signal, was unobtainable. This exports what the rules already
248
+ * compute; it invents no metric and calibrates no threshold.
249
+ *
250
+ * Sorted worst cohesion first (as `allocationCohesion` does — the ranking IS the
251
+ * signal); modules without a cohesion measurement follow, stable by `moduleId`.
252
+ */
253
+ export function moduleMetrics(graph) {
254
+ const byId = (a, b) => a.moduleId < b.moduleId ? -1 : a.moduleId > b.moduleId ? 1 : 0;
255
+ return measureModules(graph).sort((a, b) => {
256
+ if (a.cohesion && b.cohesion)
257
+ return a.cohesion.ratio - b.cohesion.ratio || byId(a, b);
258
+ if (a.cohesion)
259
+ return -1;
260
+ if (b.cohesion)
261
+ return 1;
262
+ return byId(a, b);
263
+ });
264
+ }
265
+ export function allocationCohesion(graph) {
266
+ // CR-SM-232: dieselbe Rechnung wie `moduleMetrics` — hier nur die Projektion auf
267
+ // die Module MIT Messwert. Zwei Implementierungen derselben Kohäsion wären genau
268
+ // die Drift, gegen die dieser CR angetreten ist.
269
+ return moduleMetrics(graph)
270
+ .filter((m) => m.cohesion !== null)
271
+ .map((m) => ({
272
+ moduleId: m.moduleId,
273
+ moduleName: m.moduleName,
274
+ internal: m.cohesion.internal,
275
+ external: m.cohesion.external,
276
+ cohesion: m.cohesion.ratio,
277
+ }));
260
278
  }
261
279
  export const MT_RULES = [
262
280
  { id: 'MT-01', name: 'Module instability', severity: 'warning', evaluate: mt01Instability },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sigloch/contracts",
3
- "version": "3.2.0",
3
+ "version": "3.3.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",