@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.
- package/LICENSE +21 -0
- package/dist/harness/index.d.ts +185 -0
- package/dist/harness/index.js +185 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +14 -0
- package/dist/se/ao-rules.d.ts +59 -0
- package/dist/se/ao-rules.js +341 -0
- package/dist/se/conformance-rules.d.ts +64 -0
- package/dist/se/conformance-rules.js +364 -0
- package/dist/se/cr-quality-rules.d.ts +8 -0
- package/dist/se/cr-quality-rules.js +141 -0
- package/dist/se/evaluate-all.d.ts +17 -0
- package/dist/se/evaluate-all.js +50 -0
- package/dist/se/fchain-quality-rules.d.ts +10 -0
- package/dist/se/fchain-quality-rules.js +105 -0
- package/dist/se/fmea-rules.d.ts +17 -0
- package/dist/se/fmea-rules.js +137 -0
- package/dist/se/format-e-parser.d.ts +28 -0
- package/dist/se/format-e-parser.js +217 -0
- package/dist/se/index.d.ts +28 -0
- package/dist/se/index.js +28 -0
- package/dist/se/meta-model.d.ts +26 -0
- package/dist/se/meta-model.js +60 -0
- package/dist/se/metric-rules.d.ts +45 -0
- package/dist/se/metric-rules.js +208 -0
- package/dist/se/near-duplicate-rules.d.ts +44 -0
- package/dist/se/near-duplicate-rules.js +106 -0
- package/dist/se/ontology.d.ts +327 -0
- package/dist/se/ontology.js +216 -0
- package/dist/se/quality-rules.d.ts +28 -0
- package/dist/se/quality-rules.js +206 -0
- package/dist/se/readiness.d.ts +62 -0
- package/dist/se/readiness.js +79 -0
- package/dist/se/rules.d.ts +159 -0
- package/dist/se/rules.js +854 -0
- package/dist/se/schema-quality-rules.d.ts +11 -0
- package/dist/se/schema-quality-rules.js +73 -0
- package/dist/se/semantic-id.d.ts +30 -0
- package/dist/se/semantic-id.js +90 -0
- package/dist/se/uc-quality-rules.d.ts +13 -0
- package/dist/se/uc-quality-rules.js +123 -0
- package/dist/se/view-rules.d.ts +11 -0
- package/dist/se/view-rules.js +56 -0
- package/package.json +51 -0
package/dist/se/rules.js
ADDED
|
@@ -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
|
+
}
|