@sigloch/contracts 10.12.0 → 10.14.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.
@@ -6,6 +6,44 @@ const ARTIFACT_LABEL = {
6
6
  fmea: 'FMEA',
7
7
  implplan: 'Implementation Plan',
8
8
  };
9
+ /**
10
+ * CR-SM-382: was der Hinweis nennt — den Task, seinen Skill und den ersten Arbeitsschritt.
11
+ *
12
+ * Bis hierher lautete der fix_hint "Set attributes.analysisFreshness.<id>.graphVersion …". Gemessen im
13
+ * lokalen AgentDiary-Lauf (local-1, 2026-09-30): das Modell setzte fuenf Stempel in einem Zug, ohne ein
14
+ * einziges Artefakt, und meldete die Analysen als erledigt — der Hinweis nannte die Metrik als Handlung.
15
+ * Dazu wich die Artefakt-id vom Task-Namen ab (`implplan` gegen `plan`), der Aufruf
16
+ * `graph_generate {task:'implplan'}` schlug fehl. Der Hinweis nannte darauf den Aufruf woertlich und die
17
+ * Arbeit; den Stempel schreibt der letzte Schritt des Skills, zusammen mit den Funden.
18
+ *
19
+ * CR-SM-387: der Hinweis nennt keinen Werkzeugnamen mehr. `graph_generate` hat nur der Autopilot; der
20
+ * interaktive Agent (todo-local, 2026-10-03) zitierte den Aufruf zweimal als fehlendes Werkzeug und
21
+ * liess die Analyse liegen. Der Skill fuehrt die Analyse in beiden Betriebsarten — er ist der Name, der
22
+ * stimmen muss (`skill` folgt RULE_HELP[…].prompt, der Test haelt beide zusammen); damit entfaellt auch
23
+ * die Fehlerquelle `task` gegen Artefakt-id.
24
+ */
25
+ const ANALYSIS_WORK = {
26
+ conops: {
27
+ skill: 'se-conops',
28
+ firstStep: 'walk configuration, credentials, user management, deployment, observability and data lifecycle, and write each concern the system must satisfy as a non-functional REQ with its TEST at SYS scope',
29
+ },
30
+ trade: {
31
+ skill: 'se-trade',
32
+ firstStep: 'name the open decision with its options and criteria, then record the chosen option as a CR with relation(label: decides) edges to what it decides',
33
+ },
34
+ 'assumption-review': {
35
+ skill: 'se-irr',
36
+ firstStep: 'list every unproven assumption the model rests on (unmeasured numbers, unverified external dependencies) in a record under docs/records/, then promote the load-bearing ones to CRs',
37
+ },
38
+ fmea: {
39
+ skill: 'se-fmea',
40
+ firstStep: 'for each FCHAIN name its failure modes and add each one as a REQ with role risk (severity, occurrence, detection) plus a mitigation REQ and its TEST',
41
+ },
42
+ implplan: {
43
+ skill: 'se-plan',
44
+ firstStep: 'cut one CR per leaf REQ at its carrier and group the CRs into MS milestones in dependency order',
45
+ },
46
+ };
9
47
  /** Presence-check for one analysis artifact's freshness stamp, anchored on SYS. */
10
48
  function analysisFreshnessPresence(ruleId, artifactId) {
11
49
  return (graph) => {
@@ -16,12 +54,13 @@ function analysisFreshnessPresence(ruleId, artifactId) {
16
54
  if (AnalysisFreshnessStampSchema.safeParse(stamp).success)
17
55
  return [];
18
56
  const label = ARTIFACT_LABEL[artifactId];
57
+ const work = ANALYSIS_WORK[artifactId];
19
58
  return [{
20
59
  rule_id: ruleId,
21
60
  severity: 'warning',
22
61
  element_id: sys.id,
23
- message: `${label} (${artifactId}) has no freshness stamp — was it ever written with a graphVersion stamp?`,
24
- fix_hint: `Set attributes.analysisFreshness.${artifactId}.graphVersion to the current graphVersion() when writing/refreshing the ${label} artifact`,
62
+ message: `${label} has not been carried out — no analysis is on record for this system`,
63
+ fix_hint: `Carry out the ${label}: work through the skill ${work.skill} step by step. First step: ${work.firstStep}. The closing step of the skill records the analysis as done, in the same batch as its findings`,
25
64
  context: { element_type: sys.type, element_name: sys.name },
26
65
  }];
27
66
  };
@@ -104,7 +104,7 @@ function codeRefMustResolve(graph, facts) {
104
104
  severity: 'warning',
105
105
  element_id: el.id,
106
106
  message: `${el.id} realRef.file '${ref.file}' does not exist on disk`,
107
- fix_hint: 'Re-realize the FUNC (graph_realize) against the current source tree, or fix the moved/renamed file path',
107
+ fix_hint: `Re-bind the FUNC to the current source tree via graph_mutate Format-E \`~ ${el.id} @realRef {"file":…,"symbol":…}\` — or restore the moved/renamed file`,
108
108
  context: { element_type: el.type, element_name: el.name },
109
109
  });
110
110
  continue;
@@ -148,7 +148,7 @@ function testRefMustResolve(graph, facts) {
148
148
  severity: 'warning',
149
149
  element_id: el.id,
150
150
  message: `${el.id} testRefs entry file '${ref.file}' does not exist on disk`,
151
- fix_hint: 'The test file was moved or deleted — rebind the TEST (graph_realize) to the current file, or drop the entry',
151
+ fix_hint: `The test file was moved or deleted — rebind the TEST via graph_mutate Format-E \`~ ${el.id} @testRefs [{"file":…,"tool":…}]\` (the patch replaces the whole list — carry the other entries), or drop the entry`,
152
152
  context: { element_type: el.type, element_name: el.name },
153
153
  });
