@sigloch/contracts 0.8.0 → 2.0.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.
@@ -0,0 +1,59 @@
1
+ /**
2
+ * ElementUid — the family's canonical element id form (CR-SM-217).
3
+ *
4
+ * <TYPE>-<slug> ACTOR-claude-code · REQ-safety · MOD-harness
5
+ *
6
+ * Decided on 2026-07-27 over the previous `Name.TypeAbbr.Counter` schema, in this order:
7
+ *
8
+ * 1. **Token-neutral.** Measured on the graphcode SSOT graph (1871 uid occurrences,
9
+ * cl100k_base): `ACTOR-claude-code.ACTOR` 5.28 tok/uid vs `claudecode.ACTOR.001`
10
+ * 5.30 — 0.3 % apart. The spelling costs nothing, so it is decided on correctness.
11
+ * 2. **It is what the real graphs use.** 626 of 1145 family elements are already
12
+ * `TYPE-slug`; the schema's declared canon had 89.
13
+ * 3. **It keeps a free type cross-check.** CR-SM-216 moved the type into the `### <TYPE>`
14
+ * section, which removes the "type appears twice" redundancy that used to catch a
15
+ * mis-sectioned node. A `TYPE-slug` uid carries the type in its prefix without paying
16
+ * for it (`REQ-safety` = 3 tokens, `REQ-safety.REQ` = 5), so the check survives.
17
+ *
18
+ * `parseElementUid` exists **only** for that cross-check. It is never a type source —
19
+ * the type comes from the graph or from the Format-E section (CR-SM-216 contract).
20
+ *
21
+ * **Not every id is an ElementUid, and that is allowed.** Ids that mirror an external
22
+ * identifier live in their own namespace and are simply unchecked, never rejected:
23
+ * CR elements carry their change-request number (`CR-GC-100`, 88 in graphcode, 123 in
24
+ * graph-view-edit) and graphify's pre-gate candidates are `cand_<hex>`. The canon binds
25
+ * *new* ids minted through `toElementUid`; existing ids stay until their repo touches
26
+ * them.
27
+ */
28
+ import { z } from 'zod/v4';
29
+ import { ElementType } from './ontology.js';
30
+ /** Longest slug we accept; keeps ids readable and index-friendly. */
31
+ export declare const MAX_SLUG_LENGTH = 60;
32
+ export declare const ElementUid: z.ZodString;
33
+ export type ElementUid = z.infer<typeof ElementUid>;
34
+ /**
35
+ * Build a canonical uid. Pure and deterministic — the same (type, name) always yields
36
+ * the same uid.
37
+ *
38
+ * Collisions are the **caller's** problem: only the caller knows the namespace, so
39
+ * disambiguation (`-2`, `-3`) belongs there. Documented rather than hidden behind a
40
+ * counter that would make this function stateful.
41
+ */
42
+ export declare function toElementUid(type: ElementType, name: string): string;
43
+ /**
44
+ * Split a uid into its parts. Throws when the prefix is not an ElementType.
45
+ *
46
+ * ⚠️ For the prefix↔section cross-check only — **never** as a type source. Deriving a
47
+ * type from an id's spelling is what made aimpro CR-230 drop an entire graph silently.
48
+ */
49
+ export declare function parseElementUid(uid: string): {
50
+ type: ElementType;
51
+ slug: string;
52
+ };
53
+ /** Non-throwing variant: `undefined` for ids of any other convention (legacy, graphify candidates). */
54
+ export declare function tryParseElementUid(uid: string): {
55
+ type: ElementType;
56
+ slug: string;
57
+ } | undefined;
58
+ /** True when `uid` is a canonical ElementUid. */
59
+ export declare function isElementUid(uid: string): boolean;
@@ -0,0 +1,74 @@
1
+ /**
2
+ * ElementUid — the family's canonical element id form (CR-SM-217).
3
+ *
4
+ * <TYPE>-<slug> ACTOR-claude-code · REQ-safety · MOD-harness
5
+ *
6
+ * Decided on 2026-07-27 over the previous `Name.TypeAbbr.Counter` schema, in this order:
7
+ *
8
+ * 1. **Token-neutral.** Measured on the graphcode SSOT graph (1871 uid occurrences,
9
+ * cl100k_base): `ACTOR-claude-code.ACTOR` 5.28 tok/uid vs `claudecode.ACTOR.001`
10
+ * 5.30 — 0.3 % apart. The spelling costs nothing, so it is decided on correctness.
11
+ * 2. **It is what the real graphs use.** 626 of 1145 family elements are already
12
+ * `TYPE-slug`; the schema's declared canon had 89.
13
+ * 3. **It keeps a free type cross-check.** CR-SM-216 moved the type into the `### <TYPE>`
14
+ * section, which removes the "type appears twice" redundancy that used to catch a
15
+ * mis-sectioned node. A `TYPE-slug` uid carries the type in its prefix without paying
16
+ * for it (`REQ-safety` = 3 tokens, `REQ-safety.REQ` = 5), so the check survives.
17
+ *
18
+ * `parseElementUid` exists **only** for that cross-check. It is never a type source —
19
+ * the type comes from the graph or from the Format-E section (CR-SM-216 contract).
20
+ *
21
+ * **Not every id is an ElementUid, and that is allowed.** Ids that mirror an external
22
+ * identifier live in their own namespace and are simply unchecked, never rejected:
23
+ * CR elements carry their change-request number (`CR-GC-100`, 88 in graphcode, 123 in
24
+ * graph-view-edit) and graphify's pre-gate candidates are `cand_<hex>`. The canon binds
25
+ * *new* ids minted through `toElementUid`; existing ids stay until their repo touches
26
+ * them.
27
+ */
28
+ import { z } from 'zod/v4';
29
+ import { ElementType } from './ontology.js';
30
+ /** Longest slug we accept; keeps ids readable and index-friendly. */
31
+ export const MAX_SLUG_LENGTH = 60;
32
+ /** Built from the live ElementType enum — there is no second type catalogue. */
33
+ const UID_RE = new RegExp(`^(${ElementType.options.join('|')})-([a-z0-9]+(?:-[a-z0-9]+)*)$`);
34
+ export const ElementUid = z.string()
35
+ .regex(UID_RE, 'ElementUid must match <TYPE>-<kebab-slug>')
36
+ .refine(uid => (uid.split('-').slice(1).join('-')).length <= MAX_SLUG_LENGTH, `slug must be at most ${MAX_SLUG_LENGTH} characters`);
37
+ /**
38
+ * Build a canonical uid. Pure and deterministic — the same (type, name) always yields
39
+ * the same uid.
40
+ *
41
+ * Collisions are the **caller's** problem: only the caller knows the namespace, so
42
+ * disambiguation (`-2`, `-3`) belongs there. Documented rather than hidden behind a
43
+ * counter that would make this function stateful.
44
+ */
45
+ export function toElementUid(type, name) {
46
+ const slug = name
47
+ .toLowerCase()
48
+ .replace(/[^a-z0-9]+/g, '-')
49
+ .replace(/^-+|-+$/g, '')
50
+ .slice(0, MAX_SLUG_LENGTH)
51
+ .replace(/-+$/, '');
52
+ return `${type}-${slug}`;
53
+ }
54
+ /**
55
+ * Split a uid into its parts. Throws when the prefix is not an ElementType.
56
+ *
57
+ * ⚠️ For the prefix↔section cross-check only — **never** as a type source. Deriving a
58
+ * type from an id's spelling is what made aimpro CR-230 drop an entire graph silently.
59
+ */
60
+ export function parseElementUid(uid) {
61
+ const m = UID_RE.exec(uid);
62
+ if (!m)
63
+ throw new Error(`Not an ElementUid: "${uid}" — expected <TYPE>-<kebab-slug>`);
64
+ return { type: m[1], slug: m[2] };
65
+ }
66
+ /** Non-throwing variant: `undefined` for ids of any other convention (legacy, graphify candidates). */
67
+ export function tryParseElementUid(uid) {
68
+ const m = UID_RE.exec(uid);
69
+ return m ? { type: m[1], slug: m[2] } : undefined;
70
+ }
71
+ /** True when `uid` is a canonical ElementUid. */
72
+ export function isElementUid(uid) {
73
+ return ElementUid.safeParse(uid).success;
74
+ }
@@ -23,7 +23,9 @@ export const ALL_RULE_DEFS = [
23
23
  ...FM_RULES.map(r => ({ id: r.id, name: r.name, severity: r.severity })),
24
24
  ...VIEW_RULES.map(r => ({ id: r.id, name: r.name, severity: r.severity })),
25
25
  ];
