@sigloch/contracts 3.1.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.
@@ -5,7 +5,7 @@
5
5
  /** Ontology schema version (element types + trace types). */
6
6
  export declare const ONTOLOGY_VERSION = "4.0.0";
7
7
  /** Rules engine version (validation rules incl. RC conformance). */
8
- export declare const RULES_VERSION = "2.23.0";
8
+ export declare const RULES_VERSION = "2.24.0";
9
9
  /** Meta-model version (trace pattern constraints + format-e parser). */
10
10
  export declare const META_MODEL_VERSION = "1.4.0";
11
11
  export * from './ontology.js';
package/dist/se/index.js CHANGED
@@ -5,7 +5,7 @@
5
5
  /** Ontology schema version (element types + trace types). */
6
6
  export const ONTOLOGY_VERSION = '4.0.0'; // BREAKING: `SemanticId` (`Name.TypeAbbr.Counter`) deleted, `ElementUid` (`<TYPE>-<slug>`) is the family canon — the old canon was used by no production graph of the family while 626 of 1145 elements already carried TYPE-slug (CR-SM-217); realRef unifies codeRef+schemaRef (+physical-MOD CAD ref), symbol optional; testRef stays separate (CR-228 C); +RepoRelativePathSchema on testRef/realRef .file — no absolute/`..` paths (CR-GC-255)
7
7
  /** Rules engine version (validation rules incl. RC conformance). */
8
- export const RULES_VERSION = '2.23.0'; // CR-SM-227: +AF-01..05 Analysis-Freshness-Legs (analysis-freshness-rules.ts) — the third leg-kind a review gate checks (rule legs, layer-presence legs [R-28], now analysis-freshness legs, docs/articles/07-the-scoring-landscape.md); presence-only rules (Presence/Resolution-Split like R-19/R-20/R-26/R-27 vs. RC-01..05) checking a `graphVersion` freshness stamp exists under SYS.attributes.analysisFreshness.<AnalysisArtifactId> for each of the 5 CR-GC-221 judgment artifacts (conops/trade/assumption-review/fmea/implplan) — staleness (stamp vs live graphVersion) stays a consumer/I-O concern (`CreationCurrencyProvider`, @sigloch/graphcode-client), NOT evaluated here; Familie-Review 2026-08-05 gate assignment → RULE_TO_PHASE: ConOps/Trade/Assumption-Review → PDR, FMEA → CDR, Implementation Plan → TRR (SRR gets none); +AnalysisArtifactId enum + AnalysisFreshnessStampSchema (ontology.ts) as the SSOT graphcode-client's ARTIFACT_CATALOG analysis ids must match; grober Graph-Versions-Stempel only, no scope-hash in this first cut (conscious CR-SM-227 limitation).
8
+ export const RULES_VERSION = '2.24.0'; // CR-GC-315: R-12 + R-21 no longer tax FLOW reuse. R-12 restricted to dependency-carrying traces (compose/allocate/relation) — on `io` the only reachable shape was FUNC ─io→ FLOW ─io→ the SAME FUNC, i.e. read-modify-write on a shared FLOW, so the check was pure false-positive there (real io-cycles span ≥2 FUNCs and a 2-cycle test cannot see them); satisfy/verify/produces 2-cycles are pattern-illegal and belong to R-18. Its dedup key is now direction-independent (`type|sorted pair`) — keying on the message never collapsed anything, so every cycle was reported twice. R-21 no longer derives FUNC↔FUNC connections from FLOW co-adjacency alone: a producer/consumer pair with NO shared FCHAIN is silent. One hub FLOW with P producers and C consumers manufactured P·C findings, each demanding its own FCHAIN+integration test including pairs that never interact — reuse was penalised quadratically. The FCHAIN is the declared integration scope; the test is owed on that declared claim only. Rule intent (CR-GC-240 gap: unit/UC tests do not cover FUNC↔FUNC wiring) unchanged. Prior: CR-SM-227: +AF-01..05 Analysis-Freshness-Legs (analysis-freshness-rules.ts) — the third leg-kind a review gate checks (rule legs, layer-presence legs [R-28], now analysis-freshness legs, docs/articles/07-the-scoring-landscape.md); presence-only rules (Presence/Resolution-Split like R-19/R-20/R-26/R-27 vs. RC-01..05) checking a `graphVersion` freshness stamp exists under SYS.attributes.analysisFreshness.<AnalysisArtifactId> for each of the 5 CR-GC-221 judgment artifacts (conops/trade/assumption-review/fmea/implplan) — staleness (stamp vs live graphVersion) stays a consumer/I-O concern (`CreationCurrencyProvider`, @sigloch/graphcode-client), NOT evaluated here; Familie-Review 2026-08-05 gate assignment → RULE_TO_PHASE: ConOps/Trade/Assumption-Review → PDR, FMEA → CDR, Implementation Plan → TRR (SRR gets none); +AnalysisArtifactId enum + AnalysisFreshnessStampSchema (ontology.ts) as the SSOT graphcode-client's ARTIFACT_CATALOG analysis ids must match; grober Graph-Versions-Stempel only, no scope-hash in this first cut (conscious CR-SM-227 limitation).
9
9
  /** Meta-model version (trace pattern constraints + format-e parser). */