154
154
  continue;
@@ -192,7 +192,7 @@ function schemaRefMustResolve(graph, facts) {
192
192
  severity: 'warning',
193
193
  element_id: el.id,
194
194
  message: `${el.id} realRef.file '${ref.file}' does not exist on disk`,
195
- fix_hint: 'Re-bind the SCHEMA (graph_realize) to the current source tree, or fix the moved/renamed file path',
195
+ fix_hint: `Re-bind the SCHEMA to the current source tree via graph_mutate Format-E \`~ ${el.id} @realRef {"file":…,"symbol":…}\` — or restore the moved/renamed file`,
196
196
  context: { element_type: el.type, element_name: el.name },
197
197
  });
198
198
  continue;
@@ -653,6 +653,42 @@ function schemaParsedOnlyAtInterface(graph, facts) {
653
653
  }
654
654
  return violations;
655
655
  }
656
+ // ---------------------------------------------------------------------------
657
+ // RC-10 (CR-SM-344): a MOD must be resolvable — carry a `path`, or have at least one
658
+ // allocated FUNC with a bound realRef. Those are the only two ways `buildModResolver`
659
+ // maps a file to a MOD; a MOD with neither can never own a file, so RC-05 and the
660
+ // boundary measurement are blind to it BY CONSTRUCTION. Measured at sigllm: 0 RC-05
661
+ // findings at 19 % import coverage, 8 at 81 % — the zero was the absence of a question.
662
+ // The cost in the message comes from `importCoverage` (the same resolution), not from a
663
+ // second count. `concept`/`external` are exempt — the same exemptions as R-20.
664
+ // ABSENT `importEdges` = silence, like RC-05: without an import graph RC-05 has nothing to
665
+ // judge, so there is no blindness to report — and the cost could not be named.
666
+ // ---------------------------------------------------------------------------
667
+ function modMustBeResolvable(graph, facts) {
668
+ if (facts.importEdges === undefined)
669
+ return []; // extractor supplied no import graph — silence
670
+ const typeOf = new Map(graph.elements.map(e => [e.id, e.type]));
671
+ const boundFuncs = new Set(graph.elements.filter(e => e.type === 'FUNC' && readRealRef(e.attributes).state === 'bound').map(e => e.id));
672
+ const resolvedByFunc = new Set(graph.traces
673
+ .filter(t => t.type === 'allocate' && boundFuncs.has(t.source) && typeOf.get(t.target) === 'MOD')
674
+ .map(t => t.target));
675
+ const blind = graph.elements.filter(e => e.type === 'MOD' &&
676
+ e.attributes?.concept !== true && e.attributes?.external !== true &&
677
+ !(typeof e.attributes?.path === 'string' && e.attributes.path.length > 0) &&
678
+ !resolvedByFunc.has(e.id));
679
+ if (blind.length === 0)
680
+ return [];
681
+ const coverage = importCoverage(graph, facts);
682
+ const cost = `${coverage.unassigned.length} of ${coverage.endpoints} import endpoints stay unassigned in this repo`;
683
+ return blind.map(el => ({
684
+ rule_id: 'RC-10',
685
+ severity: 'warning',
686
+ element_id: el.id,
687
+ message: `${el.id} has neither a path nor an allocated FUNC with a realRef — no file can resolve to it, RC-05 is blind here (${cost})`,
688
+ fix_hint: `Set the module's source directory via graph_mutate Format-E \`~ ${el.id} @path <dir>\` — or bind one of its allocated FUNCs (\`~ FUNC-x @realRef {"file":…,"symbol":…}\`); mark it concept:true if no code realizes it yet`,
689
+ context: { element_type: el.type, element_name: el.name },
690
+ }));
691
+ }
656
692
  export const CODE_CONFORMANCE_RULES = [
657
693
  { id: 'RC-01', name: 'FUNC realRef resolves to a declared symbol', severity: 'warning', domain: ['FUNC'], evaluate: codeRefMustResolve },
658
694
  { id: 'RC-02', name: 'testRefs entries resolve to runnable tests', severity: 'warning', domain: ['TEST'], evaluate: testRefMustResolve },
@@ -663,6 +699,7 @@ export const CODE_CONFORMANCE_RULES = [
663
699
  { id: 'RC-07', name: 'CR node agrees with docs/cr', severity: 'warning', domain: ['CR', 'SYS'], evaluate: crNodeMatchesFile },
664
700
  { id: 'RC-08', name: 'SCHEMA realRef is a Zod schema', severity: 'warning', domain: ['SCHEMA'], evaluate: schemaRefMustBeZod },
665
701
  { id: 'RC-09', name: 'SCHEMA is parsed only at its modelled interface', severity: 'warning', domain: ['SCHEMA'], evaluate: schemaParsedOnlyAtInterface },
702
+ { id: 'RC-10', name: 'MOD has resolvable files', severity: 'warning', domain: ['MOD'], evaluate: modMustBeResolvable },
666
703
  ];
667
704
  /** Run all RC rules against a graph + extracted code facts. */
668
705
  export function evaluateConformanceRules(graph, facts) {
@@ -1,3 +1,4 @@
1
+ import { readRealRef } from './ontology.js';
1
2
  // ---------------------------------------------------------------------------
2
3
  // CR-R01: ein offener CR nennt, WAS er aendert (CR-SM-295)
3
4
  //
@@ -178,6 +179,76 @@ function crShouldHaveMilestone(graph) {
178
179
  }));