26
- const SE_PREFIXES = ['R-', 'UC-', 'FC-', 'SC-', 'MT-', 'CR-', 'AO-', 'FM-', 'NFR-', 'RT-', 'PH-', 'CA-', 'IO-', 'VR-', 'CL-'];
26
+ // CR-SM-221: 'RD-' was missing 'RD-01'.startsWith('R-') is false, so the
27
+ // decomposition rules ran in `default` only and never in the `se` profile.
28
+ const SE_PREFIXES = ['R-', 'RD-', 'UC-', 'FC-', 'SC-', 'MT-', 'CR-', 'AO-', 'FM-', 'NFR-', 'RT-', 'PH-', 'CA-', 'IO-', 'VR-', 'CL-'];
27
29
  const CODING_PREFIXES = ['BQ-', 'ND-'];
28
30
  export function getRuleDefsForProfile(profile) {
29
31
  if (profile === 'se')
@@ -1,17 +1,33 @@
1
1
  /**
2
2
  * Format E Parser — Graph mutations from compact text format.
3
3
  * SE-relevant subset: nodes (+/-/~) and edges (+/-), no chat-canvas or views.
4
- * ~150 LOC, no external dependencies beyond @sigloch/contracts/se.
4
+ *
5
+ * CR-SM-216 (Format-E v2): the element type comes from the `### <TYPE>` section a node
6
+ * is declared under, never from the spelling of its id. The old id-derived typing made
7
+ * every consumer with a different id convention fail *silently* — aimpro CR-230 lost a
8
+ * whole graph that way (`TYPE-slug` ids rejected, result empty, no error).
5
9
  *
6
10
  * @sigloch/contracts/se
7
11
  */
12
+ import { ElementType } from './ontology.js';
8
13
  import type { TraceType, OntologyGraph } from './ontology.js';
9
14
  export interface FormatEOperation {
10
15
  type: 'add_node' | 'remove_node' | 'update_node' | 'add_edge' | 'remove_edge' | 'strict_add_node' | 'strict_add_edge';
11
16
  semanticId: string;
17
+ /**
18
+ * CR-SM-216: the element type, taken from the node's `### <TYPE>` section. Set on
19
+ * every node-creating operation — consumers must read it instead of re-deriving a
20
+ * type from the id.
21
+ */
22
+ elementType?: ElementType;
12
23
  description?: string;
13
- /** CR-147: Parsed @key value attributes from lines below the node entry. */
14
- attributes?: Record<string, string>;
24
+ /**
25
+ * CR-147: Parsed @key value attributes from lines below the node entry.
26
+ * Values are strings, EXCEPT JSON object/array literals which are hydrated
27
+ * (BOK-CR-026) — object-valued bindings like `realRef`/`testRef` must reach
28
+ * `attributes` as objects or R-26/R-19 reject them as invalid.
29
+ */
30
+ attributes?: Record<string, unknown>;
15
31
  sourceId?: string;
16
32
  targetId?: string;
17
33
  traceType?: TraceType;
@@ -22,7 +38,23 @@ export interface FormatEDiff {
22
38
  }
23
39
  /** Extract a ```format-e block from LLM output. Returns null if not found. */
24
40
  export declare function extractFormatE(llmOutput: string): string | null;
41
+ export interface ParseFormatEOptions {
42
+ /**
43
+ * CR-SM-216: resolve the type of a uid that this text does not declare. A mutation
44
+ * diff adding edges between existing nodes carries no `## Nodes` block, so the
45
+ * caller binds this to its store. Without it such a diff is an error, never a
46
+ * silent skip.
47
+ */
48
+ resolveType?: (uid: string) => ElementType | undefined;
49
+ }
25
50
  /** Parse a Format E text block into validated operations. */
26
- export declare function parseFormatE(input: string): FormatEDiff;
27
- /** Serialize an OntologyGraph to compact Format E text. */
51
+ export declare function parseFormatE(input: string, options?: ParseFormatEOptions): FormatEDiff;
52
+ /**
53
+ * Serialize an OntologyGraph to compact Format E text.
54
+ *
55
+ * CR-SM-216: nodes are written under `### <TYPE>` sections — the type is declared once
56
+ * per section instead of once per id. Measured on the graphcode SSOT graph (369
57
+ * elements), 12 section headers cost ~48 tokens where a per-node type attribute would
58
+ * have cost ~1476.
59
+ */
28
60
  export declare function serializeToFormatE(graph: OntologyGraph): string;
@@ -1,5 +1,16 @@
1
+ /**
2
+ * Format E Parser — Graph mutations from compact text format.
3
+ * SE-relevant subset: nodes (+/-/~) and edges (+/-), no chat-canvas or views.
4
+ *
5
+ * CR-SM-216 (Format-E v2): the element type comes from the `### <TYPE>` section a node
6
+ * is declared under, never from the spelling of its id. The old id-derived typing made
7
+ * every consumer with a different id convention fail *silently* — aimpro CR-230 lost a
8
+ * whole graph that way (`TYPE-slug` ids rejected, result empty, no error).
9
+ *
10
+ * @sigloch/contracts/se
11
+ */
12
+ import { ElementType } from './ontology.js';
1
13
  import { isValidTrace } from './meta-model.js';
2
- import { isSemanticId, extractFromSemanticId } from './semantic-id.js';
3
14
  // ---------------------------------------------------------------------------
4
15
  // Extraction
5
16
  // ---------------------------------------------------------------------------
@@ -21,19 +32,48 @@ const OP_PREFIX = {
21
32
  '~': 'update',
22
33
  '!': 'strict_add',
23
34
  };
24
- const EDGE_RE = /^([+\-~!])?\s*(\S+)\s+-(\w+)->\s+(\S+)\s*$/;
35
+ /**
36
+ * CR-SM-215: the target group is `(.+)` — Format-E allows fan-out
37
+ * `A -x-> B, C, D`, one edge per target. `graph-api-core`'s codec has always parsed
38
+ * it; this parser rejected it as `Invalid edge syntax`, so the same text produced
39
+ * different operations depending on which parser saw it.
40
+ */
41
+ const EDGE_RE = /^([+\-~!])?\s*(\S+)\s+-(\w+)->\s+(.+?)\s*$/;
25
42
  const NODE_RE = /^([+\-~!])?\s*(\S+?)(?:\|(.*))?$/;
26
43
  /** CR-147: @key value attribute line (indented, below a node entry). */
27
44
  const ATTR_RE = /^\s*@(\w+)\s+(.+)$/;
45
+ /**
46
+ * BOK-CR-026: hydrate a JSON object/array attribute value. The ontology's bindings
47
+ * (`realRef {file,symbol?,lang?}`, `testRef {file,tool,…}`) are objects; kept as raw
48
+ * strings they fail RealRefSchema/TestRefSchema and the element reads as unbound.
49
+ * Only `{…}`/`[…]` are attempted — every other value stays the string it is, and a
50
+ * malformed literal falls back to the string rather than failing the whole parse.
51
+ */
52
+ function hydrateAttrValue(raw) {
53
+ if (!/^[{[]/.test(raw))
54
+ return raw;
55
+ try {
56
+ return JSON.parse(raw);
57
+ }
58
+ catch {
59
+ return raw;
60
+ }
61
+ }
28
62
  /** CR-148: Trace-type normalization aliases (source→target→from→to). */
29
63
  const TRACE_NORMALIZE = {
30
64
  FLOW: { SCHEMA: 'relation' }, // FLOW→SCHEMA io → relation
31
65
  };
66
+ /** `### <TYPE>` — the node type section (CR-SM-216). */
67
+ const TYPE_SECTION_RE = /^###\s+([A-Za-z_]+)\s*$/;
32
68
  /** Parse a Format E text block into validated operations. */
33
- export function parseFormatE(input) {
69
+ export function parseFormatE(input, options = {}) {
34
70
  const operations = [];
35
71
  const errors = [];
36
72
  let section = null;
73
+ let currentType = null;
74
+ /** uid → type, from this text's node sections. */
75
+ const declared = new Map();
76
+ const typeOf = (uid) => declared.get(uid) ?? options.resolveType?.(uid);
37
77
  for (const rawLine of input.split('\n')) {
38
78
  const line = rawLine.trim();
39
79
  if (!line || line.startsWith('//') || line.startsWith('#!'))
@@ -45,7 +85,7 @@ export function parseFormatE(input) {
45
85
  if (lastOp && (lastOp.type === 'add_node' || lastOp.type === 'update_node' || lastOp.type === 'strict_add_node')) {
46
86
  if (!lastOp.attributes)
47
87
  lastOp.attributes = {};
48
- lastOp.attributes[attrMatch[1]] = attrMatch[2].trim();
88
+ lastOp.attributes[attrMatch[1]] = hydrateAttrValue(attrMatch[2].trim());
49
89
  }
50
90
  else {
51
91
  errors.push(`@attribute line without preceding node: "${line}"`);
@@ -55,49 +95,64 @@ export function parseFormatE(input) {
55
95
  // Section headers
56
96
  if (/^##\s*nodes?\s*$/i.test(line)) {
57
97
  section = 'nodes';
98
+ currentType = null;
58
99
  continue;
59
100
  }
60
101
  if (/^##\s*edges?\s*$/i.test(line)) {
61
102
  section = 'edges';
103
+ currentType = null;
104
+ continue;
105
+ }
106
+ // CR-SM-216: `### <TYPE>` carries the element type for the nodes below it.
107
+ const typeSection = TYPE_SECTION_RE.exec(line);
108
+ if (typeSection && section === 'nodes') {
109
+ const parsed = ElementType.safeParse(typeSection[1]);
110
+ if (parsed.success) {
111
+ currentType = parsed.data;
112
+ }
113
+ else {
114
+ currentType = null;
115
+ errors.push(`Unknown element type section: "### ${typeSection[1]}"`);
116
+ }
62
117
  continue;
63
118
  }
64
119
  // Skip other markdown headers
65
120
  if (line.startsWith('#'))
66
121
  continue;
67
- // Try edge first (more specific pattern)
68
- const edgeMatch = EDGE_RE.exec(line);
69
- if (edgeMatch || section === 'edges') {
122
+ // Edge: detect by arrow in the structural part only — before the description
123
+ // pipe. Node descriptions may legitimately contain '->' ("FUNC->FUNC compose");
124
+ // edges carry no pipe, so splitting on '|' disambiguates (CR-GC-247). Since
125
+ // CR-SM-215 widened the target group to `(.+?)`, this guard is what keeps a
126
+ // multi-word node description from being read as an edge.
127
+ const isEdgeLine = line.split('|', 1)[0].includes('->');
128
+ if (isEdgeLine || section === 'edges') {
129
+ const edgeMatch = isEdgeLine ? EDGE_RE.exec(line) : null;
70
130
  if (edgeMatch) {
71
- parseEdge(edgeMatch, operations, errors);
131
+ parseEdge(edgeMatch, typeOf, operations, errors);
72
132
  }
73
133
  else {
74
134
  errors.push(`Invalid edge line: "${line}"`);
75
135
  }
76
136
  continue;
77
137
  }
78
- // Try node
138
+ // Node
79
139
  if (section === 'nodes') {
80
140
  const nodeMatch = NODE_RE.exec(line);
81
- if (nodeMatch) {
82
- parseNode(nodeMatch, operations, errors);
141
+ if (!nodeMatch) {
142
+ errors.push(`Invalid node line: "${line}"`);
143
+ }
144
+ else if (!currentType) {
145
+ // CR-SM-216: no type section, no type. Guessing one from the id is what
146
+ // CR-230 punished; an error is the point.
147
+ errors.push(`Node "${nodeMatch[2]}" is not under a "### <TYPE>" section`);
83
148
  }
84
149
  else {
85
- errors.push(`Invalid node line: "${line}"`);
150
+ declared.set(nodeMatch[2], currentType);
151
+ parseNode(nodeMatch, currentType, operations, errors);
86
152
  }
87
153
  continue;
88
154
  }
89
- // Outside a section try to auto-detect
90
- const autoEdge = EDGE_RE.exec(line);
91
- if (autoEdge) {
92
- parseEdge(autoEdge, operations, errors);
93
- continue;
94
- }
95
- const autoNode = NODE_RE.exec(line);
96
- if (autoNode && isSemanticId(autoNode[2])) {
97
- parseNode(autoNode, operations, errors);
98
- continue;
99
- }
100
- // Unknown line
155
+ // Unknown line (edges are handled above, nodes need their section)
101
156
  if (line.length > 0)
102
157
  errors.push(`Unrecognized line: "${line}"`);
103
158
  }
@@ -111,60 +166,40 @@ function normalizeTraceType(srcType, tgtType, traceType) {
111
166
  }
112
167
  return traceType;
113
168
  }
114
- function parseNode(m, ops, errors) {
169
+ function parseNode(m, elementType, ops, errors) {
115
170
  const opChar = m[1] || '+';
116
171
  const id = m[2];
117
172
  const descr = m[3]?.trim();
118
173
  const action = OP_PREFIX[opChar] ?? 'add';
119
- if (!isSemanticId(id)) {
120
- errors.push(`Invalid SemanticId: "${id}"`);
174
+ if (!id) {
175
+ errors.push(`Node line without an id: "${m[0]}"`);
121
176
  return;
122
177
  }
123
178
  if (action === 'remove') {
124
179
  ops.push({ type: 'remove_node', semanticId: id });
125
180
  }
126
181
  else if (action === 'update') {
127
- ops.push({ type: 'update_node', semanticId: id, description: descr });
182
+ ops.push({ type: 'update_node', semanticId: id, elementType, description: descr });
128
183
  }
129
184
  else if (action === 'strict_add') {
130
- ops.push({ type: 'strict_add_node', semanticId: id, description: descr });
185
+ ops.push({ type: 'strict_add_node', semanticId: id, elementType, description: descr });
131
186
  }
132
187
  else {
133
- ops.push({ type: 'add_node', semanticId: id, description: descr });
188
+ ops.push({ type: 'add_node', semanticId: id, elementType, description: descr });
134
189
  }
135
190
  }
136
- function parseEdge(m, ops, errors) {
191
+ function parseEdge(m, typeOf, ops, errors) {
137
192
  const opChar = m[1] || '+';
138
193
  const sourceId = m[2];
139
194
  const traceType = m[3];
140
- const targetId = m[4];
141
195
  const action = OP_PREFIX[opChar] ?? 'add';
142
- if (!isSemanticId(sourceId)) {
143
- errors.push(`Invalid source SemanticId: "${sourceId}"`);
144
- return;
145
- }
146
- if (!isSemanticId(targetId)) {
147
- errors.push(`Invalid target SemanticId: "${targetId}"`);
148
- return;
149
- }
150
196
  if (!VALID_TRACE_TYPES.has(traceType)) {
151
197
  errors.push(`Invalid trace type: "${traceType}"`);
152
198
  return;
153
199
  }
154
- // Meta-model validation (with CR-148 normalization)
155
- let resolvedTraceType = traceType;
156
- try {
157
- const src = extractFromSemanticId(sourceId);
158
- const tgt = extractFromSemanticId(targetId);
159
- // CR-148: Normalize before meta-model check (e.g. FLOW→SCHEMA io → relation)
160
- resolvedTraceType = normalizeTraceType(src.type, tgt.type, traceType);
161
- if (!isValidTrace({ source: src.type, target: tgt.type, type: resolvedTraceType })) {
162
- errors.push(`Meta-model violation: ${src.type} -${traceType}-> ${tgt.type} is not valid`);
163
- return;
164
- }
165
- }
166
- catch {
167
- errors.push(`Cannot extract types from edge: ${sourceId} -${traceType}-> ${targetId}`);
200
+ const srcType = typeOf(sourceId);
201
+ if (!srcType) {
202
+ errors.push(`Cannot resolve type of "${sourceId}" — not declared under a "### <TYPE>" section and no resolveType provided`);
168
203
  return;
169
204
  }
170
205
  const edgeType = action === 'remove'
@@ -172,33 +207,71 @@ function parseEdge(m, ops, errors) {
172
207
  : action === 'strict_add'
173
208
  ? 'strict_add_edge'
174
209
  : 'add_edge';
175
- ops.push({
176
- type: edgeType,
177
- semanticId: `${sourceId}->${targetId}`,
178
- sourceId,
179
- targetId,
180
- traceType: resolvedTraceType,
181
- });
210
+ // CR-SM-215: 1:n fan-out — `A -x-> B, C` is n independent edges. Validation runs
211
+ // per target (like `graph-api-core`'s codec), so one bad target does not discard
212
+ // its siblings.
213
+ const targets = m[4].split(',').map(t => t.trim()).filter(Boolean);
214
+ for (const targetId of targets) {
215
+ const tgtType = typeOf(targetId);
216
+ if (!tgtType) {
217
+ errors.push(`Cannot resolve type of "${targetId}" — not declared under a "### <TYPE>" section and no resolveType provided`);
218
+ continue;
219
+ }
220
+ // CR-148: Normalize before the meta-model check (e.g. FLOW→SCHEMA io → relation)
221
+ const resolvedTraceType = normalizeTraceType(srcType, tgtType, traceType);
222
+ if (!isValidTrace({ source: srcType, target: tgtType, type: resolvedTraceType })) {
223
+ errors.push(`Meta-model violation: ${srcType} -${traceType}-> ${tgtType} is not valid`);
224
+ continue;
225
+ }
226
+ ops.push({
227
+ type: edgeType,
228
+ semanticId: `${sourceId}->${targetId}`,
229
+ sourceId,
230
+ targetId,
231
+ traceType: resolvedTraceType,
232
+ });
233
+ }
182
234
  }
183
235
  // ---------------------------------------------------------------------------
184
236
  // Serializer
185
237
  // ---------------------------------------------------------------------------
186
- /** Serialize an OntologyGraph to compact Format E text. */
238
+ /**
239
+ * Serialize an OntologyGraph to compact Format E text.
240
+ *
241
+ * CR-SM-216: nodes are written under `### <TYPE>` sections — the type is declared once
242
+ * per section instead of once per id. Measured on the graphcode SSOT graph (369
243
+ * elements), 12 section headers cost ~48 tokens where a per-node type attribute would
244
+ * have cost ~1476.
245
+ */
187
246
  export function serializeToFormatE(graph) {
188
247
  const lines = [];
189
- // Nodes
190
- if (graph.elements.length > 0) {
248
+ // Nodes, grouped by type
249
+ const modelingElements = graph.elements.filter(el => el.type !== 'SESSION'); // skip audit sessions
250
+ if (modelingElements.length > 0) {
191
251
  lines.push('## Nodes');
192
- for (const el of graph.elements) {
193
- if (el.type === 'SESSION')
194
- continue; // skip audit sessions
195
- const descr = el.description ? `|${el.description}` : '';
196
- lines.push(`+ ${el.id}${descr}`);
197
- // CR-147: Serialize known attributes
198
- if (el.attributes) {
199
- for (const [k, v] of Object.entries(el.attributes)) {
200
- if (v != null && String(v).length > 0) {
201
- lines.push(` @${k} ${String(v)}`);
252
+ const byType = new Map();
253
+ for (const el of modelingElements) {
254
+ const group = byType.get(el.type);
255
+ if (group)
256
+ group.push(el);
257
+ else
258
+ byType.set(el.type, [el]);
259
+ }
260
+ for (const type of [...byType.keys()].sort()) {
261
+ lines.push(`### ${type}`);
262
+ for (const el of byType.get(type) ?? []) {
263
+ const descr = el.description ? `|${el.description}` : '';
264
+ lines.push(`+ ${el.id}${descr}`);
265
+ // CR-147: Serialize known attributes
266
+ if (el.attributes) {
267
+ for (const [k, v] of Object.entries(el.attributes)) {
268
+ if (v == null)
269
+ continue;
270
+ // BOK-CR-026: objects/arrays as JSON — String({}) collapses a realRef/testRef
271
+ // binding to "[object Object]" and loses it on the next parse.
272
+ const text = typeof v === 'object' ? JSON.stringify(v) : String(v);
273
+ if (text.length > 0)
274
+ lines.push(` @${k} ${text}`);
202
275
  }
203
276
  }
204
277
  }
@@ -3,9 +3,9 @@
3
3
  * Single source of truth for SE ontology schemas across all projects.
4
4
  */
5
5
  /** Ontology schema version (element types + trace types). */
6
- export declare const ONTOLOGY_VERSION = "3.9.0";
6
+ export declare const ONTOLOGY_VERSION = "4.0.0";
7
7
  /** Rules engine version (validation rules incl. RC conformance). */
8
- export declare const RULES_VERSION = "2.18.0";
8
+ export declare const RULES_VERSION = "2.21.0";
9
9
  /** Meta-model version (trace pattern constraints + format-e parser). */
10
10
  export declare const META_MODEL_VERSION = "1.4.0";
11
11
  export * from './ontology.js';
@@ -24,5 +24,5 @@ export * from './quality-rules.js';
24
24
  export * from './evaluate-all.js';
25
25
  export * from './readiness.js';
26
26
  export * from './meta-model.js';
27
- export * from './semantic-id.js';
27
+ export * from './element-uid.js';
28
28
  export * from './format-e-parser.js';
package/dist/se/index.js CHANGED
@@ -3,9 +3,9 @@
3
3
  * Single source of truth for SE ontology schemas across all projects.
4
4
  */
5
5
  /** Ontology schema version (element types + trace types). */
6
- export const ONTOLOGY_VERSION = '3.9.0'; // realRef unifies codeRef+schemaRef (+physical-MOD CAD ref), symbol optional; testRef stays separate (CR-228 C); +RepoRelativePathSchema on testRef/realRef .file — no absolute/`..` paths (CR-GC-255)
6
+ export const ONTOLOGY_VERSION = '4.0.0'; // BREAKING: `SemanticId` (`Name.TypeAbbr.Counter`) deleted, `ElementUid` (`<TYPE>-<slug>`) is the family canon — the old canon was used by no production graph of the family while 626 of 1145 elements already carried TYPE-slug (CR-SM-217); realRef unifies codeRef+schemaRef (+physical-MOD CAD ref), symbol optional; testRef stays separate (CR-228 C); +RepoRelativePathSchema on testRef/realRef .file — no absolute/`..` paths (CR-GC-255)
7
7
  /** Rules engine version (validation rules incl. RC conformance). */
8
- export const RULES_VERSION = '2.18.0'; // R-20/R-26/RC-01/RC-03/RC-04 read realRef (unified codeRef+schemaRef); +R-27 physical-MOD realRef presence (CR-228 C); -R-24/R-25 REQ→MOD allocation rules deleted (CR-228 A: REQ→MOD allocate no longer a valid pattern, R-18 flags residual edges); NFR-01 budget target split physical→MOD / behavioral→FCHAIN; RULE_TO_DIMENSION completeness (R-18..R-23/R-26/MS-03/CR-R04 mapped, no advisory fall-through) (CR-228 B/D); +BQ-01/02/04/06/07 base-quality rules promoted from aimpro (K2-b, completes rule consolidation); +ND-01/02 near-duplicate + AO-D01/D03/CR-01/RT-01/PH-01/CA-01/IO-01 architecture rules promoted from aimpro (K2-b); +CR-R01..04/MS-03 change-request rules promoted from aimpro (K2-b); +FM-01..03/NFR-01 FMEA + VR-01/CL-01 view rules promoted from aimpro (K2-b); +MT-01..03 architecture metrics promoted from aimpro (K2-b); +SC-01..03/UC-01..06/FC-01..03 quality rules promoted from aimpro (K2-a); +RC-05 cross-module import drift (CR-212); +R-26/RC-03/RC-04 schemaRef (CR-211); +R-22..R-25/R-10/R-20 (CR-201/202/208/209/210)
8
+ export const RULES_VERSION = '2.21.0'; // -MT-03 retired as a rule and reborn as the `allocationCohesion` measurement (CR-SM-223): the 80 % threshold fired on 6/7 graphcode, 4/4 gve and 10/11 family modules — in a flow-routed architecture cross-boundary interaction is the design, not a defect; the metric now reports internal/external per MOD, worst-first, and stays out of the violation stream so it cannot depress the readiness score; +RD-04 decomposition breadth (>11 children per level → warning) and MT-03 recalibrated to FLOW-transitive connection pairs — counting raw io traces reported internal=0 on every real SE graph (15/15 false positives), because the meta-model routes FUNC↔FUNC through FLOW; 'RD-' added to the se profile prefixes, where RD-01..04 were silently missing (CR-SM-221); -SC-01/-SC-03 deleted (BOK-CR-026): `realRef` is the single SCHEMA binding truth (R-26 presence, RC-03/RC-04 resolution); the legacy `zodDefinition`/`sourceFile`/`sourceExport` attributes are gone from every producer, SC_RULES = [SC-02]; R-20/R-26/RC-01/RC-03/RC-04 read realRef (unified codeRef+schemaRef); +R-27 physical-MOD realRef presence (CR-228 C); -R-24/R-25 REQ→MOD allocation rules deleted (CR-228 A: REQ→MOD allocate no longer a valid pattern, R-18 flags residual edges); NFR-01 budget target split physical→MOD / behavioral→FCHAIN; RULE_TO_DIMENSION completeness (R-18..R-23/R-26/MS-03/CR-R04 mapped, no advisory fall-through) (CR-228 B/D); +BQ-01/02/04/06/07 base-quality rules promoted from aimpro (K2-b, completes rule consolidation); +ND-01/02 near-duplicate + AO-D01/D03/CR-01/RT-01/PH-01/CA-01/IO-01 architecture rules promoted from aimpro (K2-b); +CR-R01..04/MS-03 change-request rules promoted from aimpro (K2-b); +FM-01..03/NFR-01 FMEA + VR-01/CL-01 view rules promoted from aimpro (K2-b); +MT-01..03 architecture metrics promoted from aimpro (K2-b); +SC-01..03/UC-01..06/FC-01..03 quality rules promoted from aimpro (K2-a); +RC-05 cross-module import drift (CR-212); +R-26/RC-03/RC-04 schemaRef (CR-211); +R-22..R-25/R-10/R-20 (CR-201/202/208/209/210)
9
9
  /** Meta-model version (trace pattern constraints + format-e parser). */
10
10
  export const META_MODEL_VERSION = '1.4.0'; // -REQ→MOD allocate pattern removed (CR-228 A); +FUNC→FUNC compose (blackbox function decomposition)
11
11
  export * from './ontology.js';
@@ -24,5 +24,5 @@ export * from './quality-rules.js';
24
24
  export * from './evaluate-all.js';
25
25
  export * from './readiness.js';
26
26
  export * from './meta-model.js';
27
- export * from './semantic-id.js';
27
+ export * from './element-uid.js';
28
28
  export * from './format-e-parser.js';
@@ -18,14 +18,41 @@ export declare function mt01Instability(graph: OntologyGraph): RuleViolation[];
18
18
  * Components > 1 → info.
19
19
  */
20
20
  export declare function mt02Lcom4(graph: OntologyGraph): RuleViolation[];
21
+ /** One module's allocation-cohesion measurement (CR-SM-223). */
22
+ export interface AllocationCohesion {
23
+ moduleId: string;
24
+ moduleName: string;
25
+ /** Connection pairs with both endpoints allocated to this module. */
26
+ internal: number;
27
+ /** Connection pairs with exactly one endpoint allocated to this module. */
28
+ external: number;
29
+ /** internal / (internal + external), in [0, 1]. */
30
+ cohesion: number;
31
+ }
21
32
  /**
22
- * MT-03: Allocation Cohesion (CR-191: reformulated).
23
- * cohesion = internal_flows / (internal_flows + external_flows).
24
- * internal_flows = io traces between FUNCs within the same module.
25
- * external_flows = io traces crossing module boundary (one end inside, one outside).
26
- * If external_flows === 0 cohesion = 100% OK.
33
+ * Allocation cohesion — a **measurement, not a rule** (CR-SM-223, decision 2026-07-29).
34
+ *
35
+ * It used to be MT-03 with an 80 % threshold, and it fired on nearly every module of
36
+ * every real graph: 6 of 7 on graphcode, 4 of 4 on graph-view-edit, 10 of 11 on the
37
+ * family graph. `CR-SM-221` first suspected the edge definition and made it
38
+ * FLOW-transitive — the hit rate did not move. The threshold was the miscalibration:
39
+ * in a flow-routed layered architecture, module interaction crosses boundaries by
40
+ * design, so "80 % of interaction is internal" describes a monolith, not a healthy
41
+ * module.
42
+ *
43
+ * Rather than fit a cut-off to 11 data points, this reports the number and lets the
44
+ * architect judge. Returned worst-first, so the head of the list is where to look.
45
+ * Modules with fewer than two allocated FUNCs, or with no external connection at all,
46
+ * carry no signal and are omitted.
47
+ *
48
+ * Deliberately NOT a `RuleDefinition`: `computeReadiness` counts every violation into
49
+ * its dimension score regardless of severity, so a per-module advisory would depress
50
+ * the `alloc` score permanently. A measurement must not masquerade as a defect.
51
+ *
52
+ * Validation of this metric — and of MT-01/MT-02, which are thresholded the same way —
53
+ * is deferred (CR-SM-223).
27
54
  */
28
- export declare function mt03AllocationCohesion(graph: OntologyGraph): RuleViolation[];
55
+ export declare function allocationCohesion(graph: OntologyGraph): AllocationCohesion[];
29
56
  export declare const MT_RULES: readonly [{
30
57
  readonly id: "MT-01";
31
58
  readonly name: "Module instability";
@@ -36,10 +63,5 @@ export declare const MT_RULES: readonly [{
36
63
  readonly name: "Module cohesion (LCOM4)";
37
64
  readonly severity: "info";
38
65
  readonly evaluate: typeof mt02Lcom4;
39
- }, {
40
- readonly id: "MT-03";
41
- readonly name: "Allocation cohesion";
42
- readonly severity: "info";
43
- readonly evaluate: typeof mt03AllocationCohesion;
44
66
  }];
45
67
  export declare function evaluateMTRules(graph: OntologyGraph): RuleViolation[];
@@ -151,57 +151,117 @@ export function mt02Lcom4(graph) {
151
151
  return violations;
152
152
  }
153
153
  /**
154
- * MT-03: Allocation Cohesion (CR-191: reformulated).
155
- * cohesion = internal_flows / (internal_flows + external_flows).
156
- * internal_flows = io traces between FUNCs within the same module.
157
- * external_flows = io traces crossing module boundary (one end inside, one outside).
158
- * If external_flows === 0 cohesion = 100% OK.
154
+ * CR-SM-221: connection pairs between non-FLOW elements, FLOW-transitive.
155
+ *
156
+ * The SE meta-model routes FUNC↔FUNC communication through FLOW nodes
157
+ * (`FUNC —io→ FLOW —io FUNC`); a direct FUNC→FUNC io trace is the exception, not
158
+ * the rule. Counting raw io traces therefore found *zero* internal edges on every
159
+ * real graph — measured on graphcode (369 elements), graph-view-edit (257) and the
160
+ * family graph (638): 15 of 15 MODs reported internal=0, i.e. 15 false positives and
161
+ * no discriminating power at all.
162
+ *
163
+ * A pair counts once, no matter how many flows connect it: the metric asks "do these
164
+ * two talk to each other", not "how often".
159
165
  */
160
- export function mt03AllocationCohesion(graph) {
161
- const violations = [];
166
+ function connectionPairs(graph) {
167
+ const typeOf = new Map(graph.elements.map(e => [e.id, e.type]));
168
+ const pairs = new Set();
169
+ const add = (a, b) => {
170
+ if (a !== b)
171
+ pairs.add(a < b ? `${a}|${b}` : `${b}|${a}`);
172
+ };
173
+ const intoFlow = new Map(); // flow → producers
174
+ const outOfFlow = new Map(); // flow → consumers
175
+ const push = (m, k, v) => {
176
+ const list = m.get(k);
177
+ if (list)
178
+ list.push(v);
179
+ else
180
+ m.set(k, [v]);
181
+ };
182
+ for (const t of graph.traces) {
183
+ if (t.type !== 'io')
184
+ continue;
185
+ const srcIsFlow = typeOf.get(t.source) === 'FLOW';
186
+ const tgtIsFlow = typeOf.get(t.target) === 'FLOW';
187
+ if (srcIsFlow && tgtIsFlow)
188
+ continue; // FLOW→FLOW carries no endpoint
189
+ if (!srcIsFlow && !tgtIsFlow) {
190
+ add(t.source, t.target);
191
+ continue;
192
+ }
193
+ if (tgtIsFlow)
194
+ push(intoFlow, t.target, t.source);
195
+ else
196
+ push(outOfFlow, t.source, t.target);
197
+ }
198
+ for (const [flow, producers] of intoFlow) {
199
+ for (const consumer of outOfFlow.get(flow) ?? []) {
200
+ for (const producer of producers)
201
+ add(producer, consumer);
202
+ }
203
+ }
204
+ return pairs;
205
+ }
206
+ /**
207
+ * Allocation cohesion — a **measurement, not a rule** (CR-SM-223, decision 2026-07-29).
208
+ *
209
+ * It used to be MT-03 with an 80 % threshold, and it fired on nearly every module of
210
+ * every real graph: 6 of 7 on graphcode, 4 of 4 on graph-view-edit, 10 of 11 on the
211
+ * family graph. `CR-SM-221` first suspected the edge definition and made it
212
+ * FLOW-transitive — the hit rate did not move. The threshold was the miscalibration:
213
+ * in a flow-routed layered architecture, module interaction crosses boundaries by
214
+ * design, so "80 % of interaction is internal" describes a monolith, not a healthy
215
+ * module.
216
+ *
217
+ * Rather than fit a cut-off to 11 data points, this reports the number and lets the
218
+ * architect judge. Returned worst-first, so the head of the list is where to look.
219
+ * Modules with fewer than two allocated FUNCs, or with no external connection at all,
220
+ * carry no signal and are omitted.
221
+ *
222
+ * Deliberately NOT a `RuleDefinition`: `computeReadiness` counts every violation into
223
+ * its dimension score regardless of severity, so a per-module advisory would depress
224
+ * the `alloc` score permanently. A measurement must not masquerade as a defect.
225
+ *
226
+ * Validation of this metric — and of MT-01/MT-02, which are thresholded the same way —
227
+ * is deferred (CR-SM-223).
228
+ */
229
+ export function allocationCohesion(graph) {
230
+ const measurements = [];
162
231
  const mods = graph.elements.filter(e => e.type === 'MOD');
163
- const COHESION_THRESHOLD = 0.8;
232
+ const pairs = [...connectionPairs(graph)].map(p => p.split('|'));
164
233
  for (const mod of mods) {
165
- const allocTraces = graph.traces.filter(t => t.type === 'allocate' && t.target === mod.id);
166
- const funcIds = new Set(allocTraces.map(t => t.source));
234
+ const funcIds = new Set(graph.traces.filter(t => t.type === 'allocate' && t.target === mod.id).map(t => t.source));
167
235
  if (funcIds.size < 2)
168
236
  continue;
169
- let internalFlows = 0;
170
- let externalFlows = 0;
171
- for (const t of graph.traces) {
172
- if (t.type !== 'io')
173
- continue;
174
- const srcIn = funcIds.has(t.source);
175
- const tgtIn = funcIds.has(t.target);
176
- if (srcIn && tgtIn) {
177
- internalFlows++;
178
- }
179
- else if (srcIn || tgtIn) {
180
- externalFlows++;
181
- }
237
+ let internal = 0;
238
+ let external = 0;
239
+ for (const [a, b] of pairs) {
240
+ const aIn = funcIds.has(a);
241
+ const bIn = funcIds.has(b);
242
+ if (aIn && bIn)
243
+ internal++;
244
+ else if (aIn || bIn)
245
+ external++;
182
246
  }
183
- // No external flowscohesion = 100% OK
184
- if (externalFlows === 0)
185
- continue;
186
- const total = internalFlows + externalFlows;
187
- if (total === 0)
247
+ // No external connectionsnothing to compare against, no signal.
248
+ if (external === 0)
188
249
  continue;
189
- const cohesion = internalFlows / total;
190
- if (cohesion < COHESION_THRESHOLD) {
191
- violations.push({
192
- rule_id: 'MT-03',
193
- severity: 'info',
194
- element_id: mod.id,
195
- message: `${mod.name} allocation cohesion ${Math.round(cohesion * 100)}% (<${COHESION_THRESHOLD * 100}%). internal=${internalFlows}, external=${externalFlows}`,
196
- });
197
- }
250
+ measurements.push({
251
+ moduleId: mod.id,
252
+ moduleName: mod.name,
253
+ internal,
254
+ external,
255
+ cohesion: internal / (internal + external),
256
+ });
198
257
  }
199
- return violations;
258
+ // Worst first; stable by id so the ranking is deterministic.
259
+ return measurements.sort((a, b) => a.cohesion - b.cohesion || (a.moduleId < b.moduleId ? -1 : a.moduleId > b.moduleId ? 1 : 0));
200
260
  }
201
261
  export const MT_RULES = [
202
262
  { id: 'MT-01', name: 'Module instability', severity: 'warning', evaluate: mt01Instability },
203
263
  { id: 'MT-02', name: 'Module cohesion (LCOM4)', severity: 'info', evaluate: mt02Lcom4 },
204
- { id: 'MT-03', name: 'Allocation cohesion', severity: 'info', evaluate: mt03AllocationCohesion },
264
+ // MT-03 retired as a rule (CR-SM-223) see `allocationCohesion` above.
205
265
  ];
206
266
  export function evaluateMTRules(graph) {
207
267
  return MT_RULES.flatMap(r => r.evaluate(graph));
@@ -9,7 +9,7 @@ export const ReadinessDimension = z.enum([
9
9
  'arch', // Functional architecture (R-02, R-03, R-10, R-12)
10
10
  'alloc', // Module allocation (R-04)
11
11
  'ver', // Test coverage (R-01, R-05)
12
- 'schema', // Interface completeness (SC-01..03)
12
+ 'schema', // Interface completeness (R-26 binding, SC-02 usage)
13
13
  'cr', // CR traceability (CR-R01..R03)
14
14
  'ms', // Milestone planning (MS-01..02)
15
15
  ]);
@@ -37,6 +37,8 @@ export const RULE_TO_DIMENSION = {
37
37
  'BQ-01': 'req', 'BQ-02': 'req', 'BQ-04': 'req',
38
38
  'BQ-06': 'req', 'BQ-07': 'req',
39
39
  'RD-01': 'req', 'RD-02': 'req', 'RD-03': 'req',
40
+ // RD-04 is decomposition *breadth* — an architecture concern, not a requirement one
41
+ 'RD-04': 'arch',
40
42
  // trace/realization/allocation completeness rules (CR-228 D: previously unmapped → advisory fall-through)
41
43
  'R-18': 'arch', 'R-19': 'ver', 'R-20': 'arch', 'R-21': 'ver',
42
44
  'R-22': 'alloc', 'R-23': 'alloc', 'R-26': 'schema', 'R-27': 'arch',
@@ -52,13 +54,15 @@ export const RULE_TO_DIMENSION = {
52
54
  // ver
53
55
  'R-01': 'ver', 'R-05': 'ver',
54
56
  // schema
55
- 'SC-01': 'schema', 'SC-02': 'schema', 'SC-03': 'schema',
57
+ 'SC-02': 'schema', // SC-01/SC-03 deleted (BOK-CR-026) — R-26 is the binding rule
56
58
  // structural rules without primary dimension → assigned by closest concern
57
59
  'R-08': 'arch',
58
60
  // near-duplicate detection
59
61
  'ND-01': 'arch', 'ND-02': 'schema',
60
62
  // architecture metrics
61
- 'MT-01': 'alloc', 'MT-02': 'alloc', 'MT-03': 'alloc',
63
+ // MT-03 is no longer here: it became a measurement (`allocationCohesion`), not a
64
+ // rule (CR-SM-223) — a per-module advisory would depress this score permanently.
65
+ 'MT-01': 'alloc', 'MT-02': 'alloc',
62
66
  // CR traceability
63
67
  'CR-R01': 'cr', 'CR-R02': 'cr', 'CR-R03': 'cr', 'CR-R04': 'cr',
64
68
  // architecture optimization
package/dist/se/rules.js CHANGED
@@ -411,6 +411,58 @@ function noPrematureDecomposition(graph) {
411
411
  }));
412
412
  }
413
413
  // ---------------------------------------------------------------------------
414
+ // RD-04: Decomposition breadth (CR-SM-221)
415
+ // ---------------------------------------------------------------------------
416
+ /**
417
+ * Max children on one decomposition level. The 7–11 convention existed only as
418
+ * prose ("darüber func-of-func"); below 7 is a guideline, not a violation, so only
419
+ * the upper bound is a rule.
420
+ *
421
+ * Counted per (parent, kind) — a MOD that both holds 12 FUNCs and composes 12
422
+ * sub-MODs has two breadth problems, not one. (The spike reference merged both into
423
+ * a single counter on the MOD id and would have reported 24 under one kind.)
424
+ */
425
+ const DECOMPOSITION_BREADTH_MAX = 11;
426
+ function decompositionBreadth(graph) {
427
+ const typeOf = new Map(graph.elements.map(e => [e.id, e.type]));
428
+ const byId = new Map(graph.elements.map(e => [e.id, e]));
429
+ const counts = new Map();
430
+ const bump = (parentId, kind) => {
431
+ const key = `${parentId}${kind}`;
432
+ const entry = counts.get(key);
433
+ if (entry)
434
+ entry.n++;
435
+ else
436
+ counts.set(key, { parentId, kind, n: 1 });
437
+ };
438
+ for (const t of graph.traces) {
439
+ const src = typeOf.get(t.source);
440
+ const tgt = typeOf.get(t.target);
441
+ if (t.type === 'compose' && src === 'FUNC' && tgt === 'FUNC') {
442
+ bump(t.source, 'sub-FUNC');
443
+ }
444
+ else if (t.type === 'allocate' && tgt === 'MOD' && src === 'FUNC') {
445
+ bump(t.target, 'allocated FUNC');
446
+ }
447
+ else if (t.type === 'compose' && (src === 'SYS' || src === 'MOD') && tgt === 'MOD') {
448
+ bump(t.source, 'sub-MOD');
449
+ }
450
+ }
451
+ return [...counts.values()]
452
+ .filter(c => c.n > DECOMPOSITION_BREADTH_MAX)
453
+ .map(c => {
454
+ const parent = byId.get(c.parentId);
455
+ return {
456
+ rule_id: 'RD-04',
457
+ severity: 'warning',
458
+ element_id: c.parentId,
459
+ message: `${c.parentId} has ${c.n} ${c.kind} children on one level (>${DECOMPOSITION_BREADTH_MAX})`,
460
+ fix_hint: 'Introduce an intermediate level (func-of-func / sub-MOD)',
461
+ context: { element_type: parent?.type, element_name: parent?.name },
462
+ };
463
+ });
464
+ }
465
+ // ---------------------------------------------------------------------------
414
466
  // R-14: UC must have at least 1 compose trace (→ FCHAIN or REQ) (CR-117)
415
467
  // ---------------------------------------------------------------------------
416
468
  function ucMustHaveCompose(graph) {
@@ -870,6 +922,7 @@ export const V3_RULES = [
870
922
  { id: 'RD-01', name: 'Unresolved requirement', severity: 'warning', evaluate: unresolvedRequirement },
871
923
  { id: 'RD-02', name: 'Decomposition consistency', severity: 'warning', evaluate: decompositionConsistency },
872
924
  { id: 'RD-03', name: 'No premature decomposition', severity: 'info', evaluate: noPrematureDecomposition },
925
+ { id: 'RD-04', name: 'Decomposition breadth', severity: 'warning', evaluate: decompositionBreadth },
873
926
  { id: 'MS-01', name: 'Milestone empty scope', severity: 'warning', evaluate: msEmptyScope },
874
927
  { id: 'MS-02', name: 'Milestone dangling dependency', severity: 'error', evaluate: msDanglingDependency },
875
928
  ];
@@ -1,11 +1,19 @@
1
1
  /**
2
- * SC-01..SC-03 — Schema Quality rules (CR-122).
3
- * Complement V3 structural rules with content checks on SCHEMA elements.
2
+ * SC-02 — Schema Quality rule (CR-122, reduced by BOK-CR-026).
3
+ *
4
+ * SC-01 (`zodDefinition` present) and SC-03 (`sourceFile`/`sourceExport` present) were
5
+ * DELETED: both re-asked R-26's question ("is this SCHEMA bound to code?") on legacy
6
+ * attributes and fired on exactly the same elements (Jaccard 1.0 vs R-26). SC-03 had
7
+ * additionally become wrong — a SCHEMA correctly bound via `realRef` still fired it.
8
+ * `realRef` is the single SCHEMA binding truth (R-26 presence, RC-03/RC-04 resolution);
9
+ * a spec-only SCHEMA is `concept:true`, it does NOT carry a Zod body copy in the graph
10
+ * (same rule as TEST: bindings point at code, they never mirror it).
11
+ *
12
+ * What remains here is the one SCHEMA check that is NOT about binding: is the interface
13
+ * used by any FLOW.
4
14
  */
5
15
  import type { OntologyGraph } from './ontology.js';
6
16
  import type { RuleDefinition, RuleViolation } from './rules.js';
7
- export declare function sc01HasDefinition(graph: OntologyGraph): RuleViolation[];
8
17
  export declare function sc02IsReferenced(graph: OntologyGraph): RuleViolation[];
9
- export declare function sc03IsImplemented(graph: OntologyGraph): RuleViolation[];
10
18
  export declare const SC_RULES: RuleDefinition[];
11
19
  export declare function evaluateSCRules(graph: OntologyGraph): RuleViolation[];
@@ -1,26 +1,4 @@
1
1
  // ---------------------------------------------------------------------------
2
- // SC-01: SCHEMA must have zodDefinition attribute
3
- // ---------------------------------------------------------------------------
4
- export function sc01HasDefinition(graph) {
5
- return graph.elements
6
- .filter(e => e.type === 'SCHEMA')
7
- .filter(s => {
8
- const def = s.attributes?.zodDefinition;
9
- return !def || (typeof def === 'string' && def.trim().length === 0);
10
- })
11
- .map(s => ({
12
- rule_id: 'SC-01',
13
- severity: 'warning',
14
- element_id: s.id,
15
- message: `${s.id} has no zodDefinition attribute`,
16
- fix_hint: 'Add zodDefinition attribute with Zod schema string',
17
- context: {
18
- element_type: s.type,
19
- element_name: s.name,
20
- },
21
- }));
22
- }
23
- // ---------------------------------------------------------------------------
24
2
  // SC-02: SCHEMA must be referenced by at least one FLOW via relation trace
25
3
  // ---------------------------------------------------------------------------
26
4
  export function sc02IsReferenced(graph) {
@@ -42,31 +20,10 @@ export function sc02IsReferenced(graph) {
42
20
  }));
43
21
  }
44
22
  // ---------------------------------------------------------------------------
45
- // SC-03: SCHEMA should have sourceFile (implementation marker)
46
- // ---------------------------------------------------------------------------
47
- export function sc03IsImplemented(graph) {
48
- return graph.elements
49
- .filter(e => e.type === 'SCHEMA')
50
- .filter(s => !s.attributes?.sourceFile && !s.attributes?.sourceExport)
51
- .map(s => ({
52
- rule_id: 'SC-03',
53
- severity: 'info',
54
- element_id: s.id,
55
- message: `${s.id} has no sourceFile/sourceExport (not implemented yet)`,
56
- fix_hint: 'Implement the Zod schema in code and set sourceFile/sourceExport',
57
- context: {
58
- element_type: s.type,
59
- element_name: s.name,
60
- },
61
- }));
62
- }
63
- // ---------------------------------------------------------------------------
64
23
  // Aggregated array & convenience runner
65
24
  // ---------------------------------------------------------------------------
66
25
  export const SC_RULES = [
67
- { id: 'SC-01', name: 'Schema has definition', severity: 'warning', evaluate: sc01HasDefinition },
68
26
  { id: 'SC-02', name: 'Schema referenced by FLOW', severity: 'warning', evaluate: sc02IsReferenced },
69
- { id: 'SC-03', name: 'Schema is implemented', severity: 'info', evaluate: sc03IsImplemented },
70
27
  ];
71
28
  export function evaluateSCRules(graph) {
72
29
  return SC_RULES.flatMap(rule => rule.evaluate(graph));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sigloch/contracts",
3
- "version": "0.8.0",
3
+ "version": "2.0.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -1,30 +0,0 @@
1
- /**
2
- * SemanticId — Format-E canonical element ID schema.
3
- * Aligned with graphengine CR-052/CR-053.
4
- *
5
- * Format: {Name}.{TypeAbbr}.{Counter}
6
- * Example: ObserveSessions.UC.001
7
- *
8
- * @sigloch/contracts/se
9
- */
10
- import { z } from 'zod/v4';
11
- import type { ElementType } from './ontology.js';
12
- /** 2-char abbreviation → full ElementType. */
13
- export declare const ABBREV_TO_TYPE: Record<string, ElementType>;
14
- /** Full ElementType → 2-char abbreviation. */
15
- export declare const TYPE_TO_ABBREV: Record<string, string>;
16
- /** SemanticId: `Name.TypeAbbr.Counter` where Counter is 3-digit or 6-char alphanumeric. */
17
- export declare const SemanticId: z.ZodString;
18
- export type SemanticId = z.infer<typeof SemanticId>;
19
- /** Check if a string is a valid SemanticId. */
20
- export declare function isSemanticId(s: string): boolean;
21
- /** Extract name, type, and counter from a SemanticId. */
22
- export declare function extractFromSemanticId(semanticId: string): {
23
- name: string;
24
- type: ElementType;
25
- counter: string;
26
- };
27
- /** Build a SemanticId from parts. */
28
- export declare function toSemanticId(name: string, type: ElementType, counter: string): string;
29
- /** Get the 2-char abbreviation for an ElementType. */
30
- export declare function getTypeAbbreviation(type: ElementType): string;
@@ -1,90 +0,0 @@
1
- /**
2
- * SemanticId — Format-E canonical element ID schema.
3
- * Aligned with graphengine CR-052/CR-053.
4
- *
5
- * Format: {Name}.{TypeAbbr}.{Counter}
6
- * Example: ObserveSessions.UC.001
7
- *
8
- * @sigloch/contracts/se
9
- */
10
- import { z } from 'zod/v4';
11
- // ---------------------------------------------------------------------------
12
- // Type abbreviation maps (aligned with graphengine semantic-id.ts)
13
- // ---------------------------------------------------------------------------
14
- /** 2-char abbreviation → full ElementType. */
15
- export const ABBREV_TO_TYPE = {
16
- SY: 'SYS',
17
- UC: 'UC',
18
- RQ: 'REQ',
19
- FN: 'FUNC',
20
- FC: 'FCHAIN',
21
- FL: 'FLOW',
22
- AC: 'ACTOR',
23
- MD: 'MOD',
24
- TC: 'TEST',
25
- TS: 'TEST', // legacy alias
26
- SC: 'SCHEMA',
27
- CR: 'CR',
28
- MS: 'MS',
29
- };
30
- /** Full ElementType → 2-char abbreviation. */
31
- export const TYPE_TO_ABBREV = {
32
- SYS: 'SY',
33
- UC: 'UC',
34
- REQ: 'RQ',
35
- FUNC: 'FN',
36
- FCHAIN: 'FC',
37
- FLOW: 'FL',
38
- ACTOR: 'AC',
39
- MOD: 'MD',
40
- TEST: 'TC',
41
- SCHEMA: 'SC',
42
- SESSION: 'SE',
43
- CR: 'CR',
44
- MS: 'MS',
45
- };
46
- // ---------------------------------------------------------------------------
47
- // Zod schema
48
- // ---------------------------------------------------------------------------
49
- /** SemanticId: `Name.TypeAbbr.Counter` where Counter is 3-digit or 6-char alphanumeric. */
50
- export const SemanticId = z.string().regex(/^[A-Za-z][A-Za-z0-9_+]{0,49}\.[A-Z]{2}\.[a-z0-9]{3,6}$/, 'SemanticId must match {Name}.{TypeAbbr}.{Counter}');
51
- // ---------------------------------------------------------------------------
52
- // Pure functions
53
- // ---------------------------------------------------------------------------
54
- /** Check if a string is a valid SemanticId. */
55
- export function isSemanticId(s) {
56
- return SemanticId.safeParse(s).success;
57
- }
58
- /** Extract name, type, and counter from a SemanticId. */
59
- export function extractFromSemanticId(semanticId) {
60
- const parts = semanticId.split('.');
61
- if (parts.length < 3) {
62
- throw new Error(`Invalid semanticId: "${semanticId}" — expected Name.TypeAbbr.Counter`);
63
- }
64
- const name = parts[0];
65
- const typeAbbr = parts[1];
66
- const type = ABBREV_TO_TYPE[typeAbbr];
67
- if (!type) {
68
- throw new Error(`Unknown type abbreviation "${typeAbbr}" in: "${semanticId}"`);
69
- }
70
- return { name, type, counter: parts[2] };
71
- }
72
- /** Build a SemanticId from parts. */
73
- export function toSemanticId(name, type, counter) {
74
- const abbr = TYPE_TO_ABBREV[type];
75
- if (!abbr)
76
- throw new Error(`No abbreviation for type: ${type}`);
77
- const sanitized = sanitizeName(name);
78
- return `${sanitized}.${abbr}.${counter}`;
79
- }
80
- /** Get the 2-char abbreviation for an ElementType. */
81
- export function getTypeAbbreviation(type) {
82
- return TYPE_TO_ABBREV[type] ?? type.slice(0, 2).toUpperCase();
83
- }
84
- // ---------------------------------------------------------------------------
85
- // Helpers
86
- // ---------------------------------------------------------------------------
87
- /** Sanitize a name for SemanticId: PascalCase, only alnum + underscore + plus, max 50 chars. */
88
- function sanitizeName(name) {
89
- return name.replace(/[^a-zA-Z0-9_+]/g, '').slice(0, 50);
90
- }