@sigloch/contracts 0.7.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.
Files changed (44) hide show
  1. package/LICENSE +21 -0
  2. package/dist/harness/index.d.ts +185 -0
  3. package/dist/harness/index.js +185 -0
  4. package/dist/index.d.ts +13 -0
  5. package/dist/index.js +14 -0
  6. package/dist/se/ao-rules.d.ts +59 -0
  7. package/dist/se/ao-rules.js +341 -0
  8. package/dist/se/conformance-rules.d.ts +64 -0
  9. package/dist/se/conformance-rules.js +364 -0
  10. package/dist/se/cr-quality-rules.d.ts +8 -0
  11. package/dist/se/cr-quality-rules.js +141 -0
  12. package/dist/se/evaluate-all.d.ts +17 -0
  13. package/dist/se/evaluate-all.js +50 -0
  14. package/dist/se/fchain-quality-rules.d.ts +10 -0
  15. package/dist/se/fchain-quality-rules.js +105 -0
  16. package/dist/se/fmea-rules.d.ts +17 -0
  17. package/dist/se/fmea-rules.js +137 -0
  18. package/dist/se/format-e-parser.d.ts +28 -0
  19. package/dist/se/format-e-parser.js +217 -0
  20. package/dist/se/index.d.ts +28 -0
  21. package/dist/se/index.js +28 -0
  22. package/dist/se/meta-model.d.ts +26 -0
  23. package/dist/se/meta-model.js +60 -0
  24. package/dist/se/metric-rules.d.ts +45 -0
  25. package/dist/se/metric-rules.js +208 -0
  26. package/dist/se/near-duplicate-rules.d.ts +44 -0
  27. package/dist/se/near-duplicate-rules.js +106 -0
  28. package/dist/se/ontology.d.ts +327 -0
  29. package/dist/se/ontology.js +216 -0
  30. package/dist/se/quality-rules.d.ts +28 -0
  31. package/dist/se/quality-rules.js +206 -0
  32. package/dist/se/readiness.d.ts +62 -0
  33. package/dist/se/readiness.js +79 -0
  34. package/dist/se/rules.d.ts +159 -0
  35. package/dist/se/rules.js +854 -0
  36. package/dist/se/schema-quality-rules.d.ts +11 -0
  37. package/dist/se/schema-quality-rules.js +73 -0
  38. package/dist/se/semantic-id.d.ts +30 -0
  39. package/dist/se/semantic-id.js +90 -0
  40. package/dist/se/uc-quality-rules.d.ts +13 -0
  41. package/dist/se/uc-quality-rules.js +123 -0
  42. package/dist/se/view-rules.d.ts +11 -0
  43. package/dist/se/view-rules.js +56 -0
  44. package/package.json +51 -0