179
180
  }
180
181
  // ---------------------------------------------------------------------------
182
+ // CR-R05: kein Blatt-REQ ohne Bauauftrag (CR-SM-343) — die REQ-Seite von CR-R01.
183
+ //
184
+ // RD-01 fragt nach einem Traeger, R-01 nach einer Verifikation; ob ein REQ einen BAUAUFTRAG
185
+ // hat, fragte niemand. Gemessen am Fremdlauf sigllm: `se-plan` meldete „20 von 20 geordnet",
186
+ // im selben Graphstand hatten 24 von 64 Blatt-REQ keinen Auftrag — genau die, die alle 16
187
+ // offenen FM-03-Risiken mildern.
188
+ //
189
+ // Beauftragt ist ein Blatt-REQ (kein `compose`→REQ-Kind), wenn ein CR eine `relation` (a)
190
+ // direkt darauf traegt oder (b) auf eine FUNC, die es `satisfy`t. **MOD und SYS zaehlen
191
+ // nicht:** ein Modul ist ein Behaelter — mitgerechnet las sigllm 55/64 statt 40/64 und sah
192
+ // gesund aus. Wer ein REQ an einem MOD-/SYS-Traeger beauftragt, zieht die `relation` direkt
193
+ // auf das REQ. FCHAIN ist kein Weg: `CR -relation-> FCHAIN` ist kein TRACE_PATTERN, eine
194
+ // solche Kante waere R-18 — ein FCHAIN-getragenes REQ braucht also ebenfalls die direkte Kante.
195
+ //
196
+ // Jeder CR zaehlt, auch ein abgeschlossener: gebaut ist beauftragt.
197
+ //
198
+ // **Eine gebaute FUNC deckt ohne CR** (CR-SM-375): traegt die FUNC eine gueltige `realRef`
199
+ // oder ist sie `external`, ist der Bauauftrag erledigt — Bestand vor der CR-Disziplin braucht
200
+ // keinen nachgetragenen Auftrag. `concept: true` ist nicht gebaut, auch mit realRef. Fuer
201
+ // MOD/SYS/FCHAIN aendert das nichts: dort deckt nur die direkte `CR -relation-> REQ`.
202
+ //
203
+ // **Ohne CR-Knoten schweigt die Regel** und zaehlt weder im Zaehler noch im Nenner
204
+ // (`RULE_PRECONDITION`, readiness.ts). Ein Repo ohne Plan im Graphen (`cr: docs`) bekaeme
205
+ // sonst auf jedem REQ einen Befund — die Regel wuerde abgeschaltet und waere danach ueberall
206
+ // still (Fail-open-Klasse ND-01/ND-02 vor CR-SM-286).
207
+ // ---------------------------------------------------------------------------
208
+ function funcIsBuilt(e) {
209
+ if (e.attributes?.concept === true)
210
+ return false;
211
+ return e.attributes?.external === true || readRealRef(e.attributes).state === 'bound';
212
+ }
213
+ function leafReqNeedsBuildOrder(graph) {
214
+ const crIds = new Set(graph.elements.filter(e => e.type === 'CR').map(e => e.id));
215
+ if (crIds.size === 0)
216
+ return [];
217
+ const typeOf = new Map(graph.elements.map(e => [e.id, e.type]));
218
+ const ordered = new Set(); // CR -relation-> X, oder eine bereits gebaute FUNC
219
+ for (const t of graph.traces) {
220
+ if (t.type === 'relation' && crIds.has(t.source))
221
+ ordered.add(t.target);
222
+ }
223
+ for (const e of graph.elements) {
224
+ if (e.type === 'FUNC' && funcIsBuilt(e))
225
+ ordered.add(e.id);
226
+ }
227
+ const covered = new Set();
228
+ const parents = new Set();
229
+ for (const t of graph.traces) {
230
+ if (t.type === 'relation' && crIds.has(t.source) && typeOf.get(t.target) === 'REQ')
231
+ covered.add(t.target);
232
+ if (t.type === 'satisfy' && typeOf.get(t.source) === 'FUNC' && ordered.has(t.source))
233
+ covered.add(t.target);
234
+ if (t.type === 'compose' && typeOf.get(t.source) === 'REQ' && typeOf.get(t.target) === 'REQ')
235
+ parents.add(t.source);
236
+ }
237
+ return graph.elements
238
+ .filter(e => e.type === 'REQ' && !parents.has(e.id) && !covered.has(e.id))
239
+ .map(req => ({
240
+ rule_id: 'CR-R05',
241
+ severity: 'warning',
242
+ element_id: req.id,
243
+ message: `${req.id} is a leaf REQ with no build order (no CR relation to it or to a FUNC that satisfies it, and no built FUNC satisfies it)`,
244
+ fix_hint: `Add a CR relation through graph_mutate Format-E \`CR-x -relation-> ${req.id}\` — or to the FUNC that satisfies it; a CR on its MOD/SYS carrier does not count`,
245
+ context: {
246
+ element_type: req.type,
247
+ element_name: req.name,
248
+ },
249
+ }));
250
+ }
251
+ // ---------------------------------------------------------------------------
181
252
  // Exports
