@sigloch/contracts 6.1.0 → 9.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,5 @@
1
1
  import { getND02SimilarityMatrix } from './near-duplicate-rules.js';
2
+ import { indexOf } from './graph-index.js';
2
3
  const SCHEMA_OVERLAP_THRESHOLD = 0.5;
3
4
  /**
4
5
  * AO-D01: Relay Node Detection.
@@ -6,13 +7,14 @@ const SCHEMA_OVERLAP_THRESHOLD = 0.5;
6
7
  * and (c) target FUNCs share SCHEMA overlap >= 0.5 (via ND-02 matrix, skipped if unavailable).
7
8
  */
8
9
  export function aoD01RelayNode(graph) {
10
+ const idx = indexOf(graph);
9
11
  const violations = [];
10
- const funcs = graph.elements.filter(e => e.type === 'FUNC');
12
+ const funcs = idx.elementsOfType('FUNC');
11
13
  const funcIdSet = new Set(funcs.map(f => f.id));
12
14
  for (const func of funcs) {
13
15
  // (a) No satisfy traces (FUNC as source) connecting to any REQ
14
16
  const hasSatisfy = graph.traces.some(t => t.type === 'satisfy' &&
15
- t.source === func.id && graph.elements.some(e => e.id === t.target && e.type === 'REQ'));
17
+ t.source === func.id && idx.typeOf(t.target) === 'REQ');
16
18
  if (hasSatisfy)
17
19
  continue;
18
20
  // (b) >=2 outgoing io traces to other FUNCs
@@ -24,7 +26,7 @@ export function aoD01RelayNode(graph) {
24
26
  // (c) Target FUNCs share SCHEMA overlap >= 0.5 (skip check if no matrix)
25
27
  if (!passesSchemaOverlap(graph, ioTargets))
26
28
  continue;
27
- const el = graph.elements.find(e => e.id === func.id);
29
+ const el = idx.byId.get(func.id);
28
30
  violations.push({
29
31
  rule_id: 'AO-D01',
30
32
  severity: 'info',
@@ -37,29 +39,35 @@ export function aoD01RelayNode(graph) {
37
39
  }
38
40
  /** CR-165: Resolve FUNC→SCHEMA via FUNC→FLOW(io) + FLOW→SCHEMA(relation). */
39
41
  function funcToSchemas(graph, funcId) {
42
+ const idx = indexOf(graph);
40
43
  const schemas = new Set();
41
44
  // Find FLOWs connected to this FUNC via io (FUNC→FLOW or FLOW→FUNC)
45
+ //
46
+ // CR-SM-264: die io-Kanten dieser FUNC statt zweier Vollscans ueber ALLE Traces je FUNC
47
+ // (`F x T`) — AO-D03 ruft diese Funktion einmal je FUNC. Die Reihenfolge von `flowIds` ist
48
+ // dabei egal, die Menge wird nur auf Mitgliedschaft geprueft.
42
49
  const flowIds = new Set();
43
- for (const t of graph.traces) {
44
- if (t.type === 'io') {
45
- if (t.source === funcId && graph.elements.some(e => e.id === t.target && e.type === 'FLOW')) {
46
- flowIds.add(t.target);
47
- }
48
- if (t.target === funcId && graph.elements.some(e => e.id === t.source && e.type === 'FLOW')) {
49
- flowIds.add(t.source);
50
- }
51
- }
52
- }
53
- // Find SCHEMAs connected to those FLOWs via relation
54
- for (const t of graph.traces) {
55
- if (t.type === 'relation' && flowIds.has(t.source) && graph.elements.some(e => e.id === t.target && e.type === 'SCHEMA')) {
50
+ for (const t of idx.out(funcId, 'io'))
51
+ if (idx.typeOf(t.target) === 'FLOW')
52
+ flowIds.add(t.target);
53
+ for (const t of idx.in(funcId, 'io'))
54
+ if (idx.typeOf(t.source) === 'FLOW')
55
+ flowIds.add(t.source);
56
+ // Find SCHEMAs connected to those FLOWs via relation.
57
+ //
58
+ // Hier NICHT ueber `flowIds` iterieren, obwohl das billiger waere: die Einfuegereihenfolge
59
+ // dieser Menge landet ueber `shared.join(', ')` woertlich in der AO-D03-Meldung. Der Lauf
60
+ // ueber die relation-Kanten in Graph-Reihenfolge haelt sie identisch — und ist trotzdem
61
+ // deutlich kuerzer als der Vollscan, weil er nur einen Kantentyp sieht.
62
+ for (const t of idx.tracesOfType('relation')) {
63
+ if (flowIds.has(t.source) && idx.typeOf(t.target) === 'SCHEMA')
56
64
  schemas.add(t.target);
57
- }
58
65
  }
59
66
  return schemas;
60
67
  }
61
68
  /** Check if any pair of target FUNCs shares SCHEMA overlap via FLOW→SCHEMA(relation) + ND-02 matrix. */
62
69
  function passesSchemaOverlap(graph, targetFuncIds) {
70
+ const idx = indexOf(graph);
63
71
  const nd02 = getND02SimilarityMatrix();
64
72
  if (!nd02)
65
73
  return true; // no matrix → skip check, assume pass
@@ -93,8 +101,9 @@ function passesSchemaOverlap(graph, targetFuncIds) {
93
101
  * FUNC A sends io to both B and C, where B and C connect to the same SCHEMA elements.
94
102
  */
95
103
  export function aoD03DuplicatePath(graph) {
104
+ const idx = indexOf(graph);
96
105
  const violations = [];
97
- const funcs = graph.elements.filter(e => e.type === 'FUNC');
106
+ const funcs = idx.elementsOfType('FUNC');
98
107
  const funcIdSet = new Set(funcs.map(f => f.id));
99
108
  const seen = new Set();
100
109
  // Pre-compute: FUNC → set of SCHEMA targets via FLOW→SCHEMA(relation) (CR-165)
@@ -141,15 +150,16 @@ export function aoD03DuplicatePath(graph) {
141
150
  // diesen Aus-Zustand nicht: jedes Modulpaar mit >= 1 Kreuzung erzeugte eine info-Meldung.
142
151
  // ---------------------------------------------------------------------------
143
152
  export function cr01CrossingFlowCount(graph, policy) {
153
+ const idx = indexOf(graph);
144
154
  const violations = [];
145
155
  const steps = policy.crossingFlows;
146
156
  if (steps === null)
147
157
  return violations;
148
- const mods = graph.elements.filter(e => e.type === 'MOD');
158
+ const mods = idx.elementsOfType('MOD');
149
159
  // Build MOD → Set<FUNC-id> mapping
150
160
  const modFuncs = new Map();
151
161
  for (const mod of mods) {
152
- const funcIds = new Set(graph.traces.filter(t => t.type === 'allocate' && t.target === mod.id).map(t => t.source));
162
+ const funcIds = new Set(idx.in(mod.id, 'allocate').map(t => t.source));
153
163
  modFuncs.set(mod.id, funcIds);
154
164
  }
155
165
  // Count crossings per module pair
@@ -191,10 +201,11 @@ export function cr01CrossingFlowCount(graph, policy) {
191
201
  // FUNCs must not be allocated directly to physical MODs — use logical sub-modules.
192
202
  // ---------------------------------------------------------------------------
193
203
  export function rt01PhysicalBoundaryIntegrity(graph) {
204
+ const idx = indexOf(graph);
194
205
  const physicalMods = graph.elements.filter(e => e.type === 'MOD' && e.attributes?.kind === 'physical');
195
206
  return graph.traces
196
207
  .filter(t => t.type === 'allocate' && physicalMods.some(m => m.id === t.target))
197
- .filter(t => graph.elements.some(e => e.id === t.source && e.type === 'FUNC'))
208
+ .filter(t => idx.typeOf(t.source) === 'FUNC')
198
209
  .map(t => ({
199
210
  rule_id: 'RT-01',
200
211
  severity: 'error',
@@ -208,10 +219,11 @@ export function rt01PhysicalBoundaryIntegrity(graph) {
208
219
  // Physical MODs should have at least one logical sub-module via compose trace.
209
220
  // ---------------------------------------------------------------------------
210
221
  export function ph01PhysicalModCompleteness(graph) {
222
+ const idx = indexOf(graph);
211
223
  const physicalMods = graph.elements.filter(e => e.type === 'MOD' && e.attributes?.kind === 'physical');
212
224
  return physicalMods
213
225
  .filter(pm => !graph.traces.some(t => t.source === pm.id && t.type === 'compose' &&
214
- graph.elements.some(e => e.id === t.target && e.type === 'MOD')))
226
+ idx.typeOf(t.target) === 'MOD'))
215
227
  .map(pm => ({
216
228
  rule_id: 'PH-01',
217
229
  severity: 'info',
@@ -226,6 +238,7 @@ export function ph01PhysicalModCompleteness(graph) {
226
238
  // FUNC @requires must be satisfied by the physical MOD's @capability.
227
239
  // ---------------------------------------------------------------------------
228
240
  export function ca01CapabilityAllocation(graph) {
241
+ const idx = indexOf(graph);
229
242
  const violations = [];
230
243
  const funcsWithRequires = graph.elements.filter(e => e.type === 'FUNC' && e.attributes?.requires);
231
244
  for (const func of funcsWithRequires) {
@@ -236,15 +249,16 @@ export function ca01CapabilityAllocation(graph) {
236
249
  const allocTrace = graph.traces.find(t => t.source === func.id && t.type === 'allocate');
237
250
  if (!allocTrace)
238
251
  continue;
239
- const logicalMod = graph.elements.find(e => e.id === allocTrace.target && e.type === 'MOD');
252
+ const logicalModCandidate = idx.byId.get(allocTrace.target);
253
+ const logicalMod = logicalModCandidate?.type === 'MOD' ? logicalModCandidate : undefined;
240
254
  if (!logicalMod)
241
255
  continue;
242
256
  // Find physical parent MOD (compose source → logical MOD target)
243
257
  const physParentTrace = graph.traces.find(t => t.type === 'compose' && t.target === logicalMod.id &&
244
- graph.elements.some(e => e.id === t.source && e.type === 'MOD' && e.attributes?.kind === 'physical'));
258
+ (idx.byId.get(t.source)?.type === 'MOD' && idx.byId.get(t.source)?.attributes?.kind === 'physical'));
245
259
  if (!physParentTrace)
246
260
  continue;
247
- const physMod = graph.elements.find(e => e.id === physParentTrace.source);
261
+ const physMod = idx.byId.get(physParentTrace.source);
248
262
  const capabilities = (Array.isArray(physMod.attributes?.capability)
249
263
  ? physMod.attributes.capability
250
264
  : physMod.attributes?.capability ? [physMod.attributes.capability] : []);
@@ -289,9 +303,10 @@ export function ca01CapabilityAllocation(graph) {
289
303
  // andere Regel trifft.
290
304
  // ---------------------------------------------------------------------------
291
305
  export function io01CrossModuleCompleteness(graph) {
306
+ const idx = indexOf(graph);
292
307
  const violations = [];
293
308
  const flowIds = new Set(graph.elements.filter(e => e.type === 'FLOW').map(e => e.id));
294
- const funcIdSet = new Set(graph.elements.filter(e => e.type === 'FUNC').map(e => e.id));
309
+ const funcIdSet = idx.idsOfType('FUNC');
295
310
  /** io-Nachbarschaft FUNC <-> FLOW, richtungslos: die Frage ist "haengt das zusammen?". */
296
311
  const flowsOfFunc = new Map();
297
312
  for (const t of graph.traces) {
@@ -306,7 +321,7 @@ export function io01CrossModuleCompleteness(graph) {
306
321
  flowsOfFunc.set(f, new Set());
307
322
  flowsOfFunc.get(f).add(fl);
308
323
  }
309
- for (const fc of graph.elements.filter(e => e.type === 'FCHAIN')) {
324
+ for (const fc of idx.elementsOfType('FCHAIN')) {
310
325
  const members = graph.traces
311
326
  .filter(t => t.source === fc.id && t.type === 'compose')
312
327
  .map(t => t.target)
@@ -356,7 +371,7 @@ export function io01CrossModuleCompleteness(graph) {
356
371
  for (const id of wired) {
357
372
  if (largestSet.has(id))
358
373
  continue;
359
- const el = graph.elements.find(e => e.id === id);
374
+ const el = idx.byId.get(id);
360
375
  const mod = graph.traces.find(t => t.source === id && t.type === 'allocate')?.target ?? 'unallocated';
361
376
  violations.push({
362
377
  rule_id: 'IO-01',
@@ -368,7 +383,7 @@ export function io01CrossModuleCompleteness(graph) {
368
383
  element_type: 'FUNC',
369
384
  element_name: el?.name ?? id,
370
385
  candidate_targets: largest.slice(0, 3).map(t => {
371
- const te = graph.elements.find(e => e.id === t);
386
+ const te = idx.byId.get(t);
372
387
  return { id: t, type: te?.type ?? 'FUNC', name: te?.name ?? t };
373
388
  }),
374
389
  },
@@ -49,6 +49,7 @@ export declare const CodeFactsSchema: z.ZodObject<{
49
49
  from: z.ZodString;
50
50
  to: z.ZodString;
51
51
  }, z.core.$strip>>>;
52
+ declaredDependencies: z.ZodOptional<z.ZodArray<z.ZodString>>;
52
53
  }, z.core.$strip>;
53
54
  export type CodeFacts = z.infer<typeof CodeFactsSchema>;
54
55
  /** A conformance rule: pure over (graph, facts) — never touches I/O itself. */
@@ -58,6 +59,34 @@ export interface ConformanceRuleDefinition {
58
59
  severity: RuleSeverity;
59
60
  evaluate: (graph: OntologyGraph, facts: CodeFacts) => RuleViolation[];
60
61
  }
62
+ /**
63
+ * Wie viel des Import-Graphen konnte RC-05 überhaupt ansehen? (CR-SM-268 Teil 2)
64
+ *
65
+ * Eine MESSUNG über den Lauf, kein Befund über das Modell — deshalb eine eigene Funktion neben
66
+ * den Regeln und keine `RuleViolation`. „12 von 178 Endpunkten nicht zugeordnet" sagt nichts
67
+ * über den Graphen aus; es sagt, wie belastbar die Zahl daneben ist.
68
+ *
69
+ * Vorher hing die Liste als Textanhang an der Meldung eines ANDEREN Befundes und verschwand mit
70
+ * ihm: CR-GC-423 hat die drei RC-05-Drift-Befunde von graphcode geschlossen, und mit ihnen war
71
+ * die Liste der nicht zugeordneten Dateien weg — sie musste in CR-GC-424/425 von Hand
72
+ * rekonstruiert werden. „RC-05: 0 Befunde" hieß damit wahlweise „alle 178 Endpunkte geprüft,
73
+ * keiner driftet" oder „166 geprüft, 12 gar nicht angesehen", und niemand konnte die beiden
74
+ * unterscheiden.
75
+ *
76
+ * Geschwister von `skipped` (CR-GC-398), nicht dasselbe: `skipped` heißt „diese Quelle wurde GAR
77
+ * NICHT ausgewertet" (kein lesbarer repoRoot). Hier ist sie ausgewertet worden, und trotzdem
78
+ * fällt ein Teil durch. Zwei Zustände, zwei Felder — zusammengelegt wären „nicht gelaufen" und
79
+ * „gelaufen, aber blind" wieder ununterscheidbar.
80
+ */
81
+ export interface ImportCoverage {
82
+ /** Verschiedene Dateien, die überhaupt als Import-Endpunkt vorkommen. */
83
+ endpoints: number;
84
+ /** Davon einer MOD zugeordnet — nur diese konnte RC-05 beurteilen. */
85
+ assigned: number;
86
+ /** Der Rest, benannt statt gezählt: ohne die Namen ist die Lücke nicht schließbar. */
87
+ unassigned: string[];
88
+ }
89
+ export declare function importCoverage(graph: OntologyGraph, facts: CodeFacts): ImportCoverage;
61
90
  /** All RC conformance rules — evaluated by executors that can supply CodeFacts. */
62
91
  export declare const CODE_CONFORMANCE_RULES: ConformanceRuleDefinition[];
63
92
  /** Run all RC rules against a graph + extracted code facts. */
@@ -48,6 +48,17 @@ export const CodeFactsSchema = z.object({
48
48
  * given), so pre-CR-212 CodeFacts stay valid.
49
49
  */
50
50
  importEdges: z.array(ImportEdgeSchema).optional(),
51
+ /**
52
+ * Package names the consumer declares in `dependencies` + `devDependencies`, for
53
+ * RC-06 (CR-SM-262). The extractor reads them from the repo's package.json.
54
+ *
55
+ * Optional, and the ABSENT case is silence, not a violation — the opposite of `files`,
56
+ * where a missing key must surface loudly. The asymmetry is deliberate: a missing file
57
+ * entry means the extractor looked and found nothing, while an absent dependency list
58
+ * means it never looked. Treating "never looked" as "declares nothing" would report
59
+ * every external binding in the graph at once, which is noise, not a finding.
60
+ */
61
+ declaredDependencies: z.array(z.string()).optional(),
51
62
  });
52
63
  const missingFile = (facts, file) => facts.files[file]?.exists !== true;
53
64
  // RC-01: every valid FUNC realRef must resolve — file on disk, symbol declared in
@@ -256,15 +267,19 @@ function schemaRefMustBeUsed(graph, facts) {
256
267
  // such adjacency ⇒ one RC-05 per (fromMod → toMod) pair, evidence = the
257
268
  // crossing file imports.
258
269
  // ---------------------------------------------------------------------------
259
- function importDriftConformance(graph, facts) {
260
- const importEdges = facts.importEdges ?? [];
261
- if (importEdges.length === 0)
262
- return [];
270
+ /**
271
+ * Datei MOD, die EINE Auflösung für RC-05 und `importCoverage` (CR-SM-268 Teil 2).
272
+ *
273
+ * Bewusst herausgezogen und nicht zweimal geschrieben: die Abdeckungszahl muss dieselbe Frage
274
+ * stellen wie die Regel, sonst misst sie etwas anderes als das, was geprüft wird — und eine
275
+ * Abdeckungszahl, die nicht zur Prüfung passt, ist schlimmer als keine.
276
+ *
277
+ * Zwei Wege, in dieser Reihenfolge: die `realRef` einer allozierten FUNC (eine gebundene Datei
278
+ * gehört zur MOD ihrer FUNC), danach das `path`-Präfix einer MOD, longest-prefix. Wer keinen
279
+ * von beiden trifft, ist `unassigned` — und wird nie still verworfen.
280
+ */
281
+ function buildModResolver(graph) {
263
282
  const typeOf = new Map(graph.elements.map(e => [e.id, e.type]));
264
- const modIds = graph.elements.filter(e => e.type === 'MOD').map(e => e.id);
265
- if (modIds.length === 0)
266
- return [];
267
- // 1. file → MOD. Direct realRef bindings first.
268
283
  const fileToMod = new Map();
269
284
  for (const el of graph.elements) {
270
285
  if (el.type !== 'FUNC')
@@ -281,7 +296,7 @@ function importDriftConformance(graph, facts) {
281
296
  .filter(e => e.type === 'MOD' && typeof e.attributes?.path === 'string')
282
297
  .map(e => ({ id: e.id, path: e.attributes.path.replace(/[*].*$/, '').replace(/\/+$/, '') }))
283
298
  .filter(m => m.path.length > 0);
284
- const resolveMod = (file) => {
299
+ return (file) => {
285
300
  if (fileToMod.has(file))
286
301
  return fileToMod.get(file);
287
302
  let best;
@@ -292,6 +307,27 @@ function importDriftConformance(graph, facts) {
292
307
  }
293
308
  return best?.id;
294
309
  };
310
+ }
311
+ export function importCoverage(graph, facts) {
312
+ const importEdges = facts.importEdges ?? [];
313
+ const resolveMod = buildModResolver(graph);
314
+ const endpoints = new Set();
315
+ for (const edge of importEdges) {
316
+ endpoints.add(edge.from);
317
+ endpoints.add(edge.to);
318
+ }
319
+ const unassigned = [...endpoints].filter(f => resolveMod(f) === undefined).sort();
320
+ return { endpoints: endpoints.size, assigned: endpoints.size - unassigned.length, unassigned };
321
+ }
322
+ function importDriftConformance(graph, facts) {
323
+ const importEdges = facts.importEdges ?? [];
324
+ if (importEdges.length === 0)
325
+ return [];
326
+ const modIds = graph.elements.filter(e => e.type === 'MOD').map(e => e.id);
327
+ if (modIds.length === 0)
328
+ return [];
329
+ const typeOf = new Map(graph.elements.map(e => [e.id, e.type]));
330
+ const resolveMod = buildModResolver(graph);
295
331
  // 2. graph MOD-adjacency (undirected) from io crossings FUNC_A ─io→ FLOW ─io→ FUNC_B.
296
332
  const funcMod = (funcId) => {
297
333
  const t = graph.traces.find(tr => tr.source === funcId && tr.type === 'allocate' && typeOf.get(tr.target) === 'MOD');
@@ -321,16 +357,15 @@ function importDriftConformance(graph, facts) {
321
357
  const [x, y] = [a, b].sort();
322
358
  return adjacent.has(`${x}|${y}`);
323
359
  };
324
- // 3. drift per (fromMod → toMod), aggregating evidence; collect unassigned files.
360
+ // 3. drift per (fromMod → toMod), aggregating evidence.
361
+ // CR-SM-268 Teil 2: die nicht zugeordneten Dateien werden hier NICHT mehr gesammelt. Sie
362
+ // hingen als Textanhang an der Meldung eines anderen Befundes und verschwanden mit ihm
363
+ // (CR-GC-423). Ihr Ort ist jetzt `importCoverage` — eine Messung, die auch dann etwas sagt,
364
+ // wenn diese Regel schweigt.
325
365
  const drift = new Map();
326
- const unassigned = new Set();
327
366
  for (const edge of importEdges) {
328
367
  const fromMod = resolveMod(edge.from);
329
368
  const toMod = resolveMod(edge.to);
330
- if (!fromMod)
331
- unassigned.add(edge.from);
332
- if (!toMod)
333
- unassigned.add(edge.to);
334
369
  if (!fromMod || !toMod || fromMod === toMod)
335
370
  continue;
336
371
  if (isAdjacent(fromMod, toMod))
@@ -341,7 +376,6 @@ function importDriftConformance(graph, facts) {
341
376
  drift.set(key, entry);
342
377
  }
343
378
  const nameOf = new Map(graph.elements.map(e => [e.id, e.name]));
344
- const unassignedNote = unassigned.size > 0 ? ` (unassigned files not mapped to a MOD: ${[...unassigned].sort().join(', ')})` : '';
345
379
  return [...drift.values()].map(d => {
346
380
  const shown = d.evidence.slice(0, 5);
347
381
  const more = d.evidence.length > shown.length ? ` +${d.evidence.length - shown.length} more` : '';
@@ -349,7 +383,7 @@ function importDriftConformance(graph, facts) {
349
383
  rule_id: 'RC-05',
350
384
  severity: 'warning',
351
385
  element_id: d.fromMod,
352
- message: `${d.fromMod} imports ${d.toMod} across a module boundary the graph does not document — evidence: ${shown.join('; ')}${more}${unassignedNote}`,
386
+ message: `${d.fromMod} imports ${d.toMod} across a module boundary the graph does not document — evidence: ${shown.join('; ')}${more}`,
353
387
  fix_hint: `Document the ${d.fromMod}→${d.toMod} dependency in the graph (an io/FLOW connection between their FUNCs), or remove the import`,
354
388
  context: {
355
389
  element_type: 'MOD',
@@ -359,6 +393,61 @@ function importDriftConformance(graph, facts) {
359
393
  };
360
394
  });
361
395
  }
396
+ // ---------------------------------------------------------------------------
397
+ // RC-06: an `external` realRef names a package the consumer actually depends on
398
+ // (CR-SM-262).
399
+ //
400
+ // RC-01..03 skip `external === true` — and rightly so: the path of a foreign package is
401
+ // not in this repo and cannot be resolved here. The consequence was that external bindings
402
+ // were never checked at all and rotted in silence. Found by hand at the graphcode
403
+ // self-model on 2026-08-22, not by a rule: `SCHEMA-metric-vector` carried
404
+ // `realRef.file = "packages/se-optimizer/src/metrics.ts"`, and `@sigloch/se-optimizer` had
405
+ // ceased to exist with CR-SM-248 — `MetricVector` moved to `se-engine`. The node pointed at
406
+ // a package that is not there, and RC-01 said nothing because `external: true` was set.
407
+ //
408
+ // The PATH is unverifiable here; the PACKAGE NAME is not. A path of the shape
409
+ // `packages/<name>/…` claims a workspace package, so `@sigloch/<name>` must appear in the
410
+ // consumer's dependencies. Any other path shape stays unchecked rather than guessed.
411
+ //
412
+ // Severity `warning`, not `error`: the path may deviate from the convention for honest
413
+ // reasons (monorepo restructuring, a linked working copy), and an `error` would block every
414
+ // further mutation through the delta gate. A rotted binding is a completeness signal, not a
415
+ // mismeasurement — unlike R-29, where the number itself becomes wrong.
416
+ // ---------------------------------------------------------------------------
417
+ /** `packages/<name>/…` — the workspace-package path shape RC-06 can decide. */
418
+ const WORKSPACE_PATH = /^packages\/([^/]+)\//;
419
+ /** Package scope of this family. A path claims `<SCOPE>/<name>`. */
420
+ const WORKSPACE_SCOPE = '@sigloch';
421
+ function externalRefMustNameDependency(graph, facts) {
422
+ const declared = facts.declaredDependencies;
423
+ if (declared === undefined)
424
+ return []; // extractor supplied none — silence, s. CodeFactsSchema
425
+ const known = new Set(declared);
426
+ const violations = [];
427
+ for (const el of graph.elements) {
428
+ // `concept: true` has no binding that could rot — a concept node claims nothing about code.
429
+ if (el.attributes?.external !== true || el.attributes?.concept === true)
430
+ continue;
431
+ const parsed = RealRefSchema.safeParse(el.attributes?.realRef);
432
+ if (!parsed.success)
433
+ continue;
434
+ const match = WORKSPACE_PATH.exec(parsed.data.file);
435
+ if (!match)
436
+ continue; // not the shape this rule can decide
437
+ const pkg = `${WORKSPACE_SCOPE}/${match[1]}`;
438
+ if (known.has(pkg))
439
+ continue;
440
+ violations.push({
441
+ rule_id: 'RC-06',
442
+ severity: 'warning',
443
+ element_id: el.id,
444
+ message: `${el.id} binds to '${parsed.data.file}', but '${pkg}' is not a declared dependency`,
445
+ fix_hint: `Add '${pkg}' to dependencies, or re-point the realRef at the package that now owns the symbol`,
446
+ context: { element_type: el.type, element_name: el.name },
447
+ });
448
+ }
449
+ return violations;
450
+ }
362
451
  /** All RC conformance rules — evaluated by executors that can supply CodeFacts. */
363
452
  export const CODE_CONFORMANCE_RULES = [
364
453
  { id: 'RC-01', name: 'FUNC realRef resolves to a declared symbol', severity: 'error', evaluate: codeRefMustResolve },
@@ -366,6 +455,7 @@ export const CODE_CONFORMANCE_RULES = [
366
455
  { id: 'RC-03', name: 'SCHEMA realRef resolves to a declared export', severity: 'error', evaluate: schemaRefMustResolve },
367
456
  { id: 'RC-04', name: 'SCHEMA realRef is parsed at its interface', severity: 'warning', evaluate: schemaRefMustBeUsed },
368
457
  { id: 'RC-05', name: 'cross-module import drift', severity: 'warning', evaluate: importDriftConformance },
458
+ { id: 'RC-06', name: 'external realRef names a declared dependency', severity: 'warning', evaluate: externalRefMustNameDependency },
369
459
  ];
370
460
  /** Run all RC rules against a graph + extracted code facts. */
371
461
  export function evaluateConformanceRules(graph, facts) {
@@ -1,33 +1,36 @@
1
+ import { indexOf } from './graph-index.js';
1
2
  // ---------------------------------------------------------------------------
2
3
  // FC-01: FCHAIN must have Actor boundary (input or output via FLOW→ACTOR io)
3
4
  // ---------------------------------------------------------------------------
4
5
  export function fc01ActorBoundary(graph) {
5
- return graph.elements
6
- .filter(e => e.type === 'FCHAIN')
6
+ // CR-SM-264: dieselbe Frage, einmal statt je Kette neu.
7
+ //
8
+ // Die alte Fassung war die verschachtelte Form, die UC-02 kubisch gemacht hat (CR-SM-261):
9
+ // je FCHAIN alle Traces, darin je io-Kante noch einmal alle Traces, darin je Treffer alle
10
+ // Elemente — `FCHAIN x FUNC x T x T x E`. Im Profil nach Teil a war FC-01 mit 6,3 % die
11
+ // teuerste FCHAIN-Regel.
12
+ //
13
+ // Beide Zweige fragen in Wahrheit DASSELBE: "beruehrt dieses Element eine io-Kante, deren
14
+ // anderes Ende ein ACTOR ist?" Einmal fuer den Zwischenknoten der Kettenglieder, einmal
15
+ // direkt fuer den Eltern-UC. Eine Menge beantwortet beide.
16
+ const idx = indexOf(graph);
17
+ const actorAdjacent = new Set();
18
+ for (const t of idx.tracesOfType('io')) {
19
+ if (idx.typeOf(t.target) === 'ACTOR')
20
+ actorAdjacent.add(t.source);
21
+ if (idx.typeOf(t.source) === 'ACTOR')
22
+ actorAdjacent.add(t.target);
23
+ }
24
+ return idx.elementsOfType('FCHAIN')
7
25
  .filter(fc => {
8
- // Get FUNCs in this FCHAIN via compose
9
- const funcIds = graph.traces
10
- .filter(t => t.source === fc.id && t.type === 'compose')
11
- .map(t => t.target);
12
- // Check if any FUNC has io trace involving an ACTOR (via FLOW)
13
- const hasActorIO = funcIds.some(fid => graph.traces.some(t => {
14
- if (t.type !== 'io')
15
- return false;
16
- // FUNC→FLOW or FLOW→FUNC
17
- const flowId = t.source === fid ? t.target : (t.target === fid ? t.source : null);
18
- if (!flowId)
19
- return false;
20
- // FLOW→ACTOR or ACTOR→FLOW
21
- return graph.traces.some(ft => ft.type === 'io' &&
22
- ((ft.source === flowId && graph.elements.some(e => e.id === ft.target && e.type === 'ACTOR')) ||
23
- (ft.target === flowId && graph.elements.some(e => e.id === ft.source && e.type === 'ACTOR'))));
24
- }));
25
- // Also check direct ACTOR→UC io on the parent UC
26
- const parentUC = graph.traces.find(t => t.type === 'compose' && t.target === fc.id &&
27
- graph.elements.some(e => e.id === t.source && e.type === 'UC'));
28
- const hasDirectActorIO = parentUC && graph.traces.some(t => t.type === 'io' &&
29
- ((t.target === parentUC.source && graph.elements.some(e => e.id === t.source && e.type === 'ACTOR')) ||
30
- (t.source === parentUC.source && graph.elements.some(e => e.id === t.target && e.type === 'ACTOR'))));
26
+ // Mitglieder der Kette — bewusst OHNE Typfilter, wie in der alten Fassung: FC-01 fragt
27
+ // nach dem, was komponiert ist, nicht nach dem, was eine FUNC ist (FC-03 tut das).
28
+ const memberIds = idx.out(fc.id, 'compose').map(t => t.target);
29
+ const hasActorIO = memberIds.some(fid => idx.out(fid, 'io').some(t => actorAdjacent.has(t.target)) ||
30
+ idx.in(fid, 'io').some(t => actorAdjacent.has(t.source)));
31
+ // Der Umweg ueber den Eltern-UC: dessen eigene ACTOR-io zaehlt auch.
32
+ const parentUC = idx.in(fc.id, 'compose').find(t => idx.typeOf(t.source) === 'UC');
33
+ const hasDirectActorIO = parentUC !== undefined && actorAdjacent.has(parentUC.source);
31
34
  return !hasActorIO && !hasDirectActorIO;
32
35
  })
33
36
  .map(fc => ({
@@ -43,17 +46,15 @@ export function fc01ActorBoundary(graph) {
43
46
  // FC-02: Leaf UC (no UC→compose→UC) must have at least one FCHAIN
44
47
  // ---------------------------------------------------------------------------
45
48
  export function fc02LeafUcHasFchain(graph) {
46
- return graph.elements
47
- .filter(e => e.type === 'UC')
49
+ const idx = indexOf(graph);
50
+ return idx.elementsOfType('UC')
48
51
  .filter(uc => {
52
+ const composed = idx.out(uc.id, 'compose');
49
53
  // Leaf = no compose→UC children
50
- const hasUCChild = graph.traces.some(t => t.source === uc.id && t.type === 'compose' &&
51
- graph.elements.some(e => e.id === t.target && e.type === 'UC'));
52
- if (hasUCChild)
54
+ if (composed.some(t => idx.typeOf(t.target) === 'UC'))
53
55
  return false;
54
56
  // Must have compose→FCHAIN
55
- return !graph.traces.some(t => t.source === uc.id && t.type === 'compose' &&
56
- graph.elements.some(e => e.id === t.target && e.type === 'FCHAIN'));
57
+ return !composed.some(t => idx.typeOf(t.target) === 'FCHAIN');
57
58
  })
58
59
  .map(uc => ({
59
60
  rule_id: 'FC-02',
@@ -69,15 +70,13 @@ export function fc02LeafUcHasFchain(graph) {
69
70
  // ---------------------------------------------------------------------------
70
71
  export function fc03FchainFlat(graph) {
71
72
  const violations = [];
72
- const fchains = graph.elements.filter(e => e.type === 'FCHAIN');
73
- for (const fc of fchains) {
74
- const funcIds = graph.traces
75
- .filter(t => t.source === fc.id && t.type === 'compose')
73
+ const idx = indexOf(graph);
74
+ for (const fc of idx.elementsOfType('FCHAIN')) {
75
+ const funcIds = idx.out(fc.id, 'compose')
76
76
  .map(t => t.target)
77
- .filter(id => graph.elements.some(e => e.id === id && e.type === 'FUNC'));
77
+ .filter(id => idx.typeOf(id) === 'FUNC');
78
78
  for (const fid of funcIds) {
79
- const nestedCompose = graph.traces.filter(t => t.source === fid && t.type === 'compose' &&
80
- graph.elements.some(e => e.id === t.target && e.type === 'FUNC'));
79
+ const nestedCompose = idx.out(fid, 'compose').filter(t => idx.typeOf(t.target) === 'FUNC');
81
80
  if (nestedCompose.length > 0) {
82
81
  violations.push({
83
82
  rule_id: 'FC-03',
@@ -85,7 +84,7 @@ export function fc03FchainFlat(graph) {
85
84
  element_id: fid,
86
85
  message: `${fid} in ${fc.id} has nested FUNC compose (should be flat)`,
87
86
  fix_hint: 'Move nested functions to FCHAIN level (flat composition)',
88
- context: { element_type: 'FUNC', element_name: graph.elements.find(e => e.id === fid)?.name ?? fid },
87
+ context: { element_type: 'FUNC', element_name: idx.byId.get(fid)?.name ?? fid },
89
88
  });
90
89
  }
91
90
  }
@@ -105,24 +104,29 @@ export function fc03FchainFlat(graph) {
105
104
  // to have unrelated ACTOR io.
106
105
  // ---------------------------------------------------------------------------
107
106
  export function fc04ActorBounded(graph) {
108
- return graph.elements
109
- .filter(e => e.type === 'FCHAIN')
107
+ // CR-SM-264: die beiden Zwischenmengen einmal je Graph statt |io|^2 je Kette.
108
+ // `actorFed` sind die Knoten, in die ein ACTOR hineinschickt, `actorConsumed` die, aus denen
109
+ // ein ACTOR liest. Beide Richtungen bleiben getrennt — anders als bei FC-01, das nur fragt,
110
+ // OB ein ACTOR anliegt; FC-04 verlangt Eingang UND Ausgang.
111
+ const idx = indexOf(graph);
112
+ const io = idx.tracesOfType('io');
113
+ const actorFed = new Set();
114
+ const actorConsumed = new Set();
115
+ for (const a of io) {
116
+ if (idx.typeOf(a.source) === 'ACTOR')
117
+ actorFed.add(a.target);
118
+ if (idx.typeOf(a.target) === 'ACTOR')
119
+ actorConsumed.add(a.source);
120
+ }
121
+ return idx.elementsOfType('FCHAIN')
110
122
  .filter(fc => {
111
- const funcIds = new Set(graph.traces
112
- .filter(t => t.source === fc.id && t.type === 'compose')
113
- .map(t => t.target)
114
- .filter(id => graph.elements.some(e => e.id === id && e.type === 'FUNC')));
123
+ const funcIds = new Set(idx.out(fc.id, 'compose').map(t => t.target).filter(id => idx.typeOf(id) === 'FUNC'));
115
124
  if (funcIds.size === 0)
116
125
  return false; // R-15 already flags the empty-chain case
117
- const io = graph.traces.filter(t => t.type === 'io');
118
- const isActor = (id) => graph.elements.some(e => e.id === id && e.type === 'ACTOR');
119
- const isFlow = (id) => graph.elements.some(e => e.id === id && e.type === 'FLOW');
120
126
  // Entry: ACTOR→FLOW→FUNC∈chain (something triggers the chain).
121
- const entry = io.some(e => funcIds.has(e.target) && isFlow(e.source) &&
122
- io.some(a => a.target === e.source && isActor(a.source)));
127
+ const entry = io.some(e => funcIds.has(e.target) && idx.typeOf(e.source) === 'FLOW' && actorFed.has(e.source));
123
128
  // Exit: FUNC∈chain→FLOW→ACTOR (the chain produces something back to an actor).
124
- const exit = io.some(e => funcIds.has(e.source) && isFlow(e.target) &&
125
- io.some(a => a.source === e.target && isActor(a.target)));
129
+ const exit = io.some(e => funcIds.has(e.source) && idx.typeOf(e.target) === 'FLOW' && actorConsumed.has(e.target));
126
130
  return !(entry && exit);
127
131
  })
128
132
  .map(fc => ({
@@ -0,0 +1,3 @@
1
+ import type { OntologyGraph } from './ontology.js';
2
+ /** Hebt eine flach committete SSOT in die Form, die der Live-Gate dem Evaluator gibt. */
3
+ export declare function toEvaluableGraph(graph: OntologyGraph): OntologyGraph;