@@ -0,0 +1,854 @@
1
+ /**
2
+ * SE Rules — declarative config table + evaluation (version: see RULES_VERSION in ./index.ts).
3
+ * R-07/R-09/R-13 removed (CR-180), RD-01..03 added.
4
+ * @sigloch/contracts/se — single source of truth for SE validation rules.
5
+ */
6
+ import { z } from 'zod/v4';
7
+ import { ElementType, TraceType, TestRefSchema, CodeRefSchema, SchemaRefSchema } from './ontology.js';
8
+ import { isValidTrace } from './meta-model.js';
9
+ export const RuleSeverity = z.enum(['error', 'warning', 'info']);
10
+ /** Candidate target for resolving a violation (e.g. a REQ to satisfy, a TEST to link). */
11
+ export const ViolationCandidate = z.object({
12
+ id: z.string(),
13
+ type: ElementType,
14
+ name: z.string(),
15
+ });
16
+ /** Graph context attached to a violation — enables prompt generation without extra DB queries. */
17
+ export const ViolationContext = z.object({
18
+ element_type: ElementType.optional(),
19
+ element_name: z.string().optional(),
20
+ candidate_targets: z.array(ViolationCandidate).optional(),
21
+ existing_traces: z.array(z.object({
22
+ target: z.string(),
23
+ type: TraceType,
24
+ })).optional(),
25
+ parent_module: z.string().optional(),
26
+ current_description: z.string().optional(),
27
+ });
28
+ export const RuleViolation = z.object({
29
+ rule_id: z.string(),
30
+ severity: RuleSeverity,
31
+ element_id: z.string(),
32
+ message: z.string(),
33
+ fix_hint: z.string().optional(),
34
+ context: ViolationContext.optional(),
35
+ });
36
+ // ---------------------------------------------------------------------------
37
+ // Graph lookup helpers (shared across rules)
38
+ // ---------------------------------------------------------------------------
39
+ /** Find the MOD a FUNC/SCHEMA is allocated to. */
40
+ function findParentModule(graph, elementId) {
41
+ const trace = graph.traces.find(t => t.source === elementId && t.type === 'allocate');
42
+ return trace?.target;
43
+ }
44
+ /** Get all outgoing traces of a given type from an element. */
45
+ function outgoingTraces(graph, elementId, traceType) {
46
+ return graph.traces.filter(t => t.source === elementId && (!traceType || t.type === traceType));
47
+ }
48
+ /** Tokenize id/name/description into a lowercase token set (drops tokens < 3 chars). */
49
+ function overlapTokens(...parts) {
50
+ return new Set(parts
51
+ .filter((p) => !!p)
52
+ .join(' ')
53
+ .toLowerCase()
54
+ .split(/[^a-z0-9]+/)
55
+ .filter(t => t.length >= 3));
56
+ }
57
+ /**
58
+ * Map elements to ViolationCandidate format. When `ref` is given, rank candidates
59
+ * by id/name/description token overlap with `ref` (descending) so the most relevant
60
+ * target is first — e.g. REQ-bootstrap → TEST-bootstrap (CR-GC-203 item 3). Ranking
61
+ * is a hint, not an auto-link; semantic confirmation stays with the caller.
62
+ */
63
+ function toCandidates(elements, ref) {
64
+ if (!ref)
65
+ return elements.map(e => ({ id: e.id, type: e.type, name: e.name }));
66
+ const refTokens = overlapTokens(ref.id, ref.name, ref.description);
67
+ const score = (e) => {
68
+ let n = 0;
69
+ for (const tok of overlapTokens(e.id, e.name, e.description))
70
+ if (refTokens.has(tok))
71
+ n++;
72
+ return n;
73
+ };
74
+ return [...elements]
75
+ .sort((a, b) => score(b) - score(a))
76
+ .map(e => ({ id: e.id, type: e.type, name: e.name }));
77
+ }
78
+ // ---------------------------------------------------------------------------
79
+ // R-01: Every REQ must have at least one verify trace
80
+ // ---------------------------------------------------------------------------
81
+ function reqMustHaveVerification(graph) {
82
+ const reqs = graph.elements.filter(e => e.type === 'REQ');
83
+ const tests = graph.elements.filter(e => e.type === 'TEST');
84
+ return reqs
85
+ .filter(req => !graph.traces.some(t => t.target === req.id && t.type === 'verify'))
86
+ .map(req => ({
87
+ rule_id: 'R-01',
88
+ severity: 'error',
89
+ element_id: req.id,
90
+ message: `${req.id} has no verification trace`,
91
+ fix_hint: 'Link a TEST element via verify trace',
92
+ context: {
93
+ element_type: req.type,
94
+ element_name: req.name,
95
+ candidate_targets: toCandidates(tests, req),
96
+ current_description: req.description,
97
+ },
98
+ }));
99
+ }
100
+ // ---------------------------------------------------------------------------
101
+ // R-02: Every FUNC must satisfy at least one REQ
102
+ // ---------------------------------------------------------------------------
103
+ function funcMustSatisfyReq(graph) {
104
+ const funcs = graph.elements.filter(e => e.type === 'FUNC');
105
+ const allReqs = graph.elements.filter(e => e.type === 'REQ');
106
+ return funcs
107
+ .filter(fn => !graph.traces.some(t => t.source === fn.id && t.type === 'satisfy'))
108
+ .map(fn => {
109
+ const modId = findParentModule(graph, fn.id);
110
+ // Candidate REQs: same module's other FUNCs satisfy these, or constraints referencing this func
111
+ const siblingReqs = modId
112
+ ? graph.traces
113
+ .filter(t => t.type === 'allocate' && t.target === modId && t.source !== fn.id)
114
+ .flatMap(t => graph.traces.filter(st => st.source === t.source && st.type === 'satisfy'))
115
+ .map(st => graph.elements.find(e => e.id === st.target))
116
+ .filter((e) => !!e)
117
+ : [];
118
+ // Also include constraints whose description mentions this func's name
119
+ const namePattern = fn.name.toLowerCase();
120
+ const matchingConstraints = allReqs.filter(r => r.kinds?.includes('non-functional') && r.description.toLowerCase().includes(namePattern));
121
+ const candidates = [...new Map([...siblingReqs, ...matchingConstraints, ...allReqs].map(e => [e.id, e])).values()];
122
+ return {
123
+ rule_id: 'R-02',
124
+ severity: 'warning',
125
+ element_id: fn.id,
126
+ message: `${fn.id} does not satisfy any requirement`,
127
+ fix_hint: 'Link to a REQ via satisfy trace',
128
+ context: {
129
+ element_type: fn.type,
130
+ element_name: fn.name,
131
+ parent_module: modId,
132
+ existing_traces: outgoingTraces(graph, fn.id).map(t => ({ target: t.target, type: t.type })),
133
+ candidate_targets: toCandidates(candidates, fn),
134
+ },
135
+ };
136
+ });
137
+ }
138
+ // ---------------------------------------------------------------------------
139
+ // R-03: ASIL isolation
140
+ // ---------------------------------------------------------------------------
141
+ function asilIsolation(graph) {
142
+ const violations = [];
143
+ const modules = graph.elements.filter(e => e.type === 'MOD');
144
+ for (const mod of modules) {
145
+ const allocated = graph.traces
146
+ .filter(t => t.target === mod.id && t.type === 'allocate')
147
+ .map(t => graph.elements.find(e => e.id === t.source))
148
+ .filter((e) => !!e);
149
+ const hasD = allocated.some(e => e.asil === 'D');
150
+ const hasQM = allocated.some(e => e.asil === 'QM');
151
+ if (hasD && hasQM) {
152
+ const dFuncs = allocated.filter(e => e.asil === 'D');
153
+ const qmFuncs = allocated.filter(e => e.asil === 'QM');
154
+ violations.push({
155
+ rule_id: 'R-03',
156
+ severity: 'error',
157
+ element_id: mod.id,
158
+ message: `${mod.id} mixes ASIL-D and ASIL-QM functions`,
159
+ fix_hint: 'Separate ASIL-D functions into isolated module',
160
+ context: {
161
+ element_type: mod.type,
162
+ element_name: mod.name,
163
+ candidate_targets: toCandidates([...dFuncs, ...qmFuncs]),
164
+ },
165
+ });
166
+ }
167
+ }
168
+ return violations;
169
+ }
170
+ // ---------------------------------------------------------------------------
171
+ // R-04: Max module size
172
+ // ---------------------------------------------------------------------------
173
+ function maxModuleSize(graph) {
174
+ const modules = graph.elements.filter(e => e.type === 'MOD');
175
+ const violations = [];
176
+ for (const mod of modules) {
177
+ const allocatedIds = graph.traces.filter(t => t.target === mod.id && t.type === 'allocate').map(t => t.source);
178
+ const allocated = allocatedIds.map(id => graph.elements.find(e => e.id === id)).filter((e) => !!e);
179
+ const funcCount = allocated.length;
180
+ if (funcCount <= 8)
181
+ continue;
182
+ // Count crossing flows: io paths from FUNCs in this module to FUNCs in other modules
183
+ const funcIds = new Set(allocatedIds);
184
+ const crossings = graph.traces.filter(t => {
185
+ if (t.type !== 'io')
186
+ return false;
187
+ const srcIn = funcIds.has(t.source);
188
+ const tgtIn = funcIds.has(t.target);
189
+ return (srcIn && !tgtIn) || (!srcIn && tgtIn);
190
+ }).length;
191
+ if (funcCount > 12 && crossings === 0) {
192
+ violations.push({
193
+ rule_id: 'R-04',
194
+ severity: 'info',
195
+ element_id: mod.id,
196
+ message: `${mod.id} has ${funcCount} functions but 0 crossings (cohesive, just large)`,
197
+ fix_hint: 'Consider splitting if complexity grows',
198
+ context: { element_type: mod.type, element_name: mod.name, candidate_targets: toCandidates(allocated) },
199
+ });
200
+ }
201
+ else if (funcCount > 12 && crossings > 0) {
202
+ violations.push({
203
+ rule_id: 'R-04',
204
+ severity: 'warning',
205
+ element_id: mod.id,
206
+ message: `${mod.id} has ${funcCount} functions and ${crossings} crossing flows (split recommended)`,
207
+ fix_hint: 'Split module into smaller units to reduce coupling',
208
+ context: { element_type: mod.type, element_name: mod.name, candidate_targets: toCandidates(allocated) },
209
+ });
210
+ }
211
+ else if (funcCount > 8 && crossings > 2) {
212
+ violations.push({
213
+ rule_id: 'R-04',
214
+ severity: 'warning',
215
+ element_id: mod.id,
216
+ message: `${mod.id} has ${funcCount} functions and ${crossings} crossing flows (high coupling)`,
217
+ fix_hint: 'Reduce crossing flows or split module',
218
+ context: { element_type: mod.type, element_name: mod.name, candidate_targets: toCandidates(allocated) },
219
+ });
220
+ }
221
+ }
222
+ return violations;
223
+ }
224
+ // ---------------------------------------------------------------------------
225
+ // R-05: Every TEST must verify at least one REQ
226
+ // ---------------------------------------------------------------------------
227
+ function testMustVerifyReq(graph) {
228
+ const tests = graph.elements.filter(e => e.type === 'TEST');
229
+ const allReqs = graph.elements.filter(e => e.type === 'REQ');
230
+ return tests
231
+ .filter(test => !graph.traces.some(t => t.source === test.id && t.type === 'verify'))
232
+ .map(test => ({
233
+ rule_id: 'R-05',
234
+ severity: 'warning',
235
+ element_id: test.id,
236
+ message: `${test.id} does not verify any requirement`,
237
+ fix_hint: 'Link to a REQ via verify trace',
238
+ context: {
239
+ element_type: test.type,
240
+ element_name: test.name,
241
+ candidate_targets: toCandidates(allReqs),
242
+ },
243
+ }));
244
+ }
245
+ // ---------------------------------------------------------------------------
246
+ // R-06: DELETED — replaced by type-specific rules R-14..R-17 (CR-117)
247
+ // ---------------------------------------------------------------------------
248
+ // ---------------------------------------------------------------------------
249
+ // R-07: DELETED — absorbed into BQ-07 (CR-180)
250
+ // ---------------------------------------------------------------------------
251
+ // ---------------------------------------------------------------------------
252
+ // R-08: Bidirectional trace consistency
253
+ // ---------------------------------------------------------------------------
254
+ function traceConsistency(graph) {
255
+ return graph.traces
256
+ .filter(t => t.category !== 'audit')
257
+ .filter(t => {
258
+ const sourceExists = graph.elements.some(e => e.id === t.source);
259
+ const targetExists = graph.elements.some(e => e.id === t.target);
260
+ return !sourceExists || !targetExists;
261
+ })
262
+ .map(t => ({
263
+ rule_id: 'R-08',
264
+ severity: 'error',
265
+ element_id: t.source,
266
+ message: `Trace ${t.source}→${t.target} references non-existent element`,
267
+ }));
268
+ }
269
+ // ---------------------------------------------------------------------------
270
+ // R-09: DELETED — superseded by RD-01..RD-03 (CR-180)
271
+ // ---------------------------------------------------------------------------
272
+ // ---------------------------------------------------------------------------
273
+ // R-10: FLOW completeness — a FLOW needs BOTH a producer (incoming io) and a
274
+ // consumer (outgoing io). Direction-blind coverage let source-less flows pass
275
+ // as dead ports (CR-201, graph-view-edit CR-GVE-159/-163): FLOW-graph-model had
276
+ // two outgoing io edges but no producer and stayed green. Same rule-id (same
277
+ // concern, stricter check — no parallel path); up to two warnings per FLOW.
278
+ // ---------------------------------------------------------------------------
279
+ function flowCompleteness(graph) {
280
+ const flows = graph.elements.filter(e => e.type === 'FLOW');
281
+ // Producers/consumers of a FLOW are FUNCs or ACTORs (io endpoints on the
282
+ // upstream/downstream side); UC may consume too. Offer both as candidates.
283
+ const sources = graph.elements.filter(e => e.type === 'FUNC' || e.type === 'ACTOR');
284
+ const io = graph.traces.filter(t => t.type === 'io');
285
+ const violations = [];
286
+ for (const f of flows) {
287
+ const ctx = { element_type: f.type, element_name: f.name, candidate_targets: toCandidates(sources) };
288
+ if (!io.some(t => t.target === f.id)) {
289
+ violations.push({
290
+ rule_id: 'R-10',
291
+ severity: 'warning',
292
+ element_id: f.id,
293
+ message: `${f.id} has no producer (incoming io)`,
294
+ fix_hint: 'Link a FUNC or ACTOR as the source via io trace',
295
+ context: ctx,
296
+ });
297
+ }
298
+ if (!io.some(t => t.source === f.id)) {
299
+ violations.push({
300
+ rule_id: 'R-10',
301
+ severity: 'warning',
302
+ element_id: f.id,
303
+ message: `${f.id} has no consumer (outgoing io)`,
304
+ fix_hint: 'Link a FUNC, ACTOR, or UC as the target via io trace',
305
+ context: ctx,
306
+ });
307
+ }
308
+ }
309
+ return violations;
310
+ }
311
+ // ---------------------------------------------------------------------------
312
+ // R-11: REMOVED — superseded by SC-02 (identical check, better severity).
313
+ // ---------------------------------------------------------------------------
314
+ // ---------------------------------------------------------------------------
315
+ // R-12: Circular dependency detection
316
+ // ---------------------------------------------------------------------------
317
+ function noDirectCircular(graph) {
318
+ const violations = [];
319
+ for (const t of graph.traces) {
320
+ if (graph.traces.some(other => other.source === t.target && other.target === t.source && other.type === t.type)) {
321
+ violations.push({
322
+ rule_id: 'R-12',
323
+ severity: 'warning',
324
+ element_id: t.source,
325
+ message: `Circular ${t.type} between ${t.source} and ${t.target}`,
326
+ });
327
+ }
328
+ }
329
+ const seen = new Set();
330
+ return violations.filter(v => {
331
+ const key = v.message;
332
+ if (seen.has(key))
333
+ return false;
334
+ seen.add(key);
335
+ return true;
336
+ });
337
+ }
338
+ // ---------------------------------------------------------------------------
339
+ // R-13: DELETED — superseded by RD-01 (CR-180)
340
+ // ---------------------------------------------------------------------------
341
+ // ---------------------------------------------------------------------------
342
+ // RD-01: Unresolved requirement (leaf REQ without satisfy)
343
+ // ---------------------------------------------------------------------------
344
+ function unresolvedRequirement(graph) {
345
+ const reqs = graph.elements.filter(e => e.type === 'REQ');
346
+ // Leaf REQs: no compose→REQ children
347
+ const leafReqs = reqs.filter(req => !graph.traces.some(t => t.source === req.id && t.type === 'compose' &&
348
+ graph.elements.some(e => e.id === t.target && e.type === 'REQ')));
349
+ return leafReqs
350
+ .filter(req => !graph.traces.some(t => t.target === req.id && t.type === 'satisfy'))
351
+ .map(req => ({
352
+ rule_id: 'RD-01',
353
+ severity: 'warning',
354
+ element_id: req.id,
355
+ message: `${req.id} is a leaf REQ with no satisfy trace (unresolved)`,
356
+ fix_hint: 'Add satisfy trace from FUNC, FCHAIN, MOD, or SYS',
357
+ context: {
358
+ element_type: req.type,
359
+ element_name: req.name,
360
+ // CR-GC-203 item 3: rank the potential satisfiers by overlap with this REQ.
361
+ candidate_targets: toCandidates(graph.elements.filter(e => e.type === 'FUNC' || e.type === 'FCHAIN' || e.type === 'MOD' || e.type === 'SYS'), req),
362
+ },
363
+ }));
364
+ }
365
+ // ---------------------------------------------------------------------------
366
+ // RD-02: Decomposition consistency (parent REQ should not have FUNC satisfy)
367
+ // ---------------------------------------------------------------------------
368
+ function decompositionConsistency(graph) {
369
+ const reqs = graph.elements.filter(e => e.type === 'REQ');
370
+ const parentReqs = reqs.filter(req => graph.traces.some(t => t.source === req.id && t.type === 'compose' &&
371
+ graph.elements.some(e => e.id === t.target && e.type === 'REQ')));
372
+ return parentReqs
373
+ .filter(req => graph.traces.some(t => t.target === req.id && t.type === 'satisfy' &&
374
+ graph.elements.some(e => e.id === t.source && e.type === 'FUNC')))
375
+ .map(req => ({
376
+ rule_id: 'RD-02',
377
+ severity: 'warning',
378
+ element_id: req.id,
379
+ message: `${req.id} has sub-REQs but also a direct FUNC satisfy (should only have origin traces)`,
380
+ fix_hint: 'Remove FUNC→satisfy from parent REQ; only children should be directly satisfied',
381
+ context: { element_type: req.type, element_name: req.name },
382
+ }));
383
+ }
384
+ // ---------------------------------------------------------------------------
385
+ // RD-03: No premature decomposition (all children same satisfy target)
386
+ // ---------------------------------------------------------------------------
387
+ function noPrematureDecomposition(graph) {
388
+ const reqs = graph.elements.filter(e => e.type === 'REQ');
389
+ const parentReqs = reqs.filter(req => graph.traces.some(t => t.source === req.id && t.type === 'compose' &&
390
+ graph.elements.some(e => e.id === t.target && e.type === 'REQ')));
391
+ return parentReqs
392
+ .filter(parent => {
393
+ const childIds = graph.traces
394
+ .filter(t => t.source === parent.id && t.type === 'compose' &&
395
+ graph.elements.some(e => e.id === t.target && e.type === 'REQ'))
396
+ .map(t => t.target);
397
+ if (childIds.length < 2)
398
+ return false;
399
+ const satisfyTargets = childIds.map(cid => graph.traces.filter(t => t.source !== cid && t.target === cid && t.type === 'satisfy').map(t => t.source));
400
+ // All children satisfied by the same single source
401
+ const allSame = satisfyTargets.every(targets => targets.length === 1 && targets[0] === satisfyTargets[0]?.[0]);
402
+ return allSame && satisfyTargets[0]?.length === 1;
403
+ })
404
+ .map(req => ({
405
+ rule_id: 'RD-03',
406
+ severity: 'info',
407
+ element_id: req.id,
408
+ message: `${req.id} children all have the same satisfy target — decomposition may be unnecessary`,
409
+ fix_hint: 'Consider merging sub-REQs back into parent if they share the same verifiable level',
410
+ context: { element_type: req.type, element_name: req.name },
411
+ }));
412
+ }
413
+ // ---------------------------------------------------------------------------
414
+ // R-14: UC must have at least 1 compose trace (→ FCHAIN or REQ) (CR-117)
415
+ // ---------------------------------------------------------------------------
416
+ function ucMustHaveCompose(graph) {
417
+ const ucs = graph.elements.filter(e => e.type === 'UC');
418
+ return ucs
419
+ .filter(uc => !graph.traces.some(t => t.source === uc.id && t.type === 'compose'))
420
+ .map(uc => ({
421
+ rule_id: 'R-14',
422
+ severity: 'warning',
423
+ element_id: uc.id,
424
+ message: `${uc.id} has no compose traces (no scenarios or requirements)`,
425
+ fix_hint: 'Add FCHAIN or REQ via compose trace',
426
+ context: {
427
+ element_type: uc.type,
428
+ element_name: uc.name,
429
+ },
430
+ }));
431
+ }
432
+ // ---------------------------------------------------------------------------
433
+ // R-15: FCHAIN must have at least 1 compose trace (→ FUNC) (CR-117)
434
+ // ---------------------------------------------------------------------------
435
+ function fchainMustHaveCompose(graph) {
436
+ const fchains = graph.elements.filter(e => e.type === 'FCHAIN');
437
+ const funcs = graph.elements.filter(e => e.type === 'FUNC');
438
+ return fchains
439
+ .filter(fc => !graph.traces.some(t => t.source === fc.id && t.type === 'compose'))
440
+ .map(fc => ({
441
+ rule_id: 'R-15',
442
+ severity: 'warning',
443
+ element_id: fc.id,
444
+ message: `${fc.id} has no compose traces (no functions in chain)`,
445
+ fix_hint: 'Add FUNC elements via compose trace',
446
+ context: {
447
+ element_type: fc.type,
448
+ element_name: fc.name,
449
+ candidate_targets: toCandidates(funcs),
450
+ },
451
+ }));
452
+ }
453
+ // ---------------------------------------------------------------------------
454
+ // R-16: ACTOR must have at least 1 io trace (CR-117)
455
+ // ---------------------------------------------------------------------------
456
+ function actorMustHaveTrace(graph) {
457
+ const actors = graph.elements.filter(e => e.type === 'ACTOR');
458
+ return actors
459
+ .filter(a => !graph.traces.some(t => (t.source === a.id || t.target === a.id) && t.type === 'io'))
460
+ .map(a => ({
461
+ rule_id: 'R-16',
462
+ severity: 'warning',
463
+ element_id: a.id,
464
+ message: `${a.id} has no io traces (disconnected actor)`,
465
+ fix_hint: 'Link to a UC or FLOW via io trace',
466
+ context: {
467
+ element_type: a.type,
468
+ element_name: a.name,
469
+ },
470
+ }));
471
+ }
472
+ // ---------------------------------------------------------------------------
473
+ // R-17: SYS must have at least 1 compose trace (CR-117)
474
+ // ---------------------------------------------------------------------------
475
+ function sysMustHaveCompose(graph) {
476
+ const systems = graph.elements.filter(e => e.type === 'SYS');
477
+ return systems
478
+ .filter(sys => !graph.traces.some(t => t.source === sys.id && t.type === 'compose'))
479
+ .map(sys => ({
480
+ rule_id: 'R-17',
481
+ severity: 'warning',
482
+ element_id: sys.id,
483
+ message: `${sys.id} has no compose traces (empty system)`,
484
+ fix_hint: 'Add UC, REQ, or MOD via compose trace',
485
+ context: {
486
+ element_type: sys.type,
487
+ element_name: sys.name,
488
+ },
489
+ }));
490
+ }
491
+ // ---------------------------------------------------------------------------
492
+ // MS-01: Milestone without scope — no CRs assigned via CR→MS [relation]
493
+ // (CR-207: scope = CRs pointing to this MS, not compose children)
494
+ // ---------------------------------------------------------------------------
495
+ function msEmptyScope(graph) {
496
+ const milestones = graph.elements.filter(e => e.type === 'MS');
497
+ return milestones
498
+ .filter(ms => {
499
+ // Check: any CR → MS [relation] trace?
500
+ const hasCr = graph.traces.some(t => t.type === 'relation' && t.target === ms.id &&
501
+ graph.elements.some(e => e.id === t.source && e.type === 'CR'));
502
+ // Fallback: legacy compose traces still count
503
+ const hasCompose = graph.traces.some(t => t.source === ms.id && t.type === 'compose');
504
+ return !hasCr && !hasCompose;
505
+ })
506
+ .map(ms => ({
507
+ rule_id: 'MS-01',
508
+ severity: 'warning',
509
+ element_id: ms.id,
510
+ message: `${ms.id} has no CRs assigned (empty milestone scope)`,
511
+ fix_hint: 'Add CR→MS [relation] traces to assign change requests to this milestone',
512
+ context: { element_type: ms.type, element_name: ms.name },
513
+ }));
514
+ }
515
+ // ---------------------------------------------------------------------------
516
+ // MS-02: Milestone depends-on dangling target
517
+ // ---------------------------------------------------------------------------
518
+ function msDanglingDependency(graph) {
519
+ const msIds = new Set(graph.elements.filter(e => e.type === 'MS').map(e => e.id));
520
+ return graph.traces
521
+ .filter(t => t.type === 'relation' && t.label === 'depends-on' && msIds.has(t.source))
522
+ .filter(t => !msIds.has(t.target))
523
+ .map(t => ({
524
+ rule_id: 'MS-02',
525
+ severity: 'error',
526
+ element_id: t.source,
527
+ message: `${t.source} depends on non-existent milestone ${t.target}`,
528
+ fix_hint: 'Fix the depends-on relation target to reference an existing MS element',
529
+ }));
530
+ }
531
+ // ---------------------------------------------------------------------------
532
+ // R-18: Valid trace pattern (CR-GC-205 Item 1) — every modeling trace's
533
+ // source/target element-type pair must be a valid TRACE_PATTERN. Lifts pair-
534
+ // legality from a consumer-local validator (graphcode codec.validate) into the
535
+ // one engine, so EVERY consumer running evaluateRules() enforces it and it shows
536
+ // up in readiness. Referential integrity (dangling endpoint) stays R-08's job —
537
+ // here we only judge pairs whose endpoints both resolve. Audit traces are exempt.
538
+ // ---------------------------------------------------------------------------
539
+ function validTracePattern(graph) {
540
+ const typeById = new Map(graph.elements.map(e => [e.id, e.type]));
541
+ return graph.traces
542
+ .filter(t => t.category !== 'audit')
543
+ .filter(t => {
544
+ const src = typeById.get(t.source);
545
+ const tgt = typeById.get(t.target);
546
+ if (!src || !tgt)
547
+ return false; // dangling endpoint → R-08, not R-18
548
+ return !isValidTrace({ source: src, target: tgt, type: t.type, label: t.label });
549
+ })
550
+ .map(t => ({
551
+ rule_id: 'R-18',
552
+ severity: 'error',
553
+ element_id: t.source,
554
+ message: `Invalid trace ${t.source} -${t.type}-> ${t.target}: ` +
555
+ `${typeById.get(t.source)} → ${typeById.get(t.target)} is not a valid ${t.type} pattern`,
556
+ fix_hint: 'Use a trace type whose TRACE_PATTERNS allows this source/target element-type pair',
557
+ }));
558
+ }
559
+ // ---------------------------------------------------------------------------
560
+ // R-19: Runnable TEST binding (CR-GC-205 Item 4) — a TEST that is not explicitly
561
+ // concept-only (attributes.concept === true) should carry a valid testRef runnable
562
+ // binding so the deduced selective test set is trustworthy. WARNING (a completeness
563
+ // signal like R-05, not a hard gate): a freshly-spec'd TEST is legitimately concept-
564
+ // level until implemented, and the no-phantom-file guarantee is enforced separately
565
+ // by the consumer's export materialization (every testRef.file is scaffolded). File
566
+ // EXISTENCE is out of scope here (rules are pure, no I/O); this guards presence/shape
567
+ // only and surfaces an unbound runnable TEST in rules_evaluate / readiness.
568
+ // ---------------------------------------------------------------------------
569
+ function testMustHaveRunnableBinding(graph) {
570
+ return graph.elements
571
+ .filter(e => e.type === 'TEST')
572
+ .filter(e => e.attributes?.concept !== true) // concept-only TESTs are exempt
573
+ .filter(e => !TestRefSchema.safeParse(e.attributes?.testRef).success)
574
+ .map(test => ({
575
+ rule_id: 'R-19',
576
+ severity: 'warning',
577
+ element_id: test.id,
578
+ message: `${test.id} is a runnable TEST without a valid testRef binding`,
579
+ fix_hint: 'Add attributes.testRef {file, case?, tool, level?}, or set attributes.concept:true if it has no run artifact yet',
580
+ context: { element_type: test.type, element_name: test.name },
581
+ }));
582
+ }
583
+ // ---------------------------------------------------------------------------
584
+ // R-20: FUNC code binding (CR-GC-205 Item 5, extended by CR-210) — a FUNC that
585
+ // is not explicitly concept-only (attributes.concept === true) or externally
586
+ // realized (attributes.external === true) counts as BOUND when it EITHER carries
587
+ // a valid codeRef {file, symbol} OR is a blackbox parent whose compose→FUNC
588
+ // children are ALL (recursively) bound — the parent is realized by its children
589
+ // (e.g. FUNC-gesture-capture, gve CR-GVE-150). A partially-bound parent fires
590
+ // R-20 naming the still-unbound leaves in the fix_hint, so drift on a child is
591
+ // enforced at the parent too (before CR-210 the parent was blanket-exempt and
592
+ // structurally unprovable). WARNING (a completeness signal like R-19, not a hard
593
+ // gate). Symbol RESOLUTION stays out of scope (rules are pure, no I/O — that is
594
+ // RC-01's job); this guards presence/shape only. Cycle-safe.
595
+ // ---------------------------------------------------------------------------
596
+ function funcMustHaveCodeBinding(graph) {
597
+ const funcById = new Map(graph.elements.filter(e => e.type === 'FUNC').map(e => [e.id, e]));
598
+ const composeFuncChildren = (id) => graph.traces
599
+ .filter(t => t.source === id && t.type === 'compose' && funcById.has(t.target))
600
+ .map(t => t.target);
601
+ const hasCodeRef = (el) => CodeRefSchema.safeParse(el.attributes?.codeRef).success;
602
+ const isExempt = (el) => el.attributes?.concept === true || el.attributes?.external === true;
603
+ // A FUNC is bound iff it is exempt, has a codeRef, or is a parent whose
604
+ // compose→FUNC children are all bound (recursive, cycle-safe).
605
+ const isBound = (id, stack) => {
606
+ const el = funcById.get(id);
607
+ if (!el)
608
+ return false;
609
+ if (isExempt(el) || hasCodeRef(el))
610
+ return true;
611
+ const children = composeFuncChildren(id);
612
+ if (children.length === 0)
613
+ return false;
614
+ if (stack.has(id))
615
+ return true; // cycle: assume in-progress node holds → terminate
616
+ stack.add(id);
617
+ const bound = children.every(c => isBound(c, stack));
618
+ stack.delete(id);
619
+ return bound;
620
+ };
621
+ // Unbound compose-descendants that actually need realizing (unbound leaves,
622
+ // recursing through unbound parents) — the actionable fix targets for a parent.
623
+ const collectUnboundLeaves = (id, acc, stack) => {
624
+ if (stack.has(id))
625
+ return;
626
+ stack.add(id);
627
+ for (const c of composeFuncChildren(id)) {
628
+ const el = funcById.get(c);
629
+ if (isExempt(el) || hasCodeRef(el))
630
+ continue;
631
+ const grandchildren = composeFuncChildren(c);
632
+ if (grandchildren.length === 0)
633
+ acc.add(c);
634
+ else
635
+ collectUnboundLeaves(c, acc, stack);
636
+ }
637
+ stack.delete(id);
638
+ };
639
+ const violations = [];
640
+ for (const fn of funcById.values()) {
641
+ if (isExempt(fn))
642
+ continue;
643
+ if (isBound(fn.id, new Set()))
644
+ continue;
645
+ const children = composeFuncChildren(fn.id);
646
+ if (children.length > 0) {
647
+ // Parent with at least one still-unbound child.
648
+ const leaves = new Set();
649
+ collectUnboundLeaves(fn.id, leaves, new Set());
650
+ const leafList = [...leaves];
651
+ violations.push({
652
+ rule_id: 'R-20',
653
+ severity: 'warning',
654
+ element_id: fn.id,
655
+ message: `${fn.id} is not fully realized — unbound compose ${leafList.length === 1 ? 'child' : 'children'}: ${leafList.join(', ')}`,
656
+ fix_hint: `Realize the leaves ${leafList.join(', ')} (graph_realize) — the parent inherits their binding`,
657
+ context: { element_type: fn.type, element_name: fn.name },
658
+ });
659
+ }
660
+ else {
661
+ violations.push({
662
+ rule_id: 'R-20',
663
+ severity: 'warning',
664
+ element_id: fn.id,
665
+ message: `${fn.id} is a FUNC without a valid codeRef binding`,
666
+ fix_hint: 'Add attributes.codeRef {file, symbol, lang?}, or set attributes.concept:true (spec-only) / attributes.external:true (realized in another package)',
667
+ context: { element_type: fn.type, element_name: fn.name },
668
+ });
669
+ }
670
+ }
671
+ return violations;
672
+ }
673
+ /** All rules — see RULES_VERSION in ./index.ts (R-06/R-07/R-09/R-11/R-13/R-24/R-25 removed, R-14..R-23/R-26/RD-01..03/MS-01..02 added) */
674
+ // ---------------------------------------------------------------------------
675
+ // R-21: Every FUNC↔FUNC connection must be covered by an integration test.
676
+ // A connection is FUNC ─io→ FLOW ─io→ FUNC. It is covered iff both
677
+ // endpoints share an FCHAIN that owns a verifying integration test
678
+ // (TEST ─verify→ REQ ←satisfy─ FCHAIN). Unit tests (FUNC→REQ) and UC
679
+ // acceptance tests do NOT cover the interface between two functions —
680
+ // CR-GC-240 folded "integration" onto the UC level, leaving FUNC↔FUNC
681
+ // wiring unverified; this rule closes that gap edge-granularly.
682
+ // ---------------------------------------------------------------------------
683
+ function fchainMustHaveIntegrationTest(graph) {
684
+ const typeOf = new Map(graph.elements.map(e => [e.id, e.type]));
685
+ const isFunc = (id) => typeOf.get(id) === 'FUNC';
686
+ const io = graph.traces.filter(t => t.type === 'io');
687
+ // FUNC↔FUNC connections via the FLOW hop: (producer FUNC) → FLOW → (consumer FUNC).
688
+ const connections = [];
689
+ for (const e of graph.elements) {
690
+ if (e.type !== 'FLOW')
691
+ continue;
692
+ const producers = io.filter(t => t.target === e.id && isFunc(t.source)).map(t => t.source);
693
+ const consumers = io.filter(t => t.source === e.id && isFunc(t.target)).map(t => t.target);
694
+ for (const p of producers)
695
+ for (const c of consumers)
696
+ if (p !== c)
697
+ connections.push([p, c]);
698
+ }
699
+ if (connections.length === 0)
700
+ return [];
701
+ // FUNC → set of FCHAINs composing it.
702
+ const chainsOfFunc = new Map();
703
+ for (const t of graph.traces) {
704
+ if (t.type === 'compose' && typeOf.get(t.source) === 'FCHAIN' && isFunc(t.target)) {
705
+ (chainsOfFunc.get(t.target) ?? chainsOfFunc.set(t.target, new Set()).get(t.target)).add(t.source);
706
+ }
707
+ }
708
+ // FCHAINs whose satisfy-REQ is verified by a TEST = chains with an integration test.
709
+ const verifiedReqs = new Set(graph.traces.filter(t => t.type === 'verify').map(t => t.target));
710
+ const testedChains = new Set();
711
+ for (const t of graph.traces) {
712
+ if (t.type === 'satisfy' && typeOf.get(t.source) === 'FCHAIN' && verifiedReqs.has(t.target)) {
713
+ testedChains.add(t.source);
714
+ }
715
+ }
716
+ const violations = [];
717
+ const seen = new Set();
718
+ for (const [p, c] of connections) {
719
+ const shared = [...(chainsOfFunc.get(p) ?? [])].filter(ch => chainsOfFunc.get(c)?.has(ch));
720
+ if (shared.some(ch => testedChains.has(ch)))
721
+ continue;
722
+ const anchor = shared[0] ?? p;
723
+ const key = `${anchor}|${p}>${c}`;
724
+ if (seen.has(key))
725
+ continue;
726
+ seen.add(key);
727
+ const anchorEl = graph.elements.find(e => e.id === anchor);
728
+ violations.push({
729
+ rule_id: 'R-21',
730
+ severity: 'warning',
731
+ element_id: anchor,
732
+ message: shared.length > 0
733
+ ? `${anchor} has no integration test covering connection ${p} → ${c}`
734
+ : `connection ${p} → ${c} is in no FCHAIN with an integration test`,
735
+ fix_hint: 'Group both FUNCs in an FCHAIN and verify an FCHAIN-satisfied REQ with an integration TEST',
736
+ context: { element_type: anchorEl?.type, element_name: anchorEl?.name },
737
+ });
738
+ }
739
+ return violations;
740
+ }
741
+ // ---------------------------------------------------------------------------
742
+ // R-22: FUNC must be allocated to a MOD (CR-202) — every function lives on a
743
+ // module. An unallocated FUNC is a model bug: structure views that nest via
744
+ // allocate silently drop it (gve renders it under SYS as a workaround). Pattern
745
+ // analogous to R-02/funcMustSatisfyReq; allocation (FUNC→MOD) and satisfaction
746
+ // (FUNC→REQ) are orthogonal, so this is its own rule. Cross-module composite
747
+ // allocation (parent on MOD-A, child on MOD-B) is coupling info, NOT an error —
748
+ // deliberately not checked (CR-GVE-173/174).
749
+ // ---------------------------------------------------------------------------
750
+ function funcMustBeAllocated(graph) {
751
+ const funcs = graph.elements.filter(e => e.type === 'FUNC');
752
+ const mods = graph.elements.filter(e => e.type === 'MOD');
753
+ const modIds = new Set(mods.map(m => m.id));
754
+ return funcs
755
+ .filter(fn => !graph.traces.some(t => t.source === fn.id && t.type === 'allocate' && modIds.has(t.target)))
756
+ .map(fn => ({
757
+ rule_id: 'R-22',
758
+ severity: 'warning',
759
+ element_id: fn.id,
760
+ message: `${fn.id} is not allocated to any module`,
761
+ fix_hint: 'Link to a MOD via allocate trace',
762
+ context: {
763
+ element_type: fn.type,
764
+ element_name: fn.name,
765
+ candidate_targets: toCandidates(mods, fn),
766
+ },
767
+ }));
768
+ }
769
+ // ---------------------------------------------------------------------------
770
+ // R-23: MOD must have an allocated FUNC (CR-208) — the MOD-side complement of
771
+ // R-22. R-04 only checks the upper bound (>8 FUNCs); nothing checks the lower.
772
+ // An empty module is a legal intermediate state in the model (the view no longer
773
+ // suppresses it, CR-GVE-188) — so the quality signal belongs in the rules, not
774
+ // the view. Mirrors the empty-container rules R-14/R-16/R-17. Not merged with
775
+ // R-22: one rule per element perspective (like R-01 REQ-side beside R-02/R-05).
776
+ // ---------------------------------------------------------------------------
777
+ function modMustHaveAllocatedFunc(graph) {
778
+ const mods = graph.elements.filter(e => e.type === 'MOD');
779
+ const funcs = graph.elements.filter(e => e.type === 'FUNC');
780
+ const modIds = new Set(mods.map(m => m.id));
781
+ const unallocatedFuncs = funcs.filter(fn => !graph.traces.some(t => t.source === fn.id && t.type === 'allocate' && modIds.has(t.target)));
782
+ return mods
783
+ .filter(mod => !graph.traces.some(t => t.target === mod.id && t.type === 'allocate' &&
784
+ graph.elements.some(e => e.id === t.source && e.type === 'FUNC')))
785
+ .map(mod => ({
786
+ rule_id: 'R-23',
787
+ severity: 'warning',
788
+ element_id: mod.id,
789
+ message: `${mod.id} has no allocated FUNC`,
790
+ fix_hint: 'Allocate a FUNC via allocate trace or remove the module',
791
+ context: {
792
+ element_type: mod.type,
793
+ element_name: mod.name,
794
+ candidate_targets: toCandidates(unallocatedFuncs, mod),
795
+ },
796
+ }));
797
+ }
798
+ // R-24/R-25: DELETED (CR-228) — they guarded `REQ ─allocate→ MOD`, a pattern the
799
+ // meta-model no longer sanctions. A MOD does not own a REQ; physical NFRs use
800
+ // MOD→satisfy→REQ, behavioral NFRs FCHAIN→satisfy→REQ. R-18 now flags any residual
801
+ // REQ→MOD allocate edge as an invalid trace pattern.
802
+ // ---------------------------------------------------------------------------
803
+ // R-26: SCHEMA schemaRef presence (CR-211) — a SCHEMA that is not explicitly
804
+ // concept-only or external should carry a valid schemaRef {file, symbol} so its
805
+ // Zod definition is machine-resolvable (RC-03 then checks it resolves, RC-04 that
806
+ // it is parsed at the interface). WARNING, not error: the 9 currently-unbound
807
+ // SCHEMAs on the reference model must not turn readiness red before any binding
808
+ // exists — the presence signal mirrors R-20 (codeRef) / R-19 (testRef). Symbol
809
+ // RESOLUTION is out of scope here (pure, no I/O — that is RC-03's job).
810
+ // ---------------------------------------------------------------------------
811
+ function schemaMustHaveSchemaRef(graph) {
812
+ return graph.elements
813
+ .filter(e => e.type === 'SCHEMA')
814
+ .filter(e => e.attributes?.concept !== true && e.attributes?.external !== true)
815
+ .filter(e => !SchemaRefSchema.safeParse(e.attributes?.schemaRef).success)
816
+ .map(sc => ({
817
+ rule_id: 'R-26',
818
+ severity: 'warning',
819
+ element_id: sc.id,
820
+ message: `${sc.id} is a SCHEMA without a valid schemaRef binding`,
821
+ fix_hint: 'Add attributes.schemaRef {file, symbol, lang?}, or set attributes.concept:true (spec-only) / attributes.external:true (foreign-API contract)',
822
+ context: { element_type: sc.type, element_name: sc.name },
823
+ }));
824
+ }
825
+ export const V3_RULES = [
826
+ { id: 'R-01', name: 'REQ must have verification', severity: 'error', evaluate: reqMustHaveVerification },
827
+ { id: 'R-02', name: 'FUNC must satisfy REQ', severity: 'warning', evaluate: funcMustSatisfyReq },
828
+ { id: 'R-03', name: 'ASIL isolation', severity: 'error', evaluate: asilIsolation },
829
+ { id: 'R-04', name: 'Max module size', severity: 'warning', evaluate: maxModuleSize },
830
+ { id: 'R-05', name: 'TEST must verify REQ', severity: 'warning', evaluate: testMustVerifyReq },
831
+ { id: 'R-14', name: 'UC must have compose', severity: 'warning', evaluate: ucMustHaveCompose },
832
+ { id: 'R-15', name: 'FCHAIN must have compose', severity: 'warning', evaluate: fchainMustHaveCompose },
833
+ { id: 'R-16', name: 'ACTOR must have io', severity: 'warning', evaluate: actorMustHaveTrace },
834
+ { id: 'R-17', name: 'SYS must have compose', severity: 'warning', evaluate: sysMustHaveCompose },
835
+ { id: 'R-08', name: 'Trace consistency', severity: 'error', evaluate: traceConsistency },
836
+ { id: 'R-10', name: 'FLOW completeness', severity: 'warning', evaluate: flowCompleteness },
837
+ { id: 'R-12', name: 'No circular dependencies', severity: 'warning', evaluate: noDirectCircular },
838
+ { id: 'R-18', name: 'Valid trace pattern', severity: 'error', evaluate: validTracePattern },
839
+ { id: 'R-19', name: 'Runnable TEST binding', severity: 'warning', evaluate: testMustHaveRunnableBinding },
840
+ { id: 'R-20', name: 'FUNC code binding', severity: 'warning', evaluate: funcMustHaveCodeBinding },
841
+ { id: 'R-21', name: 'FUNC↔FUNC connection needs integration test', severity: 'warning', evaluate: fchainMustHaveIntegrationTest },
842
+ { id: 'R-22', name: 'FUNC must be allocated to MOD', severity: 'warning', evaluate: funcMustBeAllocated },
843
+ { id: 'R-23', name: 'MOD must have allocated FUNC', severity: 'warning', evaluate: modMustHaveAllocatedFunc },
844
+ { id: 'R-26', name: 'SCHEMA must have schemaRef', severity: 'warning', evaluate: schemaMustHaveSchemaRef },
845
+ { id: 'RD-01', name: 'Unresolved requirement', severity: 'warning', evaluate: unresolvedRequirement },
846
+ { id: 'RD-02', name: 'Decomposition consistency', severity: 'warning', evaluate: decompositionConsistency },
847
+ { id: 'RD-03', name: 'No premature decomposition', severity: 'info', evaluate: noPrematureDecomposition },
848
+ { id: 'MS-01', name: 'Milestone empty scope', severity: 'warning', evaluate: msEmptyScope },
849
+ { id: 'MS-02', name: 'Milestone dangling dependency', severity: 'error', evaluate: msDanglingDependency },
850
+ ];
851
+ /** Run all rules against a graph */
852
+ export function evaluateRules(graph) {
853
+ return V3_RULES.flatMap(rule => rule.evaluate(graph));
854
+ }