@sigloch/graph-api-core 5.4.0 → 5.6.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.
@@ -3,8 +3,6 @@ export declare class FormatECodec {
3
3
  private readonly ontology;
4
4
  private readonly edgeArrowToType;
5
5
  private readonly validNodeTypes;
6
- /** Trace-legality patterns (CR-GC-247) — the SSOT the parser validates edges against. */
7
- private readonly patterns;
8
6
  constructor(ontology: OntologyDescriptor);
9
7
  /** Extract a ```format-e block from LLM output. Returns null if not found. */
10
8
  extractFromLlm(llmOutput: string): string | null;
@@ -31,6 +29,7 @@ export declare class FormatECodec {
31
29
  */
32
30
  serialize(graph: Graph, options?: {
33
31
  omitProvenance?: boolean;
32
+ roundTrip?: boolean;
34
33
  }): string;
35
34
  private parseNodeLine;
36
35
  private parseEdgeLine;
@@ -67,9 +66,32 @@ export declare class FormatECodec {
67
66
  * contains no comma. No edge in the SE ontology carries a structured attribute today; if one
68
67
  * ever does, the inline block is the thing to replace, not this escape.
69
68
  */
70
- private serializeAttrs;
71
- /** CR-GC-334: object/array attributes as `@key {json}` lines below the node entry. */
72
- private structuredAttrLines;
69
+ /**
70
+ * CR-SM-332: EIN Kriterium entscheidet, ob ein Attribut inline oder auf eine `@key`-Zeile
71
+ * geht — naemlich, ob sein Wert den Inline-Block brechen wuerde.
72
+ *
73
+ * Vorher entschied der TYP: `typeof v === 'object'` ging auf die Folgezeile, alles andere
74
+ * inline. Damit blieb eine Luecke derselben Klasse wie der Zeilenumbruch offen: ein STRING
75
+ * mit Komma oder Klammer ("a, b" oder "f(x)") wurde inline geschrieben, und
76
+ * `parseInlineAttrs` splittet auf Kommas — der Wert kam zerteilt oder gar nicht zurueck.
77
+ * Still, wie immer bei dieser Klasse. graphcodes Fork hatte genau dafuer bereits
78
+ * `UNSAFE_ATTR_RE`; die Regel wandert hierher, wo der einzige Codec steht.
79
+ *
80
+ * Schluessel aufsteigend nach Code-Einheiten, nicht `localeCompare` — dieselbe Begruendung
81
+ * wie bei `serializeEdges`: eine Sortierung, die von der Locale abhaengt, ist nicht
82
+ * deterministisch.
83
+ */
84
+ private nodeAttrParts;
85
+ /**
86
+ * CR-SM-332 (aus graphcodes Fork uebernommen, CR-GC-200/CR-GC-531): Knotentypen gegen die
87
+ * Ontologie, Kantentypen gegen die Ontologie, doppelte uids, aufloesbare Endpunkte.
88
+ * Paar-Legalitaet gehoert NICHT hierher — das ist R-18, dort wo Daten in den Speicher
89
+ * gehen.
90
+ */
91
+ validate(graph: Graph, resolveType?: (uid: string) => string | undefined): {
92
+ valid: boolean;
93
+ errors: string[];
94
+ };
73
95
  /** Inline block for an EDGE — structured values stringified, see `serializeAttrs`. */
74
96
  private serializeEdgeAttrs;
75
97
  private edgeTypeToArrow;
@@ -8,7 +8,6 @@
8
8
  * every foreign convention fail silently instead of loudly.
9
9
  */
10
10
  import { hydrateAttrValue, attributeTypeOf } from '@sigloch/contracts/se';
11
- import { isValidTrace, tracePatternsOf } from './types.js';
12
11
  // ---------------------------------------------------------------------------
13
12
  // Regex patterns
14
13
  // ---------------------------------------------------------------------------
@@ -23,12 +22,39 @@ const MERGE_RE = /^M\s+(.+)$/;
23
22
  const FORMAT_E_FENCE = /```format-e\s*\n([\s\S]*?)```/;
24
23
  /** Inline attribute block: [key:value,key:value] */
25
24
  const INLINE_ATTRS_RE = /\[([^\]]+)\]$/;
25
+ /**
26
+ * CR-SM-332 (ITEM-2026-183) — Format E ist ZEILENBASIERT, also darf kein Feld eine Zeile
27
+ * beenden koennen. `serialize` schrieb `|${description}` roh hinaus; eine Beschreibung mit
28
+ * `\n` erzeugte damit eine zweite Zeile, die beim naechsten Lesen als eigener KNOTEN ankam
29
+ * (uid = der Resttext, Typ = die offene `### <TYPE>`-Sektion). Am Gate reproduziert: success
30
+ * true, mutations 2 — der Phantom-Knoten waere angelegt worden.
31
+ *
32
+ * Beide Enden sind noetig. Nur lesend zu pruefen hiesse, der kaputte Text entsteht weiter und
33
+ * faellt erst beim naechsten Lesen auf — dann ohne den Knoten, der ihn verursacht hat.
34
+ */
35
+ const LINE_BREAK_RE = /[\r\n]/;
36
+ /**
37
+ * CR-SM-332: Zeichen, an denen der Inline-Block `[k:v,k:v]` zerbricht. `parseInlineAttrs`
38
+ * splittet auf Kommas, also kann kein Wert mit Komma oder Klammer dort stehen — gleich ob er
39
+ * ein Objekt ist oder ein String. Uebernommen aus graphcodes Fork (UNSAFE_ATTR_RE).
40
+ */
41
+ const UNSAFE_INLINE_RE = /[,[\]{}]/;
42
+ /**
43
+ * Vergleich nach CODE-EINHEITEN, nicht `localeCompare`: eine Sortierung, die von der Locale
44
+ * des laufenden Prozesses abhaengt, ist nicht deterministisch (REQ-deterministic-serialization).
45
+ */
46
+ const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
47
+ function assertSingleLine(where, field, value) {
48
+ if (typeof value === 'string' && LINE_BREAK_RE.test(value)) {
49
+ throw new Error(`FormatECodec.serialize: ${where} — ${field} enthaelt einen Zeilenumbruch. Format E ist `
50
+ + `zeilenbasiert; der Umbruch wuerde beim Lesen als eigene Knotenzeile ankommen `
51
+ + `(ITEM-2026-183). Ein langer Text gehoert zerlegt, nicht umgebrochen.`);
52
+ }
53
+ }
26
54
  export class FormatECodec {
27
55
  ontology;
28
56
  edgeArrowToType;
29
57
  validNodeTypes;
30
- /** Trace-legality patterns (CR-GC-247) — the SSOT the parser validates edges against. */
31
- patterns;
32
58
  constructor(ontology) {
33
59
  this.ontology = ontology;
34
60
  // Build arrow → edge type lookup
@@ -40,8 +66,6 @@ export class FormatECodec {
40
66
  }
41
67
  // Valid node type abbreviations
42
68
  this.validNodeTypes = new Set(Object.keys(ontology.nodeTypes));
43
- // Meta-model legality: descriptor.patterns (or derived from validPairs).
44
- this.patterns = tracePatternsOf(ontology);
45
69
  }
46
70
  /** Extract a ```format-e block from LLM output. Returns null if not found. */
47
71
  extractFromLlm(llmOutput) {
@@ -162,6 +186,17 @@ export class FormatECodec {
162
186
  */
163
187
  serialize(graph, options = {}) {
164
188
  const omit = options.omitProvenance === true;
189
+ const roundTrip = options.roundTrip === true;
190
+ // CR-SM-332: `roundTrip` ist die Fassung, die graphcode bis hierher als EIGENEN encode
191
+ // nachgebaut hat (projections/codec.ts) — sie traegt zusaetzlich die Felder, die neben
192
+ // `attributes` leben (name/createdAt/updatedAt), und prueft den Graphen vorher. Kein
193
+ // zweiter Codec, eine Option: der Standard bleibt zeichengleich die Agenten-Sicht.
194
+ if (roundTrip) {
195
+ const { valid, errors } = this.validate(graph);
196
+ if (!valid) {
197
+ throw new Error(`FormatECodec.serialize: graph validation failed:\n - ${errors.join('\n - ')}`);
198
+ }
199
+ }
165
200
  const lines = [];
166
201
  if (graph.nodes.length > 0) {
167
202
  lines.push('## Nodes');
@@ -178,13 +213,38 @@ export class FormatECodec {
178
213
  }
179
214
  for (const type of [...byType.keys()].sort()) {
180
215
  lines.push(`### ${type}`);
181
- for (const node of byType.get(type) ?? []) {
182
- const descr = node.description ? `|${node.description}` : '';
183
- const nodeAttrs = omit ? stripNodeProvenance(node.attributes) : node.attributes;
184
- lines.push(`+ ${node.uid}${descr}${this.serializeAttrs(nodeAttrs)}`);
185
- // CR-GC-334: realRef/testRef & friends as @key {json} the inline block above
186
- // cannot carry them, and dropping them here is what made bindings vanish.
187
- lines.push(...this.structuredAttrLines(nodeAttrs));
216
+ // CR-SM-332: innerhalb der Sektion nach uid sortiert. Vorher stand hier die
217
+ // EINGABEREIHENFOLGE zwei encode-Laeufe auf demselben Graphen konnten sich also
218
+ // unterscheiden, sobald der Speicher anders lieferte. Das war der Grund, aus dem
219
+ // graphcode einen eigenen encode hielt (REQ-deterministic-serialization).
220
+ const group = [...(byType.get(type) ?? [])].sort((a, b) => cmp(a.uid, b.uid));
221
+ for (const node of group) {
222
+ // CR-SM-332: kein Umbruch verlaesst diesen Codec.
223
+ assertSingleLine(`node "${node.uid}"`, 'description', node.description);
224
+ const base = omit ? stripNodeProvenance(node.attributes) : node.attributes;
225
+ // Die Felder, die NEBEN `attributes` am Knoten haengen — ohne sie ist
226
+ // decode(encode(g)) nicht deep-equal g. Nur in der Rundlauf-Fassung: in der
227
+ // Agenten-Sicht waere `__name` an jedem Knoten reines Rauschen.
228
+ const nodeAttrs = roundTrip
229
+ ? {
230
+ ...base,
231
+ __name: node.name,
232
+ ...(node.createdAt !== undefined ? { __createdAt: node.createdAt } : {}),
233
+ ...(node.updatedAt !== undefined ? { __updatedAt: node.updatedAt } : {}),
234
+ }
235
+ : base;
236
+ for (const [k, v] of Object.entries(nodeAttrs)) {
237
+ // Strukturierte Werte gehen als JSON auf @key-Zeilen; JSON.stringify maskiert
238
+ // den Umbruch, dort kann er die Zeile nicht brechen.
239
+ if (typeof v !== 'object')
240
+ assertSingleLine(`node "${node.uid}"`, `attribute "${k}"`, v);
241
+ }
242
+ // In der Rundlauf-Fassung steht der Pipe IMMER: ohne ihn kaeme eine leere
243
+ // Beschreibung als `undefined` zurueck statt als '' (graphcodes encode tat dasselbe).
244
+ const descr = node.description ? `|${node.description}` : (roundTrip ? '|' : '');
245
+ const { inline, follow } = this.nodeAttrParts(nodeAttrs);
246
+ lines.push(`+ ${node.uid}${descr}${inline}`);
247
+ lines.push(...follow);
188
248
  }
189
249
  }
190
250
  }
@@ -194,6 +254,14 @@ export class FormatECodec {
194
254
  const edges = omit
195
255
  ? graph.edges.map(e => ({ ...e, attributes: stripEdgeProvenance(e.attributes) }))
196
256
  : graph.edges;
257
+ // CR-SM-332: dieselbe Zusicherung an der Kante — ihr Inline-Block steht auf derselben Zeile.
258
+ for (const e of edges) {
259
+ for (const [k, v] of Object.entries(e.attributes)) {
260
+ if (typeof v !== 'object') {
261
+ assertSingleLine(`edge "${e.sourceId} -${e.edgeType}-> ${e.targetId}"`, `attribute "${k}"`, v);
262
+ }
263
+ }
264
+ }
197
265
  lines.push(...this.serializeEdges(edges));
198
266
  }
199
267
  return lines.join('\n');
@@ -202,9 +270,17 @@ export class FormatECodec {
202
270
  // Private
203
271
  // ---------------------------------------------------------------------------
204
272
  parseNodeLine(line, nodeType, declared, ops, errors) {
205
- const opChar = OP_PREFIX[line[0]] ? line[0] : '+';
206
- const rest = OP_PREFIX[line[0]] ? line.slice(1).trim() : line.trim();
207
- const action = OP_PREFIX[opChar] ?? 'add';
273
+ // CR-SM-332: das Operator-Praefix ist PFLICHT. Vorher war es optional ("kein Praefix =
274
+ // add"), womit JEDE Textzeile eine gueltige Knotenzeile war — genau der Weg, auf dem eine
275
+ // uebergelaufene Beschreibung zum Phantom-Knoten wurde. Ein Praefix zu verlangen kostet
276
+ // nichts (jeder Erzeuger schreibt es) und schliesst die Klasse.
277
+ const action = OP_PREFIX[line[0]];
278
+ if (!action) {
279
+ errors.push(`Node line without an operator prefix (+ - ~ !): "${line}" — eine Zeile ohne Operator `
280
+ + `ist keine Knotenzeile. Haeufigste Ursache: eine Beschreibung mit Zeilenumbruch.`);
281
+ return;
282
+ }
283
+ const rest = line.slice(1).trim();
208
284
  // Extract inline attributes
209
285
  let mainPart = rest;
210
286
  let inlineAttrs;
@@ -221,6 +297,14 @@ export class FormatECodec {
221
297
  errors.push(`Node line without a uid: "${line}"`);
222
298
  return;
223
299
  }
300
+ // CR-SM-332: die uid war "alles vor dem Pipe", Leerzeichen eingeschlossen — der zweite Teil
301
+ // desselben Einfallstors. Der (entfernte) contracts-Parser verlangte hier `\S+?` und haette
302
+ // den Fall gefangen; die Strenge wandert mit ihm hierher.
303
+ if (/\s/.test(uid)) {
304
+ errors.push(`Uid contains whitespace: "${uid}" — uids sind zusammenhaengend. Eine uebergelaufene `
305
+ + `Beschreibungszeile sieht genau so aus.`);
306
+ return;
307
+ }
224
308
  // CR-SM-217: with `TYPE-slug` as the family canon, the prefix restores the
225
309
  // redundancy CR-SM-216 removed — for free, since `REQ-safety` costs fewer tokens
226
310
  // than `REQ-safety.REQ`. A node under the wrong section is caught here. Uids with
@@ -290,12 +374,9 @@ export class FormatECodec {
290
374
  errors.push(`Cannot resolve type of target "${targetId}" — not declared under a "### <TYPE>" section and no resolveType provided`);
291
375
  continue;
292
376
  }
293
- // Meta-model validation (CR-GC-247: single checker, patterns SSOThonors
294
- // '*' wildcards, unlike the old per-edgeType validPairs set-membership).
295
- if (!isValidTrace({ source: srcType, target: tgtType, type: edgeType }, this.patterns)) {
296
- errors.push(`Meta-model violation: ${srcType} -${edgeType}-> ${tgtType} is not valid`);
297
- continue;
298
- }
377
+ // CR-SM-324: no legality verdict while parsing the parser knows neither label nor
378
+ // the kinds of existing nodes. The write path judges with the one rule (gate R-18,
379
+ // GraphService.validateAndApplyEdge).
299
380
  const opType = action === 'remove'
300
381
  ? 'remove_edge'
301
382
  : action === 'strict_add'
@@ -364,7 +445,7 @@ export class FormatECodec {
364
445
  entries.push({ sourceId: edge.sourceId, edgeType: edge.edgeType, targets: [edge.targetId], attrs });
365
446
  continue;
366
447
  }
367
- const key = `${edge.sourceId}${edge.edgeType}`;
448
+ const key = `${edge.sourceId}\0${edge.edgeType}`;
368
449
  const group = groups.get(key);
369
450
  if (group) {
370
451
  group.targets.push(edge.targetId);
@@ -375,7 +456,6 @@ export class FormatECodec {
375
456
  entries.push(entry);
376
457
  }
377
458
  }
378
- const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
379
459
  for (const entry of entries)
380
460
  entry.targets.sort(cmp);
381
461
  entries.sort((a, b) => cmp(a.sourceId, b.sourceId)
@@ -395,18 +475,71 @@ export class FormatECodec {
395
475
  * contains no comma. No edge in the SE ontology carries a structured attribute today; if one
396
476
  * ever does, the inline block is the thing to replace, not this escape.
397
477
  */
398
- serializeAttrs(attrs) {
399
- const entries = Object.entries(attrs).filter(([, v]) => v != null && v !== '' && typeof v !== 'object');
400
- if (entries.length === 0)
401
- return '';
402
- const pairs = entries.map(([k, v]) => `${k}:${String(v)}`);
403
- return ` [${pairs.join(',')}]`;
478
+ /**
479
+ * CR-SM-332: EIN Kriterium entscheidet, ob ein Attribut inline oder auf eine `@key`-Zeile
480
+ * geht naemlich, ob sein Wert den Inline-Block brechen wuerde.
481
+ *
482
+ * Vorher entschied der TYP: `typeof v === 'object'` ging auf die Folgezeile, alles andere
483
+ * inline. Damit blieb eine Luecke derselben Klasse wie der Zeilenumbruch offen: ein STRING
484
+ * mit Komma oder Klammer ("a, b" oder "f(x)") wurde inline geschrieben, und
485
+ * `parseInlineAttrs` splittet auf Kommas — der Wert kam zerteilt oder gar nicht zurueck.
486
+ * Still, wie immer bei dieser Klasse. graphcodes Fork hatte genau dafuer bereits
487
+ * `UNSAFE_ATTR_RE`; die Regel wandert hierher, wo der einzige Codec steht.
488
+ *
489
+ * Schluessel aufsteigend nach Code-Einheiten, nicht `localeCompare` — dieselbe Begruendung
490
+ * wie bei `serializeEdges`: eine Sortierung, die von der Locale abhaengt, ist nicht
491
+ * deterministisch.
492
+ */
493
+ nodeAttrParts(attrs) {
494
+ const entries = Object.entries(attrs)
495
+ .filter(([, v]) => v != null && v !== '')
496
+ .sort(([a], [b]) => cmp(a, b))
497
+ .map(([k, v]) => [k, typeof v === 'object' ? JSON.stringify(v) : String(v)]);
498
+ const safe = entries.filter(([, v]) => !UNSAFE_INLINE_RE.test(v));
499
+ const unsafe = entries.filter(([, v]) => UNSAFE_INLINE_RE.test(v));
500
+ return {
501
+ inline: safe.length > 0 ? ` [${safe.map(([k, v]) => `${k}:${v}`).join(',')}]` : '',
502
+ follow: unsafe.map(([k, v]) => `@${k} ${v}`),
503
+ };
404
504
  }
405
- /** CR-GC-334: object/array attributes as `@key {json}` lines below the node entry. */
406
- structuredAttrLines(attrs) {
407
- return Object.entries(attrs)
408
- .filter(([, v]) => v != null && typeof v === 'object')
409
- .map(([k, v]) => `@${k} ${JSON.stringify(v)}`);
505
+ /**
506
+ * CR-SM-332 (aus graphcodes Fork uebernommen, CR-GC-200/CR-GC-531): Knotentypen gegen die
507
+ * Ontologie, Kantentypen gegen die Ontologie, doppelte uids, aufloesbare Endpunkte.
508
+ * Paar-Legalitaet gehoert NICHT hierher das ist R-18, dort wo Daten in den Speicher
509
+ * gehen.
510
+ */
511
+ validate(graph, resolveType) {
512
+ const errors = [];
513
+ const declaredTypes = new Map(graph.nodes.map(n => [n.uid, n.type]));
514
+ const typeOf = (uid) => declaredTypes.get(uid) ?? resolveType?.(uid);
515
+ // Doppelte uids: die Map oben dedupliziert still, zwei Knoten mit derselben uid faellen
516
+ // sonst zu einem zusammen und die Kollision bleibt ungesehen (CR-GC-200).
517
+ const counts = new Map();
518
+ for (const node of graph.nodes)
519
+ counts.set(node.uid, (counts.get(node.uid) ?? 0) + 1);
520
+ for (const [uid, count] of counts) {
521
+ if (count > 1)
522
+ errors.push(`Duplicate node uid "${uid}" (${count} nodes share it)`);
523
+ }
524
+ for (const node of graph.nodes) {
525
+ if (!this.validNodeTypes.has(node.type)) {
526
+ errors.push(`Unknown node type "${node.type}" for node "${node.uid}"`);
527
+ }
528
+ }
529
+ for (const edge of graph.edges) {
530
+ if (!this.ontology.edgeTypes[edge.edgeType]) {
531
+ errors.push(`Unknown edge type "${edge.edgeType}" for edge "${edge.sourceId}" → "${edge.targetId}"`);
532
+ continue;
533
+ }
534
+ if (!typeOf(edge.sourceId)) {
535
+ errors.push(`Edge references unknown source node "${edge.sourceId}"`);
536
+ continue;
537
+ }
538
+ if (!typeOf(edge.targetId)) {
539
+ errors.push(`Edge references unknown target node "${edge.targetId}"`);
540
+ }
541
+ }
542
+ return { valid: errors.length === 0, errors };
410
543
  }
411
544
  /** Inline block for an EDGE — structured values stringified, see `serializeAttrs`. */
412
545
  serializeEdgeAttrs(attrs) {
@@ -8,6 +8,7 @@ import { isValidTrace, tracePatternsOf } from './types.js';
8
8
  import { FormatECodec } from './format-e-codec.js';
9
9
  import { DefaultRuleEngine } from './rule-engine.js';
10
10
  import { InMemoryAuditLog } from './audit.js';
11
+ import { normalizeReqKinds } from '@sigloch/contracts/se';
11
12
  import { updateEdge, mergeNodes } from './edge-ops.js';
12
13
  /**
13
14
  * CR-SM-216: a display name for a typed node, derived from the uid's *shape* only.
@@ -199,6 +200,7 @@ export class GraphService {
199
200
  sourceId: op.sourceId,
200
201
  targetId: op.targetId,
201
202
  edgeType: op.edgeType,
203
+ label: op.attributes?.label,
202
204
  });
203
205
  const edge = {
204
206
  sourceId: op.sourceId,
@@ -249,6 +251,7 @@ export class GraphService {
249
251
  sourceId: op.sourceId,
250
252
  targetId: op.targetId,
251
253
  edgeType: op.edgeType,
254
+ label: op.attributes?.label,
252
255
  });
253
256
  const edge = {
254
257
  sourceId: op.sourceId,
@@ -299,6 +302,7 @@ export class GraphService {
299
302
  const { removed, added } = updateEdge(graph, { sourceId: op.sourceId, targetId: op.targetId, edgeType: op.edgeType }, op.set);
300
303
  await this.validateAndApplyEdge({
301
304
  sourceId: added.sourceId, targetId: added.targetId, edgeType: added.edgeType,
305
+ label: added.attributes?.label,
302
306
  });
303
307
  await this.storage.deleteEdges([{
304
308
  sourceId: removed.sourceId, targetId: removed.targetId, edgeType: removed.edgeType,
@@ -337,6 +341,7 @@ export class GraphService {
337
341
  for (const edge of addedEdges) {
338
342
  await this.validateAndApplyEdge({
339
343
  sourceId: edge.sourceId, targetId: edge.targetId, edgeType: edge.edgeType,
344
+ label: edge.attributes?.label,
340
345
  });
341
346
  }
342
347
  if (removedEdges.length > 0) {
@@ -485,10 +490,10 @@ export class GraphService {
485
490
  // Hoist top-level fields
486
491
  if (element.attributes) {
487
492
  if (element.attributes.kinds != null) {
488
- const raw = String(element.attributes.kinds);
489
- element.kinds = raw.includes(',')
490
- ? raw.split(',').map((s) => s.trim())
491
- : [raw.trim()];
493
+ // CR-SM-332: `normalizeReqKinds` statt einer zweiten, handgeschriebenen Fassung
494
+ // (`String(raw).includes(',') ? split : [raw]`). Dieselbe Regel, ein Ort — die
495
+ // Funktion liegt bei den Schemata, die sie fuettert.
496
+ element.kinds = [...normalizeReqKinds(element.attributes.kinds)];
492
497
  delete element.attributes.kinds;
493
498
  }
494
499
  if (element.attributes.asil != null) {
@@ -530,10 +535,8 @@ export class GraphService {
530
535
  // Hoist top-level fields
531
536
  if (element.attributes) {
532
537
  if (element.attributes.kinds != null) {
533
- const raw = String(element.attributes.kinds);
534
- element.kinds = raw.includes(',')
535
- ? raw.split(',').map((s) => s.trim())
536
- : [raw.trim()];
538
+ // CR-SM-332: dieselbe eine Regel wie im Anlege-Pfad oben.
539
+ element.kinds = [...normalizeReqKinds(element.attributes.kinds)];
537
540
  delete element.attributes.kinds;
538
541
  }
539
542
  if (element.attributes.asil != null) {
@@ -565,9 +568,11 @@ export class GraphService {
565
568
  };
566
569
  }
567
570
  // CR-GC-247: one legality path for every ontology (SE + foreign). The edge type
568
- // must be declared (menu enumeration via edgeTypes), then the (source,target,type)
569
- // trace is checked against descriptor.patterns via the single isValidTrace —
570
- // no SE/foreign fork, no validPairs re-implementation.
571
+ // must be declared (menu enumeration via edgeTypes), then the trace is checked against
572
+ // descriptor.patterns. CR-SM-323: that checker delegates to contracts' isValidTrace — the
573
+ // routine R-18 runs fed label and endpoint kinds exactly as R-18 reads them. A foreign
574
+ // ontology's patterns (derived from validPairs) carry neither label nor where, so there
575
+ // the type pair alone decides.
571
576
  async validateAndApplyEdge(op) {
572
577
  if (!this.ontology.edgeTypes[op.edgeType]) {
573
578
  const known = Object.keys(this.ontology.edgeTypes).join(', ');
@@ -575,7 +580,14 @@ export class GraphService {
575
580
  }
576
581
  const src = await this.storage.getNode(op.sourceId);
577
582
  const tgt = await this.storage.getNode(op.targetId);
578
- if (src && tgt && !isValidTrace({ source: src.type, target: tgt.type, type: op.edgeType }, this.patterns)) {
583
+ if (src && tgt && !isValidTrace({
584
+ source: src.type,
585
+ target: tgt.type,
586
+ type: op.edgeType,
587
+ label: op.label,
588
+ sourceKinds: src.attributes?.kinds,
589
+ targetKinds: tgt.attributes?.kinds,
590
+ }, this.patterns)) {
579
591
  const allowed = this.patterns
580
592
  .filter(p => p.type === op.edgeType)
581
593
  .map(p => `${p.source}->${p.target}`)
@@ -12,7 +12,27 @@
12
12
  * Cypher) does not break consumers.
13
13
  */
14
14
  import { generateSchema } from './schema-generator.js';
15
- async function loadKuzu() {
15
+ /**
16
+ * Prozessweit genau EINE kuzu-Instanz (CR-SM-316).
17
+ *
18
+ * `kuzu.init()` ist nicht idempotent: jeder Aufruf legt eine NEUE Emscripten-Instanz
19
+ * mit eigenem Heap an (~186 MB) und haengt die alte ab. Zurueck gibt die niemand —
20
+ * WASM-Speicher schrumpft nicht, und weder `db.close()` noch der GC raeumen ihn ab.
21
+ * Vorher rief jedes `GraphCypherEngine.init()` erneut auf; im graphcode-Volllauf waren
22
+ * das 544 Aufrufe und 9641 MB Spitzenverbrauch, bis der Heap mit "memory access out of
23
+ * bounds" faultete und dabei traf, was gerade lief — in drei Laeufen drei verschiedene
24
+ * Testdateien.
25
+ *
26
+ * Der Cache haelt das PROMISE, nicht das Ergebnis: zwei Engines, die gleichzeitig
27
+ * initialisieren, warten damit auf denselben Ladevorgang statt zwei zu starten.
28
+ */
29
+ let kuzuModule = null;
30
+ function loadKuzu() {
31
+ if (!kuzuModule)
32
+ kuzuModule = initKuzu();
33
+ return kuzuModule;
34
+ }
35
+ async function initKuzu() {
16
36
  // Node path: sync nodejs variant. Detect Node by checking for `process.versions.node`.
17
37
  const isNode = typeof process !== 'undefined' && !!process.versions?.node;
18
38
  if (isNode) {
Binary file
@@ -9,7 +9,7 @@
9
9
  * - Graph (nodes/edges) ⟷ OntologyGraph (elements/traces)
10
10
  * - RuleViolation (ruleId/…) ⟷ contracts RuleViolation (rule_id/…)
11
11
  */
12
- import { ElementType, TraceType, TRACE_PATTERNS, V3_RULES, MT_RULES, UC_RULES, FC_RULES, SC_RULES, CR_RULES, AO_RULES, FM_RULES, VIEW_RULES, AF_RULES, ONTOLOGY_VERSION, DEFAULT_METRIC_POLICY, } from '@sigloch/contracts/se';
12
+ import { ElementType, TraceType, TRACE_PATTERNS, V3_RULES, MT_RULES, UC_RULES, FC_RULES, SC_RULES, CR_RULES, AO_RULES, FM_RULES, VIEW_RULES, AF_RULES, ONTOLOGY_VERSION, DEFAULT_METRIC_POLICY, normalizeReqKinds, } from '@sigloch/contracts/se';
13
13
  /**
14
14
  * Project an ontology-agnostic Graph (nodes/edges) onto the SE OntologyGraph
15
15
  * (elements/traces) that contracts/se rules evaluate against. Type-specific
@@ -32,7 +32,15 @@ export function projectToOntologyGraph(graph) {
32
32
  created_at: n.createdAt ?? '',
33
33
  updated_at: n.updatedAt,
34
34
  method: n.attributes?.method,
35
- kinds: n.attributes?.kinds,
35
+ // CR-SM-332 (ITEM-2026-183): `kinds` wird NORMALISIERT, nicht gecastet. Der Cast war die
36
+ // dritte Fassung derselben Wahrheit: der (entfernte) contracts-Parser normalisierte beim
37
+ // Lesen, GraphService.hoist tut es von Hand, und HIER kam ein ueber Format-E eingetragenes
38
+ // `@kinds non-functional` als roher STRING bei den Regeln an, wo eine Liste erwartet wird —
39
+ // CR-SM-320 auf diesem Pfad wieder offen. `undefined` bleibt `undefined`: eine leere Liste
40
+ // ist eine andere Aussage als "nicht gesetzt".
41
+ kinds: n.attributes?.kinds === undefined
42
+ ? undefined
43
+ : normalizeReqKinds(n.attributes.kinds),
36
44
  attributes: n.attributes,
37
45
  }));
38
46
  const traces = graph.edges.map((e) => ({
@@ -133,8 +141,14 @@ function liftAttributes(rest) {
133
141
  * otherwise the 114 pre-existing errors in graphcode's own graph would block every
134
142
  * write on debt the writer did not create. Promotion is a per-family decision, not a
135
143
  * side effect of being registered.
144
+ *
145
+ * CR-SM-309: IO is promoted (decision 2026-09-11). IO-02 ("a FLOW has exactly ONE
146
+ * producer", error) guards the one-FLOW-per-connection cut; without gate power a
147
+ * suggested merge put two producers back into one FLOW and passed the gate. The delta
148
+ * baseline keeps the switch-on safe: only NEW findings block, graphcode's own graph is
149
+ * at IO-02 = 0, and IO-01 shares the prefix but is a warning, which never blocks.
136
150
  */
137
- const GATING_PREFIXES = ['R-', 'RD-', 'MT-'];
151
+ const GATING_PREFIXES = ['R-', 'RD-', 'MT-', 'IO-'];
138
152
  const SE_RULE_DEFS = [
139
153
  ...V3_RULES,
140
154
  ...UC_RULES,
package/dist/types.d.ts CHANGED
@@ -1,7 +1,3 @@
1
- /**
2
- * Core types for graph-api-core — ontology-agnostic.
3
- * Domains register their node/edge types via OntologyDescriptor.
4
- */
5
1
  export interface GraphNode {
6
2
  uid: string;
7
3
  type: string;
@@ -135,21 +131,18 @@ export interface FormatEDiff {
135
131
  errors: string[];
136
132
  }
137
133
  /**
138
- * Structural trace-legality check. Ontology-agnostic: matches a trace against
139
- * TracePattern[] with '*' wildcards on source/target. Both graph-service and
140
- * format-e-codec route every check through this validPairs is no longer a
141
- * parallel re-implementation.
142
- *
143
- * A pattern's `label` is descriptive metadata (e.g. MS→MS relation 'depends-on'),
144
- * NOT a gate: label is trace data that isn't uniformly carried through the
145
- * Graph/codec pipeline, and the legality this replaces (validPairs) never keyed on
146
- * it. Enforcing label is a deliberate tightening (needs codec label round-tripping),
147
- * out of scope for the centralization.
134
+ * Trace legality for any ontology, decided by contracts' `isValidTrace` — the routine R-18
135
+ * runs. No logic of its own: `label` and endpoint `kinds` are part of the rule exactly as
136
+ * R-18 reads them. This signature only opens the SE-closed contracts types to the
137
+ * ontology-agnostic strings this package speaks; the runtime pattern shape is the same.
148
138
  */
149
139
  export declare function isValidTrace(trace: {
150
140
  source: string;
151
141
  target: string;
152
142
  type: string;
143
+ label?: string;
144
+ sourceKinds?: readonly string[];
145
+ targetKinds?: readonly string[];
153
146
  }, patterns: TracePattern[]): boolean;
154
147
  /**
155
148
  * The patterns a descriptor validates against: explicit `patterns` (SSOT) if set,
package/dist/types.js CHANGED
@@ -2,25 +2,18 @@
2
2
  * Core types for graph-api-core — ontology-agnostic.
3
3
  * Domains register their node/edge types via OntologyDescriptor.
4
4
  */
5
+ import { isValidTrace as contractsIsValidTrace } from '@sigloch/contracts/se';
5
6
  // ---------------------------------------------------------------------------
6
- // Trace legality — the ONE checker (CR-GC-247)
7
+ // Trace legality — ONE rule (CR-GC-247, CR-SM-323)
7
8
  // ---------------------------------------------------------------------------
8
9
  /**
9
- * Structural trace-legality check. Ontology-agnostic: matches a trace against
10
- * TracePattern[] with '*' wildcards on source/target. Both graph-service and
11
- * format-e-codec route every check through this validPairs is no longer a
12
- * parallel re-implementation.
13
- *
14
- * A pattern's `label` is descriptive metadata (e.g. MS→MS relation 'depends-on'),
15
- * NOT a gate: label is trace data that isn't uniformly carried through the
16
- * Graph/codec pipeline, and the legality this replaces (validPairs) never keyed on
17
- * it. Enforcing label is a deliberate tightening (needs codec label round-tripping),
18
- * out of scope for the centralization.
10
+ * Trace legality for any ontology, decided by contracts' `isValidTrace` — the routine R-18
11
+ * runs. No logic of its own: `label` and endpoint `kinds` are part of the rule exactly as
12
+ * R-18 reads them. This signature only opens the SE-closed contracts types to the
13
+ * ontology-agnostic strings this package speaks; the runtime pattern shape is the same.
19
14
  */
20
15
  export function isValidTrace(trace, patterns) {
21
- return patterns.some((p) => (p.source === '*' || p.source === trace.source) &&
22
- (p.target === '*' || p.target === trace.target) &&
23
- p.type === trace.type);
16
+ return contractsIsValidTrace(trace, patterns);
24
17
  }
25
18
  /**
26
19
  * The patterns a descriptor validates against: explicit `patterns` (SSOT) if set,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sigloch/graph-api-core",
3
- "version": "5.4.0",
3
+ "version": "5.6.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",