182
253
  // ---------------------------------------------------------------------------
183
254
  export const CR_RULES = [
@@ -190,6 +261,7 @@ export const CR_RULES = [
190
261
  // Damit ist sie nach R-08/R-18 die dritte 'all'-Regel — bewusst, nicht vergessen.
191
262
  { id: 'CR-R03', name: 'No concurrent mutation', severity: 'warning', evaluate: noConcurrentMutation, domain: ['all'] },
192
263
  { id: 'MS-03', name: 'CR without milestone', severity: 'info', evaluate: crShouldHaveMilestone, domain: ['CR'] },
264
+ { id: 'CR-R05', name: 'Leaf REQ has a build order', severity: 'warning', evaluate: leafReqNeedsBuildOrder, domain: ['REQ'] },
193
265
  ];
194
266
  // CR-SM-236: `policy` wird durchgereicht, auch wo diese Familie heute keine Schwelle hat —
195
267
  // ein Sonderweg je Familie waere genau der zweite Pfad, den der Regelsatz verbietet.
@@ -30,5 +30,12 @@ export declare function getRuleDefsForProfile(profile: ProfileId): typeof ALL_RU
30
30
  * CR-SM-233: `policy` ist **Pflicht und ohne Fallback** — ein Aufruf ohne Policy ist ein
31
31
  * Typfehler, keine stille 0.7. Wer keine eigene Quelle hat (Konfiguration, Host), nimmt
32
32
  * `DEFAULT_METRIC_POLICY` sichtbar an der Aufrufstelle.
33
+ *
34
+ * CR-SM-378: `only` ist ein REGELFILTER, kein zweiter Auswerter — dieselbe Normalisierung,
35
+ * dieselbe Reihenfolge, dieselben `evaluate`-Aufrufe; eine Regel ausserhalb des Filters laeuft nur
36
+ * nicht. Das Ergebnis ist gleich dem Vollergebnis, nachtraeglich auf `only` gefiltert. Grund:
37
+ * der Steuer-Operator (se-engine `bestBySteer`) bewertete jeden Kandidaten mit dem ganzen
38
+ * Katalog, inklusive des quadratischen ND-01 — 8,9 von 17,1 s bei 2000 Knoten fuer Befunde, die
39
+ * er nie las. Ohne `only` laeuft der ganze Katalog.
33
40
  */
34
- export declare function evaluateAllRules(graph: OntologyGraph, policy: MetricPolicy): RuleViolation[];
41
+ export declare function evaluateAllRules(graph: OntologyGraph, policy: MetricPolicy, only?: ReadonlySet<string>): RuleViolation[];
@@ -1,16 +1,16 @@
1
- import { V3_RULES, evaluateRules } from './rules.js';
2
- import { SC_RULES, evaluateSCRules } from './schema-quality-rules.js';
3
- import { UC_RULES, evaluateUCRules } from './uc-quality-rules.js';
4
- import { FC_RULES, evaluateFCRules } from './fchain-quality-rules.js';
5
- import { MT_RULES, evaluateMTRules } from './metric-rules.js';
1
+ import { V3_RULES } from './rules.js';
2
+ import { SC_RULES } from './schema-quality-rules.js';
3
+ import { UC_RULES } from './uc-quality-rules.js';
4
+ import { FC_RULES } from './fchain-quality-rules.js';
5
+ import { MT_RULES } from './metric-rules.js';
6
6
  import { toEvaluableGraph } from './flat-graph.js';
7
- import { FM_RULES, evaluateFMRules } from './fmea-rules.js';
8
- import { VIEW_RULES, evaluateViewRules } from './view-rules.js';
9
- import { CR_RULES, evaluateCRRules } from './cr-quality-rules.js';
10
- import { ND_RULES, evaluateNDRules } from './near-duplicate-rules.js';
11
- import { AO_RULES, evaluateAORules } from './ao-rules.js';
12
- import { BQ_RULES, evaluateBQRules } from './quality-rules.js';
13
- import { AF_RULES, evaluateAFRules, TASK_OUTCOME_RULES, evaluateTaskOutcomeRules } from './analysis-freshness-rules.js';
7
+ import { FM_RULES } from './fmea-rules.js';
8
+ import { VIEW_RULES } from './view-rules.js';
9
+ import { CR_RULES } from './cr-quality-rules.js';
10
+ import { ND_RULES } from './near-duplicate-rules.js';
11
+ import { AO_RULES } from './ao-rules.js';
12
+ import { BQ_RULES } from './quality-rules.js';
13
+ import { AF_RULES, TASK_OUTCOME_RULES } from './analysis-freshness-rules.js';
14
14
  import { CODE_CONFORMANCE_RULES } from './conformance-rules.js';
15
15
  /**
16
16
  * CR-SM-285: das Profil haengt am KATALOG, nicht am ID-Praefix.
@@ -67,14 +67,31 @@ export function getRuleDefsForProfile(profile) {
67
67
  return ALL_RULE_DEFS;
68
68
  return ALL_RULE_DEFS.filter(r => r.profile === profile);
69
69
  }
70
+ /**
71
+ * Die ausgewerteten Kataloge in der Reihenfolge der kanonischen Befund-Sequenz (CR-SM-240).
72
+ * Ohne `CODE_CONFORMANCE_RULES` — die brauchen `CodeFacts` (s. CATALOGS). Die Reihenfolge ist
73
+ * Vertrag: das Golden (`se-rule-output-identity.test.ts`) pinnt sie.
74
+ */
75
+ const EVALUATED_CATALOGS = [
76
+ V3_RULES, BQ_RULES, UC_RULES, FC_RULES, SC_RULES, ND_RULES, MT_RULES, CR_RULES, AO_RULES,
77
+ FM_RULES, VIEW_RULES, AF_RULES,
78
+ TASK_OUTCOME_RULES, // CR-SM-355
79
+ ];
70
80
  /**
71
81
  * Evaluate all rules against a graph. Single call replaces the individual evaluator calls.
72
82
  *
73
83
  * CR-SM-233: `policy` ist **Pflicht und ohne Fallback** — ein Aufruf ohne Policy ist ein
74
84
  * Typfehler, keine stille 0.7. Wer keine eigene Quelle hat (Konfiguration, Host), nimmt
75
85
  * `DEFAULT_METRIC_POLICY` sichtbar an der Aufrufstelle.
86
+ *
87
+ * CR-SM-378: `only` ist ein REGELFILTER, kein zweiter Auswerter — dieselbe Normalisierung,
88
+ * dieselbe Reihenfolge, dieselben `evaluate`-Aufrufe; eine Regel ausserhalb des Filters laeuft nur
89
+ * nicht. Das Ergebnis ist gleich dem Vollergebnis, nachtraeglich auf `only` gefiltert. Grund:
90
+ * der Steuer-Operator (se-engine `bestBySteer`) bewertete jeden Kandidaten mit dem ganzen
91
+ * Katalog, inklusive des quadratischen ND-01 — 8,9 von 17,1 s bei 2000 Knoten fuer Befunde, die
92
+ * er nie las. Ohne `only` laeuft der ganze Katalog.
76
93
  */
77
- export function evaluateAllRules(graph, policy) {
94
+ export function evaluateAllRules(graph, policy, only) {
78
95
  // CR-SM-284: die Hebung der flach committeten SSOT laeuft HIER, nicht beim Aufrufer.
79
96
  //
80
97
  // `toEvaluableGraph` gibt es seit CR-SM-258 — und sie wurde von keinem Produktionspfad und
@@ -84,20 +101,6 @@ export function evaluateAllRules(graph, policy) {
84
101
  //
85
102
  // Idempotent und identitaetserhaltend (s. dort), also eine Normalisierung am einzigen Eingang
86
103
  // und kein Fallback: ein bereits genesteter Graph geht unveraendert und uneingepackt durch.
87
- graph = toEvaluableGraph(graph);
88
- return [
89
- ...evaluateRules(graph, policy),
90
- ...evaluateBQRules(graph, policy),
91
- ...evaluateUCRules(graph, policy),
92
- ...evaluateFCRules(graph, policy),
93
- ...evaluateSCRules(graph, policy),
94
- ...evaluateNDRules(graph),
95
- ...evaluateMTRules(graph, policy),
96
- ...evaluateCRRules(graph, policy),
97
- ...evaluateAORules(graph, policy),
98
- ...evaluateFMRules(graph, policy),
99
- ...evaluateViewRules(graph, policy),
100
- ...evaluateAFRules(graph),
101
- ...evaluateTaskOutcomeRules(graph), // CR-SM-355
102
- ];
104
+ const evaluable = toEvaluableGraph(graph);
105
+ return EVALUATED_CATALOGS.flatMap((rules) => rules.flatMap((rule) => (only === undefined || only.has(rule.id) ? rule.evaluate(evaluable, policy) : [])));
103
106
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * FC-01..FC-03 — FCHAIN quality rules (CR-121 Phase 1).
2
+ * FC-02..FC-05 — FCHAIN quality rules (CR-121 Phase 1; FC-05 CR-SM-363).
3
3
  */
4
4
  import type { OntologyGraph } from './ontology.js';
5
5
  import type { RuleDefinition, RuleViolation } from './rules.js';
@@ -7,5 +7,6 @@ import type { MetricPolicy } from './policy.js';
7
7
  export declare function fc02LeafUcHasFchain(graph: OntologyGraph): RuleViolation[];
8
8
  export declare function fc03FchainFlat(graph: OntologyGraph): RuleViolation[];
9
9
  export declare function fc04ActorBounded(graph: OntologyGraph): RuleViolation[];
10
+ export declare function fc05ChainConnected(graph: OntologyGraph): RuleViolation[];
10
11
  export declare const FC_RULES: RuleDefinition[];
11
12
  export declare function evaluateFCRules(graph: OntologyGraph, policy: MetricPolicy): RuleViolation[];
@@ -96,6 +96,137 @@ export function fc04ActorBounded(graph) {
96
96
  }));
97
97
  }
98
98
  // ---------------------------------------------------------------------------
99
+ // FC-05: FCHAIN ist gerichtet zusammenhaengend (CR-SM-363).
100
+ //
101
+ // Eine FCHAIN ist grammatisch eine Menge (`FCHAIN -compose-> FUNC`); die Reihenfolge entsteht
102
+ // erst aus Erzeuger -> Verbraucher (`FUNC -io-> FLOW -io-> FUNC`, beide Enden Glied). Bilden die
103
+ // Glieder darueber mehr als EINE (schwache) Komponente, beschreibt jede Kettenkennzahl etwas,
104
+ // das es nicht gibt. IO-01 sieht das nicht: es verbindet zwei Glieder schon, wenn beide
105
+ // denselben FLOW konsumieren. Gemessen (graphcode spike-kettenkennzahlen, 76 Ketten): 20
106
+ // zerfallen ohne jede Regel.
107
+ //
108
+ // Je Nebenkomponente (alles ausser der groessten; Gleichstand nach kleinster ID — Gate 5) die
109
+ // Ursache:
110
+ // - `missing-link`: Haupt- und Nebenteil sind ueber eine FUNC AUSSERHALB der Kette verbunden
111
+ // (Glied -> FLOW -> X -> FLOW -> Glied) — der verbindende Schritt gehoert in die Kette.
112
+ // Nur eine FUNC ist ein fehlendes Glied: ein Weg ueber einen ACTOR geht ueber die
113
+ // Systemgrenze, und einen ACTOR kann keine Kette aufnehmen.
114
+ // - `bag`: sonst — keine FUNC verbindet die Teile: ein fehlender FLOW zwischen Gliedern oder
115
+ // parallele Dienste an einer gemeinsamen Quelle (dann ist die Kette zu teilen).
116
+ // Traegt die Kette ein Glied ohne io-Eingang oder -Ausgang, gehoert sie zuerst R-31 — FC-05
117
+ // schweigt, bis alle Glieder verdrahtet sind (keine Doppelmeldung, s. u.). Eingang/Ausgang der Kette fragt FC-04 (streng: ACTOR) — eine Kette ohne
118
+ // jeden Eingang hat auch keinen ACTOR-Eingang, FC-05 schweigt deshalb dazu (eine Ursache, ein
119
+ // Befund). EIN Befund je Kette, die Komponenten im Kontext (CR-SM-242).
120
+ // ---------------------------------------------------------------------------
121
+ export function fc05ChainConnected(graph) {
122
+ const idx = indexOf(graph);
123
+ // Erzeuger/Verbraucher je FLOW — ueber alle Typen (FUNC oder ACTOR).
124
+ const producers = new Map();
125
+ const consumers = new Map();
126
+ const wiredIn = new Set();
127
+ const wiredOut = new Set();
128
+ for (const t of idx.tracesOfType('io')) {
129
+ if (idx.typeOf(t.target) === 'FLOW') {
130
+ (producers.get(t.target) ?? producers.set(t.target, []).get(t.target)).push(t.source);
131
+ wiredOut.add(t.source);
132
+ }
133
+ if (idx.typeOf(t.source) === 'FLOW') {
134
+ (consumers.get(t.source) ?? consumers.set(t.source, []).get(t.source)).push(t.target);
135
+ wiredIn.add(t.target);
136
+ }
137
+ }
138
+ const successors = new Map();
139
+ for (const [flow, ps] of producers) {
140
+ for (const p of ps) {
141
+ const set = successors.get(p) ?? successors.set(p, new Set()).get(p);
142
+ for (const c of consumers.get(flow) ?? [])
143
+ if (c !== p)
144
+ set.add(c);
145
+ }
146
+ }
147
+ const succ = (id) => successors.get(id) ?? new Set();
148
+ const violations = [];
149
+ for (const fc of idx.elementsOfType('FCHAIN')) {
150
+ const members = [...new Set(idx.out(fc.id, 'compose').map(t => t.target).filter(id => idx.typeOf(id) === 'FUNC'))].sort();
151
+ if (members.length < 2)
152
+ continue;
153
+ // Ein Glied ohne io-Eingang oder -Ausgang ist R-31s Befund — und er kann genau die Bruecke
154
+ // sein, die fehlt. Solange eines da ist, urteilt FC-05 nicht (eine Ursache, ein Befund):
155
+ // gemessen an graphcode FCHAIN-capture, wo `FUNC-decode` ohne Ausgang sonst einen Sack vortaeuscht.
156
+ if (members.some(f => !wiredIn.has(f) || !wiredOut.has(f)))
157
+ continue;
158
+ const inChain = new Set(members);
159
+ // Schwache Komponenten ueber Glied -> Glied.
160
+ const adj = new Map(members.map(m => [m, new Set()]));
161
+ for (const a of members) {
162
+ for (const b of succ(a))
163
+ if (inChain.has(b)) {
164
+ adj.get(a).add(b);
165
+ adj.get(b).add(a);
166
+ }
167
+ }
168
+ const seen = new Set();
169
+ const comps = [];
170
+ for (const m of members) {
171
+ if (seen.has(m))
172
+ continue;
173
+ const comp = [m];
174
+ seen.add(m);
175
+ for (let i = 0; i < comp.length; i++) {
176
+ for (const n of adj.get(comp[i]))
177
+ if (!seen.has(n)) {
178
+ seen.add(n);
179
+ comp.push(n);
180
+ }
181
+ }
182
+ comps.push(comp.sort());
183
+ }
184
+ if (comps.length < 2)
185
+ continue;
186
+ comps.sort((a, b) => b.length - a.length || a[0].localeCompare(b[0]));
187
+ const main = new Set(comps[0]);
188
+ // Aussen-FUNCs X mit from -> X -> to.
189
+ const bridges = (from, to) => {
190
+ const via = new Set();
191
+ for (const a of from) {
192
+ for (const x of succ(a)) {
193
+ if (inChain.has(x) || idx.typeOf(x) !== 'FUNC')
194
+ continue;
195
+ if ([...succ(x)].some(b => to.has(b)))
196
+ via.add(x);
197
+ }
198
+ }
199
+ return [...via];
200
+ };
201
+ const side = [];
202
+ for (const comp of comps.slice(1)) {
203
+ const set = new Set(comp);
204
+ const via = [...new Set([...bridges(main, set), ...bridges(set, main)])].sort();
205
+ side.push({ members: comp, cause: via.length > 0 ? 'missing-link' : 'bag', via });
206
+ }
207
+ if (side.length === 0)
208
+ continue;
209
+ const missing = [...new Set(side.flatMap(c => c.via))].sort();
210
+ const hints = [];
211
+ if (missing.length > 0)
212
+ hints.push(`add the missing link(s) as members: ${missing.map(f => `\`${fc.id} -compose-> ${f}\``).join(', ')}`);
213
+ // `bag` sagt nur: KEINE Aussen-FUNC verbindet die Teile. Das ist entweder ein fehlender FLOW
214
+ // zwischen zwei Gliedern (gemessen: graphcode FCHAIN-generation-states, Uebergabe an
215
+ // graph_suggest nicht modelliert) oder ein Sack paralleler Dienste — der Hinweis nennt beides.
216
+ if (side.some(c => c.cause === 'bag'))
217
+ hints.push('connect the parts with a FLOW from a producer member to a consumer member if they are one chain of effects — otherwise split the chain, one FCHAIN per chain of effects');
218
+ violations.push({
219
+ rule_id: 'FC-05',
220
+ severity: 'warning',
221
+ element_id: fc.id,
222
+ message: `${fc.id} falls apart into ${comps.length} unconnected parts along producer -> consumer (${side.map(c => `${c.members.join('+')}: ${c.cause}`).join('; ')})`,
223
+ fix_hint: `Via graph_mutate Format-E, ${hints.join('; or ')}`,
224
+ context: { element_type: fc.type, element_name: fc.name, side_components: side },
225
+ });
226
+ }
227
+ return violations;
228
+ }
229
+ // ---------------------------------------------------------------------------
99
230
  // Aggregated
100
231
  // ---------------------------------------------------------------------------
101
232
  export const FC_RULES = [
@@ -105,6 +236,7 @@ export const FC_RULES = [
105
236
  // eine Kette mit n verschachtelten FUNCs erzeugt n Verstoesse gegen einen Beitrag von 1.
106
237
  { id: 'FC-03', name: 'FCHAIN is flat', severity: 'warning', evaluate: fc03FchainFlat, domain: ['FUNC'] },
107
238
  { id: 'FC-04', name: 'FCHAIN actor-bounded (trigger+consumer)', severity: 'warning', evaluate: fc04ActorBounded, domain: ['FCHAIN'] },
239
+ { id: 'FC-05', name: 'FCHAIN is connected (producer -> consumer)', severity: 'warning', evaluate: fc05ChainConnected, domain: ['FCHAIN'] },
108
240
  ];
109
241
  // CR-SM-236: `policy` wird durchgereicht, auch wo diese Familie heute keine Schwelle hat —
110
242
  // ein Sonderweg je Familie waere genau der zweite Pfad, den der Regelsatz verbietet.
@@ -1,13 +1,16 @@
1
1
  /**
2
2
  * CR-182: FMEA/FTA Risk Rules + NFR Budget Overshoot Detection.
3
3
  * FM-01: Risk-REQ missing FMEA attributes (severity, occurrence, detection).
4
- * FM-02: Risk-REQ without mitigation (compose→REQ(kinds∋mitigation)).
4
+ * FM-02: Risk-REQ without mitigation (compose→REQ(role=mitigation)).
5
+ * CR-SM-365: "Risk-REQ" heisst `attributes.role === 'risk'` (Leser `readReqRole`), nicht mehr
6
+ * `kinds ∋ risk` — die Rolle ist die Motivation der Anforderung, nicht ihre Art. Kein
7
+ * zweiter Pfad ueber `kinds`: ein Altgraph faellt hier still, bis er migriert ist.
5
8
  * FM-03: risk REQ with Action Priority High and no passed verification.
6
9
  * NFR-01: measured exceeds budget for an NFR dimension. CR-228 splits the target
7
10
  * by dimension nature: physical budgets (weight/power/cost) live on the part
8
11
  * (MOD); behavioral budgets (timing/memory-throughput) on the FCHAIN/FUNC.
9
12
  */
10
- import { readTestRefs } from './ontology.js';
13
+ import { readReqRole, readTestRefs } from './ontology.js';
11
14
  import { actionPriority, apMethod } from './action-priority.js';
12
15
  const PHYSICAL_BUDGET_PAIRS = [
13
16
  ['costBudget', 'measuredCost', 'cost'],
@@ -20,12 +23,19 @@ const BEHAVIORAL_BUDGET_PAIRS = [
20
23
  ];
21
24
  const PHYSICAL_BUDGET_TYPES = ['MOD'];
22
25
  const BEHAVIORAL_BUDGET_TYPES = ['FUNC', 'FCHAIN'];
26
+ /** Traegt das Element die FMEA-Rolle `role`? Einziger Weg der FM-Regeln (CR-SM-365). */
27
+ const hasRole = (e, role) => {
28
+ if (e.type !== 'REQ')
29
+ return false;
30
+ const r = readReqRole(e.attributes);
31
+ return r.state === 'bound' && r.value === role;
32
+ };
23
33
  // ---------------------------------------------------------------------------
24
34
  // FM-01: Risk-REQ without FMEA attributes
25
35
  // ---------------------------------------------------------------------------
26
36
  export function fm01MissingFmeaAttributes(graph) {
27
37
  return graph.elements
28
- .filter(e => e.type === 'REQ' && e.kinds?.includes('risk'))
38
+ .filter(e => hasRole(e, 'risk'))
29
39
  .filter(e => {
30
40
  const a = e.attributes ?? {};
31
41
  return a['severity'] == null || a['occurrence'] == null || a['detection'] == null;
@@ -52,20 +62,20 @@ export function fm01MissingFmeaAttributes(graph) {
52
62
  // FM-02: Risk-REQ without mitigation
53
63
  // ---------------------------------------------------------------------------
54
64
  export function fm02MissingMitigation(graph) {
55
- const riskReqs = graph.elements.filter(e => e.type === 'REQ' && e.kinds?.includes('risk'));
65
+ const riskReqs = graph.elements.filter(e => hasRole(e, 'risk'));
56
66
  return riskReqs
57
67
  .filter(riskReq => {
58
- // Check: compose trace from riskReq to a REQ with kinds∋mitigation
68
+ // Check: compose trace from riskReq to a REQ with role=mitigation
59
69
  return !graph.traces.some(t => t.source === riskReq.id &&
60
70
  t.type === 'compose' &&
61
- graph.elements.some(el => el.id === t.target && el.type === 'REQ' && el.kinds?.includes('mitigation')));
71
+ graph.elements.some(el => el.id === t.target && hasRole(el, 'mitigation')));
62
72
  })
63
73
  .map(e => ({
64
74
  rule_id: 'FM-02',
65
75
  severity: 'warning',
66
76
  element_id: e.id,
67
- message: `${e.id} is a risk REQ without a mitigation REQ (compose→REQ[mitigation])`,
68
- fix_hint: 'Create a mitigation REQ and link via compose trace',
77
+ message: `${e.id} is a risk REQ without a mitigation REQ (compose→REQ[role=mitigation])`,
78
+ fix_hint: 'Create a REQ with attributes.role="mitigation" (kinds functional or non-functional) and link it via compose trace',
69
79
  }));
70
80
  }
71
81
  // ---------------------------------------------------------------------------
@@ -80,7 +90,7 @@ export function fm03HighRiskUnverified(graph, policy) {
80
90
  const threshold = policy.riskRpn;
81
91
  if (threshold === null)
82
92
  return [];
83
- const riskReqs = graph.elements.filter(e => e.type === 'REQ' && e.kinds?.includes('risk'));
93
+ const riskReqs = graph.elements.filter(e => hasRole(e, 'risk'));
84
94
  return riskReqs
85
95
  .filter(riskReq => {
86
96
  const a = riskReq.attributes ?? {};