10
10
  export const META_MODEL_VERSION = '1.4.0'; // -REQ→MOD allocate pattern removed (CR-228 A); +FUNC→FUNC compose (blackbox function decomposition)
11
11
  export * from './ontology.js';
@@ -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/dist/se/rules.js CHANGED
@@ -312,28 +312,39 @@ function flowCompleteness(graph) {
312
312
  // R-11: REMOVED — superseded by SC-02 (identical check, better severity).
313
313
  // ---------------------------------------------------------------------------
314
314
  // ---------------------------------------------------------------------------
315
- // R-12: Circular dependency detection
316
- // ---------------------------------------------------------------------------
315
+ // R-12: Circular dependency detection — direct 2-cycles over dependency-carrying
316
+ // traces only (CR-GC-315). `io` is excluded: the only shape a 2-cycle test can
317
+ // hit on `io` is FUNC ─io→ FLOW ─io→ the SAME FUNC, i.e. read-modify-write on a
318
+ // shared FLOW — the reuse pattern, not a dependency cycle. Real io-cycles span
319
+ // ≥2 FUNCs and are invisible to a 2-cycle test anyway, so on `io` the rule was
320
+ // pure false-positive and taxed FLOW reuse. satisfy/verify/produces 2-cycles are
321
+ // already pattern-illegal and belong to R-18.
322
+ // ---------------------------------------------------------------------------
323
+ /** Trace types on which a direct 2-cycle is a real finding (CR-GC-315). */
324
+ const CIRCULAR_TRACE_TYPES = new Set(['compose', 'allocate', 'relation']);
317
325
  function noDirectCircular(graph) {
318
326
  const violations = [];
319
- for (const t of graph.traces) {
320
- if (graph.traces.some(other => other.source === t.target && other.target === t.source && other.type === t.type)) {
321
- violations.push({
322
- rule_id: 'R-12',
323
- severity: 'warning',
324
- element_id: t.source,
325
- message: `Circular ${t.type} between ${t.source} and ${t.target}`,
326
- });
327
- }
328
- }
327
+ // Direction-independent key: A↔B is ONE cycle, reported once. Keying on the
328
+ // message (CR-GC-315 predecessor) never collapsed anything the two
329
+ // directions render different messages, so every cycle was reported twice.
329
330
  const seen = new Set();
330
- return violations.filter(v => {
331
- const key = v.message;
331
+ for (const t of graph.traces) {
332
+ if (!CIRCULAR_TRACE_TYPES.has(t.type))
333
+ continue;
334
+ if (!graph.traces.some(other => other.source === t.target && other.target === t.source && other.type === t.type))
335
+ continue;
336
+ const key = `${t.type}|${[t.source, t.target].sort().join('|')}`;
332
337
  if (seen.has(key))
333
- return false;
338
+ continue;
334
339
  seen.add(key);
335
- return true;
336
- });
340
+ violations.push({
341
+ rule_id: 'R-12',
342
+ severity: 'warning',
343
+ element_id: t.source,
344
+ message: `Circular ${t.type} between ${t.source} and ${t.target}`,
345
+ });
346
+ }
347
+ return violations;
337
348
  }
338
349
  // ---------------------------------------------------------------------------
339
350
  // R-13: DELETED — superseded by RD-01 (CR-180)
@@ -724,13 +735,17 @@ function funcMustHaveCodeBinding(graph) {
724
735
  }
725
736
  /** All rules — see RULES_VERSION in ./index.ts (R-06/R-07/R-09/R-11/R-13/R-24/R-25 removed, R-14..R-23/R-26/RD-01..03/MS-01..02 added) */
726
737
  // ---------------------------------------------------------------------------
727
- // R-21: Every FUNC↔FUNC connection must be covered by an integration test.
728
- // A connection is FUNC ─io→ FLOW ─io→ FUNC. It is covered iff both
729
- // endpoints share an FCHAIN that owns a verifying integration test
730
- // (TEST ─verify→ REQ ←satisfy─ FCHAIN). Unit tests (FUNC→REQ) and UC
731
- // acceptance tests do NOT cover the interface between two functions —
732
- // CR-GC-240 folded "integration" onto the UC level, leaving FUNC↔FUNC
733
- // wiring unverified; this rule closes that gap edge-granularly.
738
+ // R-21: Every FUNC↔FUNC connection *declared as an FCHAIN* must be covered by an
739
+ // integration test. A connection is FUNC ─io→ FLOW ─io→ FUNC. It is
740
+ // covered iff both endpoints share an FCHAIN that owns a verifying
741
+ // integration test (TEST ─verify→ REQ ←satisfy─ FCHAIN). Unit tests
742
+ // (FUNC→REQ) and UC acceptance tests do NOT cover the interface between
743
+ // two functions — CR-GC-240 folded "integration" onto the UC level,
744
+ // leaving FUNC↔FUNC wiring unverified; this rule closes that gap.
745
+ // CR-GC-315: a producer/consumer pair with NO shared FCHAIN is silent.
746
+ // Co-adjacency at a shared FLOW is not an asserted interface — treating it
747
+ // as one produced P·C findings per hub FLOW and made reuse the expensive
748
+ // choice. The FCHAIN is the modelled claim; the test is owed on the claim.
734
749
  // ---------------------------------------------------------------------------
735
750
  function fchainMustHaveIntegrationTest(graph) {
736
751
  const typeOf = new Map(graph.elements.map(e => [e.id, e.type]));
@@ -769,9 +784,15 @@ function fchainMustHaveIntegrationTest(graph) {
769
784
  const seen = new Set();
770
785
  for (const [p, c] of connections) {
771
786
  const shared = [...(chainsOfFunc.get(p) ?? [])].filter(ch => chainsOfFunc.get(c)?.has(ch));
787
+ // No shared FCHAIN = no asserted integration (CR-GC-315). Sharing one FLOW
788
+ // between P producers and C consumers does not mean P·C interfaces exist —
789
+ // deriving connections from FLOW adjacency alone taxed reuse quadratically.
790
+ // The FCHAIN is the declared integration scope; only that is held to a test.
791
+ if (shared.length === 0)
792
+ continue;
772
793
  if (shared.some(ch => testedChains.has(ch)))
773
794
  continue;
774
- const anchor = shared[0] ?? p;
795
+ const anchor = shared[0];
775
796
  const key = `${anchor}|${p}>${c}`;
776
797
  if (seen.has(key))
777
798
  continue;
@@ -781,10 +802,8 @@ function fchainMustHaveIntegrationTest(graph) {
781
802
  rule_id: 'R-21',
782
803
  severity: 'warning',
783
804
  element_id: anchor,
784
- message: shared.length > 0
785
- ? `${anchor} has no integration test covering connection ${p} → ${c}`
786
- : `connection ${p} → ${c} is in no FCHAIN with an integration test`,
787
- fix_hint: 'Group both FUNCs in an FCHAIN and verify an FCHAIN-satisfied REQ with an integration TEST',
805
+ message: `${anchor} has no integration test covering connection ${p} → ${c}`,
806
+ fix_hint: 'Verify an FCHAIN-satisfied REQ with an integration TEST',
788
807
  context: { element_type: anchorEl?.type, element_name: anchorEl?.name },
789
808
  });
790
809
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sigloch/contracts",
3
- "version": "3.1.0",
3
+ "version": "3.3.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",