airgen-cli 0.12.0 → 0.13.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/dist/commands/lint.js +414 -85
- package/package.json +1 -1
package/dist/commands/lint.js
CHANGED
|
@@ -4,6 +4,15 @@ import { isJsonMode } from "../output.js";
|
|
|
4
4
|
// ── Constants ────────────────────────────────────────────────
|
|
5
5
|
const PAGE_SIZE = 100;
|
|
6
6
|
const MAX_PAGES = 50;
|
|
7
|
+
// Well-known acronyms to skip in the undocumented acronym check
|
|
8
|
+
const WELL_KNOWN_ACRONYMS = new Set([
|
|
9
|
+
"ISO", "IEEE", "HTTP", "HTTPS", "TCP", "UDP", "IP", "USB", "RAM", "ROM",
|
|
10
|
+
"CPU", "GPU", "API", "URL", "XML", "JSON", "HTML", "CSS", "SQL", "PDF",
|
|
11
|
+
"NATO", "MIL", "STD", "DOD", "FAA", "NIST", "ANSI", "IEC", "ASME",
|
|
12
|
+
"REQ", "ID", "OK", "NA", "TBD", "TBC", "NB", "HW", "SW", "FW",
|
|
13
|
+
"AC", "DC", "RF", "IR", "UV", "IO", "OS", "UI", "UX", "DB",
|
|
14
|
+
"EARS", "UHT", "MTBF", "MTTF", "MTTR", "SIL", "ASIL", "DAL",
|
|
15
|
+
]);
|
|
7
16
|
// ── Helpers ──────────────────────────────────────────────────
|
|
8
17
|
async function fetchAllRequirements(client, tenant, project) {
|
|
9
18
|
const all = [];
|
|
@@ -18,21 +27,74 @@ async function fetchAllRequirements(client, tenant, project) {
|
|
|
18
27
|
}
|
|
19
28
|
return all.filter(r => !r.deleted && !r.deletedAt);
|
|
20
29
|
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
30
|
+
async function fetchTraceLinks(client, tenant, project) {
|
|
31
|
+
try {
|
|
32
|
+
const data = await client.get(`/trace-links/${tenant}/${project}`);
|
|
33
|
+
return data.traceLinks ?? [];
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return []; // Graceful fallback — trace-aware checks simply produce no findings
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
async function fetchDocumentTiers(client, tenant, project) {
|
|
40
|
+
try {
|
|
41
|
+
const data = await client.get(`/documents/${tenant}/${project}`);
|
|
42
|
+
const map = new Map();
|
|
43
|
+
for (const doc of data.documents ?? []) {
|
|
44
|
+
if (doc.shortCode)
|
|
45
|
+
map.set(doc.slug, doc.shortCode.toUpperCase());
|
|
46
|
+
}
|
|
47
|
+
return map;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return new Map();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function buildReqTierMap(requirements, docTierMap) {
|
|
54
|
+
const map = new Map();
|
|
55
|
+
for (const req of requirements) {
|
|
56
|
+
if (!req.ref)
|
|
57
|
+
continue;
|
|
58
|
+
if (req.documentSlug) {
|
|
59
|
+
const tier = docTierMap.get(req.documentSlug);
|
|
60
|
+
if (tier) {
|
|
61
|
+
map.set(req.ref, tier);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// Fallback: infer from ref prefix
|
|
66
|
+
const prefix = req.ref.split("-")[0]?.toUpperCase();
|
|
67
|
+
if (["STK", "SYS", "SUB", "IFC", "VER", "HAZ", "ARC"].includes(prefix)) {
|
|
68
|
+
map.set(req.ref, prefix);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return map;
|
|
72
|
+
}
|
|
73
|
+
async function parallelMap(items, fn, concurrency) {
|
|
74
|
+
const results = [];
|
|
75
|
+
let index = 0;
|
|
76
|
+
async function worker() {
|
|
77
|
+
while (index < items.length) {
|
|
78
|
+
const i = index++;
|
|
79
|
+
try {
|
|
80
|
+
results[i] = { item: items[i], result: await fn(items[i]) };
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
results[i] = { item: items[i], error: err };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, () => worker()));
|
|
88
|
+
return results;
|
|
89
|
+
}
|
|
90
|
+
// ── Concept Extraction ──────────────────────────────────────
|
|
28
91
|
function extractConcepts(requirements) {
|
|
29
92
|
const conceptRefs = new Map();
|
|
93
|
+
const skip = new Set(["system", "the system", "it", "this", "all", "each", "any", "user", "operator"]);
|
|
30
94
|
function addConcept(concept, ref) {
|
|
31
95
|
const normalized = concept.toLowerCase().trim();
|
|
32
96
|
if (normalized.length < 3 || normalized.length > 60)
|
|
33
97
|
return;
|
|
34
|
-
// Skip generic words
|
|
35
|
-
const skip = new Set(["system", "the system", "it", "this", "all", "each", "any"]);
|
|
36
98
|
if (skip.has(normalized))
|
|
37
99
|
return;
|
|
38
100
|
const refs = conceptRefs.get(normalized) ?? [];
|
|
@@ -44,23 +106,39 @@ function extractConcepts(requirements) {
|
|
|
44
106
|
if (!req.text || !req.ref)
|
|
45
107
|
continue;
|
|
46
108
|
const text = req.text;
|
|
47
|
-
// "The <concept> shall"
|
|
109
|
+
// Subject: "The <concept> shall"
|
|
48
110
|
const subjectMatch = text.match(/^(?:the|a|an)\s+(.+?)\s+shall\b/i);
|
|
49
111
|
if (subjectMatch)
|
|
50
112
|
addConcept(subjectMatch[1], req.ref);
|
|
51
|
-
// "If the <concept> detects/is/has..."
|
|
113
|
+
// Conditional: "If the <concept> detects/is/has..."
|
|
52
114
|
const ifMatch = text.match(/^if\s+the\s+(.+?)\s+(?:detects?|is|has|does|fails?|receives?)\b/i);
|
|
53
115
|
if (ifMatch)
|
|
54
116
|
addConcept(ifMatch[1], req.ref);
|
|
55
|
-
// "While the <concept> is..."
|
|
117
|
+
// Temporal: "While the <concept> is..."
|
|
56
118
|
const whileMatch = text.match(/^while\s+(?:the\s+)?(.+?)\s+is\b/i);
|
|
57
119
|
if (whileMatch)
|
|
58
120
|
addConcept(whileMatch[1], req.ref);
|
|
59
|
-
// "When the <concept> designates/detects..."
|
|
60
|
-
const whenMatch = text.match(/^when\s+the\s+(.+?)\s+(?:designates?|detects?|receives?|completes?)\b/i);
|
|
121
|
+
// Temporal: "When the <concept> designates/detects..."
|
|
122
|
+
const whenMatch = text.match(/^when\s+the\s+(.+?)\s+(?:designates?|detects?|receives?|completes?|initiates?|enters?)\b/i);
|
|
61
123
|
if (whenMatch)
|
|
62
124
|
addConcept(whenMatch[1], req.ref);
|
|
63
|
-
//
|
|
125
|
+
// Passive voice: "shall be provided/controlled/managed by X"
|
|
126
|
+
const passiveMatch = text.match(/shall\s+be\s+\w+(?:ed|en)\s+by\s+(?:the\s+)?(.+?)(?:\s+(?:for|to|at|in|with|when|if|unless)\b|[.,;]|$)/i);
|
|
127
|
+
if (passiveMatch)
|
|
128
|
+
addConcept(passiveMatch[1], req.ref);
|
|
129
|
+
// Object references: "shall transmit/receive X"
|
|
130
|
+
const objPatterns = [
|
|
131
|
+
/shall\s+(?:transmit|send|deliver|forward|relay|provide|output)\s+(?:the\s+)?(.+?)\s+to\s+(?:the\s+)?(.+?)(?:\s+(?:for|at|in|with)\b|[.,;]|$)/gi,
|
|
132
|
+
/shall\s+(?:receive|accept|collect|acquire|read)\s+(?:the\s+)?(.+?)\s+from\s+(?:the\s+)?(.+?)(?:\s+(?:for|at|in|with)\b|[.,;]|$)/gi,
|
|
133
|
+
];
|
|
134
|
+
for (const pat of objPatterns) {
|
|
135
|
+
let m;
|
|
136
|
+
while ((m = pat.exec(text)) !== null) {
|
|
137
|
+
addConcept(m[1], req.ref);
|
|
138
|
+
addConcept(m[2], req.ref);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
// Reference patterns: "using the X", "via X", "from the X", "to the X"
|
|
64
142
|
const refPatterns = [
|
|
65
143
|
/using\s+(?:the\s+)?(.+?)(?:\s+(?:for|to|at|in|with)\b|[.,;]|$)/gi,
|
|
66
144
|
/via\s+(?:the\s+)?(.+?)(?:\s+(?:for|to|at|in|with)\b|[.,;]|$)/gi,
|
|
@@ -74,22 +152,36 @@ function extractConcepts(requirements) {
|
|
|
74
152
|
addConcept(m[1], req.ref);
|
|
75
153
|
}
|
|
76
154
|
}
|
|
155
|
+
// Compound nouns: multi-word capitalized terms (skip sentence starts)
|
|
156
|
+
const compoundPattern = /\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+){1,4})\b/g;
|
|
157
|
+
let cm;
|
|
158
|
+
while ((cm = compoundPattern.exec(text)) !== null) {
|
|
159
|
+
const before = text.slice(0, cm.index);
|
|
160
|
+
if (before.length === 0 || /[.!?]\s*$/.test(before))
|
|
161
|
+
continue;
|
|
162
|
+
addConcept(cm[1], req.ref);
|
|
163
|
+
}
|
|
164
|
+
// Suffix references: "the X interface/module/subsystem"
|
|
165
|
+
const suffixPattern = /the\s+(.+?)\s+(interface|module|subsystem|component|unit|assembly|sensor|actuator|controller|processor|display|panel|bus|network|link)\b/gi;
|
|
166
|
+
let sm;
|
|
167
|
+
while ((sm = suffixPattern.exec(text)) !== null) {
|
|
168
|
+
addConcept(`${sm[1]} ${sm[2]}`, req.ref);
|
|
169
|
+
}
|
|
77
170
|
}
|
|
78
171
|
return conceptRefs;
|
|
79
172
|
}
|
|
80
|
-
/**
|
|
81
|
-
* Rank concepts by frequency and pick top N.
|
|
82
|
-
*/
|
|
83
173
|
function topConcepts(conceptRefs, maxCount) {
|
|
84
174
|
return [...conceptRefs.entries()]
|
|
85
175
|
.sort((a, b) => b[1].length - a[1].length)
|
|
86
176
|
.slice(0, maxCount);
|
|
87
177
|
}
|
|
88
178
|
// ── Analysis ─────────────────────────────────────────────────
|
|
89
|
-
function analyzeFindings(concepts, comparisons, requirements) {
|
|
179
|
+
function analyzeFindings(concepts, comparisons, requirements, traceLinks, reqTierMap, conceptRefs, options) {
|
|
90
180
|
const findings = [];
|
|
91
181
|
const conceptMap = new Map(concepts.map(c => [c.name, c]));
|
|
92
|
-
//
|
|
182
|
+
// Helper: get requirement texts for a concept
|
|
183
|
+
const reqTextsFor = (c) => c.reqs.map(ref => requirements.find(r => r.ref === ref)?.text ?? "").join(" ");
|
|
184
|
+
// ── 1a. Physical mismatch (existing) ──────────────────────
|
|
93
185
|
const envKeywords = /temperature|shock|vibrat|humidity|nbc|contamina|electromagnetic|emc|climatic/i;
|
|
94
186
|
for (const c of concepts) {
|
|
95
187
|
if (c.isPhysical)
|
|
@@ -103,18 +195,120 @@ function analyzeFindings(concepts, comparisons, requirements) {
|
|
|
103
195
|
severity: "high",
|
|
104
196
|
category: "Ontological Mismatch",
|
|
105
197
|
title: `"${c.name}" lacks Physical Object trait but has physical constraints`,
|
|
106
|
-
description: `UHT classifies "${c.name}" (${c.hexCode}) without
|
|
198
|
+
description: `UHT classifies "${c.name}" (${c.hexCode}) without Physical Object, but ${envReqs.length} requirement(s) impose environmental constraints.`,
|
|
107
199
|
affectedReqs: envReqs,
|
|
108
|
-
recommendation: `Add a requirement defining the physical embodiment of "${c.name}" (
|
|
200
|
+
recommendation: `Add a requirement defining the physical embodiment of "${c.name}" (housing, LRU, equipment rack).`,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
// ── 1b. Powered without power budget ──────────────────────
|
|
205
|
+
const powerKeywords = /power|voltage|current|battery|watt|energy|consumption|supply|mains|generator/i;
|
|
206
|
+
for (const c of concepts) {
|
|
207
|
+
if (!c.isPowered)
|
|
208
|
+
continue;
|
|
209
|
+
const allText = reqTextsFor(c);
|
|
210
|
+
if (!powerKeywords.test(allText)) {
|
|
211
|
+
findings.push({
|
|
212
|
+
severity: "high",
|
|
213
|
+
category: "Ontological Mismatch",
|
|
214
|
+
title: `"${c.name}" is Powered but no requirements specify power source or budget`,
|
|
215
|
+
description: `UHT classifies "${c.name}" (${c.hexCode}) with Powered trait, but no requirements mention power supply, voltage, current, or energy budget.`,
|
|
216
|
+
affectedReqs: c.reqs,
|
|
217
|
+
recommendation: `Add power source, voltage range, and consumption requirements for "${c.name}".`,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
// ── 1c. Autonomous without safety constraints ─────────────
|
|
222
|
+
const safetyKeywords = /safety|watchdog|override|kill.?switch|emergency.?stop|fail.?safe|interlock|manual.?override|human.?in.?the.?loop/i;
|
|
223
|
+
for (const c of concepts) {
|
|
224
|
+
if (!c.isAutonomous)
|
|
225
|
+
continue;
|
|
226
|
+
const allText = reqTextsFor(c);
|
|
227
|
+
if (!safetyKeywords.test(allText)) {
|
|
228
|
+
findings.push({
|
|
229
|
+
severity: "high",
|
|
230
|
+
category: "Ontological Mismatch",
|
|
231
|
+
title: `"${c.name}" is Functionally Autonomous but has no safety constraints`,
|
|
232
|
+
description: `UHT classifies "${c.name}" (${c.hexCode}) as autonomous, but no requirements specify safety constraints, watchdog, override, or emergency stop.`,
|
|
233
|
+
affectedReqs: c.reqs,
|
|
234
|
+
recommendation: `Add safety requirements: override mechanism, watchdog timer, fail-safe state, or human-in-the-loop constraints for "${c.name}".`,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
// ── 1d. Human-Interactive without usability ────────────────
|
|
239
|
+
const usabilityKeywords = /hmi|display|ergonomic|response.?time|feedback|indicator|user.?interface|touch|screen|readab|legib|contrast|font|icon/i;
|
|
240
|
+
for (const c of concepts) {
|
|
241
|
+
if (!c.isHumanInteractive)
|
|
242
|
+
continue;
|
|
243
|
+
const allText = reqTextsFor(c);
|
|
244
|
+
if (!usabilityKeywords.test(allText)) {
|
|
245
|
+
findings.push({
|
|
246
|
+
severity: "medium",
|
|
247
|
+
category: "Ontological Mismatch",
|
|
248
|
+
title: `"${c.name}" is Human-Interactive but has no usability requirements`,
|
|
249
|
+
description: `UHT classifies "${c.name}" (${c.hexCode}) as human-interactive, but no requirements address HMI, display, ergonomics, or user feedback.`,
|
|
250
|
+
affectedReqs: c.reqs,
|
|
251
|
+
recommendation: `Add usability requirements: display format, response time, ergonomic constraints, or feedback indicators for "${c.name}".`,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
// ── 1e. Digital/Virtual without cybersecurity ──────────────
|
|
256
|
+
const cyberKeywords = /security|authenticat|encrypt|access.?control|authoriz|cyber|firewall|certificate|tls|ssl|password|credential|privilege/i;
|
|
257
|
+
for (const c of concepts) {
|
|
258
|
+
if (!c.isDigitalVirtual)
|
|
259
|
+
continue;
|
|
260
|
+
const allText = reqTextsFor(c);
|
|
261
|
+
if (!cyberKeywords.test(allText)) {
|
|
262
|
+
findings.push({
|
|
263
|
+
severity: "medium",
|
|
264
|
+
category: "Ontological Mismatch",
|
|
265
|
+
title: `"${c.name}" is Digital/Virtual but has no cybersecurity requirements`,
|
|
266
|
+
description: `UHT classifies "${c.name}" (${c.hexCode}) as digital/virtual, but no requirements address security, authentication, encryption, or access control.`,
|
|
267
|
+
affectedReqs: c.reqs,
|
|
268
|
+
recommendation: `Add cybersecurity requirements: authentication, encryption, access control, or data protection for "${c.name}".`,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
// ── 1f. Regulated without compliance ──────────────────────
|
|
273
|
+
const complianceKeywords = /standard|compliance|certif|approv|regulat|accredit|qualif|homologat|type.?approv|conformance|audit/i;
|
|
274
|
+
for (const c of concepts) {
|
|
275
|
+
if (!c.isRegulated)
|
|
276
|
+
continue;
|
|
277
|
+
const allText = reqTextsFor(c);
|
|
278
|
+
if (!complianceKeywords.test(allText)) {
|
|
279
|
+
findings.push({
|
|
280
|
+
severity: "medium",
|
|
281
|
+
category: "Ontological Mismatch",
|
|
282
|
+
title: `"${c.name}" is Regulated but has no compliance requirements`,
|
|
283
|
+
description: `UHT classifies "${c.name}" (${c.hexCode}) as regulated, but no requirements reference standards, certification, or compliance.`,
|
|
284
|
+
affectedReqs: c.reqs,
|
|
285
|
+
recommendation: `Add compliance requirements: applicable standards, certification authority, or qualification criteria for "${c.name}".`,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
// ── 1g. System-Essential without redundancy ────────────────
|
|
290
|
+
const redundancyKeywords = /redundan|failover|backup|graceful.?degrad|hot.?standby|cold.?standby|replicate|fault.?toleran|availability|\d+\s*%.*uptime|single.?point.?of.?failure/i;
|
|
291
|
+
for (const c of concepts) {
|
|
292
|
+
if (!c.isSystemEssential)
|
|
293
|
+
continue;
|
|
294
|
+
const allText = reqTextsFor(c);
|
|
295
|
+
if (!redundancyKeywords.test(allText)) {
|
|
296
|
+
findings.push({
|
|
297
|
+
severity: "medium",
|
|
298
|
+
category: "Ontological Mismatch",
|
|
299
|
+
title: `"${c.name}" is System-Essential but has no redundancy requirements`,
|
|
300
|
+
description: `UHT classifies "${c.name}" (${c.hexCode}) as system-essential, but no requirements address redundancy, failover, or fault tolerance.`,
|
|
301
|
+
affectedReqs: c.reqs,
|
|
302
|
+
recommendation: `Add redundancy or availability requirements: failover mechanism, backup, or graceful degradation for "${c.name}".`,
|
|
109
303
|
});
|
|
110
304
|
}
|
|
111
305
|
}
|
|
112
|
-
// 2. Abstract metrics without statistical parameters
|
|
306
|
+
// ── 2. Abstract metrics without statistical parameters ────
|
|
113
307
|
const metricKeywords = /probability|rate|percentage|ratio|mtbf|availability/i;
|
|
114
308
|
const statKeywords = /confidence|sample size|number of|minimum of \d+ |statistical/i;
|
|
115
309
|
for (const c of concepts) {
|
|
116
310
|
if (c.traits.length > 3)
|
|
117
|
-
continue;
|
|
311
|
+
continue;
|
|
118
312
|
const metricReqs = c.reqs.filter(ref => {
|
|
119
313
|
const req = requirements.find(r => r.ref === ref);
|
|
120
314
|
return req?.text && metricKeywords.test(req.text);
|
|
@@ -130,13 +324,13 @@ function analyzeFindings(concepts, comparisons, requirements) {
|
|
|
130
324
|
severity: "medium",
|
|
131
325
|
category: "Missing Statistical Context",
|
|
132
326
|
title: `"${c.name}" is an abstract metric without statistical parameters`,
|
|
133
|
-
description: `"${c.name}" (${c.hexCode}) has only ${c.traits.length} UHT traits
|
|
327
|
+
description: `"${c.name}" (${c.hexCode}) has only ${c.traits.length} UHT traits. Requirements set thresholds but don't specify confidence level, sample size, or test conditions.`,
|
|
134
328
|
affectedReqs: metricReqs,
|
|
135
329
|
recommendation: `Add statistical parameters (confidence level, sample size, conditions) to requirements referencing "${c.name}".`,
|
|
136
330
|
});
|
|
137
331
|
}
|
|
138
332
|
}
|
|
139
|
-
// 3. Verification
|
|
333
|
+
// ── 3. Verification/functional mixing ─────────────────────
|
|
140
334
|
const verificationReqs = requirements.filter(r => r.text && /shall be verified|verification|shall be demonstrated|shall be tested/i.test(r.text));
|
|
141
335
|
const functionalReqs = requirements.filter(r => r.text && /shall\b/i.test(r.text) && !/shall be verified|verification/i.test(r.text));
|
|
142
336
|
if (verificationReqs.length > 0 && functionalReqs.length > 0) {
|
|
@@ -146,69 +340,190 @@ function analyzeFindings(concepts, comparisons, requirements) {
|
|
|
146
340
|
severity: "medium",
|
|
147
341
|
category: "Structural Issue",
|
|
148
342
|
title: "Verification requirements mixed with functional requirements",
|
|
149
|
-
description: `${verificationReqs.length} verification requirement(s) (${(ratio * 100).toFixed(0)}%) are co-mingled with ${functionalReqs.length} functional requirements
|
|
343
|
+
description: `${verificationReqs.length} verification requirement(s) (${(ratio * 100).toFixed(0)}%) are co-mingled with ${functionalReqs.length} functional requirements.`,
|
|
150
344
|
affectedReqs: verificationReqs.map(r => r.ref).filter(Boolean),
|
|
151
|
-
recommendation: "Move verification requirements to a separate document or tag them with a distinct pattern.
|
|
345
|
+
recommendation: "Move verification requirements to a separate document or tag them with a distinct pattern.",
|
|
152
346
|
});
|
|
153
347
|
}
|
|
154
348
|
}
|
|
155
|
-
// 4. Degraded mode gaps
|
|
349
|
+
// ── 4. Degraded mode gaps ─────────────────────────────────
|
|
156
350
|
const degradedReqs = requirements.filter(r => r.text && /manual\s+(?:reversion|mode|override|backup)|fallback|degraded/i.test(r.text));
|
|
157
|
-
// Numeric performance patterns: "10 Hz", "3.6m", "250 ms", "95%", "< 5 seconds", ">= 99.9%",
|
|
158
|
-
// "within 100ms", "at least 10", units with numbers, ranges like "5-10"
|
|
159
351
|
const perfPattern = /\d+\.?\d*\s*(?:%|Hz|kHz|MHz|s\b|ms\b|sec|second|minute|min\b|hour|hr|m\b|km|cm|mm|m\/s|km\/h|mph|kph|dB|dBm|°C|°F|K\b|V\b|A\b|W\b|kW|MW|N\b|kN|Pa|kPa|MPa|bar|psi|kg|g\b|lb|fps|bps|Mbps|Gbps)|(?:within|under|below|above|at least|no (?:more|less) than|maximum|minimum)\s+\d|\d+\s*(?:to|-)\s*\d/i;
|
|
160
352
|
for (const req of degradedReqs) {
|
|
161
|
-
|
|
162
|
-
if (!hasPerf) {
|
|
353
|
+
if (!perfPattern.test(req.text ?? "")) {
|
|
163
354
|
findings.push({
|
|
164
355
|
severity: "medium",
|
|
165
356
|
category: "Coverage Gap",
|
|
166
357
|
title: `Degraded mode without performance criteria: ${req.ref}`,
|
|
167
|
-
description: `${req.ref} specifies a degraded/manual mode but provides no measurable acceptance criteria
|
|
358
|
+
description: `${req.ref} specifies a degraded/manual mode but provides no measurable acceptance criteria.`,
|
|
168
359
|
affectedReqs: [req.ref],
|
|
169
|
-
recommendation: "Add measurable performance criteria for degraded operation
|
|
360
|
+
recommendation: "Add measurable performance criteria for degraded operation.",
|
|
170
361
|
});
|
|
171
362
|
}
|
|
172
363
|
}
|
|
173
|
-
// 5. Cross-
|
|
364
|
+
// ── 5. Cross-concept ambiguity ────────────────────────────
|
|
174
365
|
for (const batch of comparisons) {
|
|
175
366
|
for (const comp of batch.comparisons) {
|
|
176
367
|
const a = conceptMap.get(batch.entity);
|
|
177
368
|
const b = conceptMap.get(comp.candidate);
|
|
178
369
|
if (!a || !b)
|
|
179
370
|
continue;
|
|
180
|
-
|
|
181
|
-
if (comp.jaccard_similarity > 0.6 && a.isPhysical !== b.isPhysical) {
|
|
371
|
+
if (comp.jaccard_similarity > options.jaccardThreshold && a.isPhysical !== b.isPhysical) {
|
|
182
372
|
findings.push({
|
|
183
373
|
severity: "low",
|
|
184
374
|
category: "Ontological Ambiguity",
|
|
185
|
-
title: `"${a.name}" and "${b.name}"
|
|
186
|
-
description: `"${a.name}" is ${a.isPhysical ? "" : "not "}
|
|
375
|
+
title: `"${a.name}" and "${b.name}" similar (${(comp.jaccard_similarity * 100).toFixed(0)}%) but differ in physical classification`,
|
|
376
|
+
description: `"${a.name}" is ${a.isPhysical ? "" : "not "}Physical Object; "${b.name}" is ${b.isPhysical ? "" : "not "}. High Jaccard similarity suggests they should be treated consistently.`,
|
|
187
377
|
affectedReqs: [...a.reqs, ...b.reqs],
|
|
188
|
-
recommendation:
|
|
378
|
+
recommendation: "Review whether both concepts should have consistent physical classification.",
|
|
189
379
|
});
|
|
190
380
|
}
|
|
191
381
|
}
|
|
192
382
|
}
|
|
193
|
-
// 6.
|
|
383
|
+
// ── 6. Weak language (no "shall") ─────────────────────────
|
|
194
384
|
const weakReqs = requirements.filter(r => r.text && !/\bshall\b/i.test(r.text) && !/shall be verified/i.test(r.text));
|
|
195
385
|
if (weakReqs.length > 0) {
|
|
196
386
|
findings.push({
|
|
197
387
|
severity: "low",
|
|
198
388
|
category: "Language Quality",
|
|
199
389
|
title: `${weakReqs.length} requirement(s) lack "shall" keyword`,
|
|
200
|
-
description:
|
|
390
|
+
description: "Requirements without \"shall\" may be informational text rather than testable requirements.",
|
|
201
391
|
affectedReqs: weakReqs.map(r => r.ref).filter(Boolean),
|
|
202
|
-
recommendation:
|
|
392
|
+
recommendation: "Rephrase using \"shall\" for testable requirements, or move informational text to notes/rationale.",
|
|
203
393
|
});
|
|
204
394
|
}
|
|
395
|
+
// ── 7. Spray pattern: mass links without rationale ────────
|
|
396
|
+
if (traceLinks.length > 0) {
|
|
397
|
+
const outgoing = new Map();
|
|
398
|
+
for (const link of traceLinks) {
|
|
399
|
+
const src = link.sourceRef ?? link.sourceRequirementId ?? "";
|
|
400
|
+
if (!outgoing.has(src))
|
|
401
|
+
outgoing.set(src, []);
|
|
402
|
+
outgoing.get(src).push(link);
|
|
403
|
+
}
|
|
404
|
+
for (const [reqRef, links] of outgoing) {
|
|
405
|
+
if (links.length > options.sprayThreshold) {
|
|
406
|
+
const hasAnyRationale = links.some(l => l.rationale && l.rationale.trim().length > 0);
|
|
407
|
+
if (!hasAnyRationale) {
|
|
408
|
+
findings.push({
|
|
409
|
+
severity: "high",
|
|
410
|
+
category: "Trace Quality",
|
|
411
|
+
title: `Spray pattern: ${reqRef} has ${links.length} outgoing links with no rationale`,
|
|
412
|
+
description: `${reqRef} has ${links.length} outgoing trace links, none with engineering rationale. This suggests mechanical linking.`,
|
|
413
|
+
affectedReqs: [reqRef],
|
|
414
|
+
recommendation: `Review trace links from ${reqRef}. Add rationale to justified links and remove spurious ones.`,
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
// ── 8. Concept coverage gaps across document tiers ────────
|
|
421
|
+
if (reqTierMap.size > 0 && conceptRefs.size > 0) {
|
|
422
|
+
const tiersPresent = new Set(reqTierMap.values());
|
|
423
|
+
for (const [concept, refs] of conceptRefs) {
|
|
424
|
+
const tiers = new Set();
|
|
425
|
+
for (const ref of refs) {
|
|
426
|
+
const tier = reqTierMap.get(ref);
|
|
427
|
+
if (tier)
|
|
428
|
+
tiers.add(tier);
|
|
429
|
+
}
|
|
430
|
+
if (tiers.has("STK") && !tiers.has("SYS") && !tiers.has("SUB") && tiersPresent.has("SYS")) {
|
|
431
|
+
findings.push({
|
|
432
|
+
severity: "medium",
|
|
433
|
+
category: "Coverage Gap",
|
|
434
|
+
title: `Concept "${concept}" in STK but not in SYS or SUB`,
|
|
435
|
+
description: `"${concept}" is referenced in stakeholder requirements but has no corresponding system or subsystem requirements.`,
|
|
436
|
+
affectedReqs: refs.filter(r => reqTierMap.get(r) === "STK"),
|
|
437
|
+
recommendation: `Create system-level requirements addressing "${concept}" to ensure stakeholder intent flows down.`,
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
if (tiers.has("SYS") && !tiers.has("SUB") && tiers.size === 1 && tiersPresent.has("SUB")) {
|
|
441
|
+
findings.push({
|
|
442
|
+
severity: "medium",
|
|
443
|
+
category: "Coverage Gap",
|
|
444
|
+
title: `Concept "${concept}" in SYS but not in SUB`,
|
|
445
|
+
description: `"${concept}" is referenced in system requirements but has no corresponding subsystem requirements.`,
|
|
446
|
+
affectedReqs: refs.filter(r => reqTierMap.get(r) === "SYS"),
|
|
447
|
+
recommendation: `Create subsystem requirements decomposing "${concept}" for implementation traceability.`,
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
// ── 9. Contradictory requirements on same concept ─────────
|
|
453
|
+
const numericExtract = /(?:shall\s+(?:not\s+)?(?:exceed|be (?:less|greater|more|fewer) than|be (?:at least|at most|no more than|no less than)))\s+(\d+(?:\.\d+)?)/i;
|
|
454
|
+
const negPattern = /shall\s+not\b/i;
|
|
455
|
+
for (const c of concepts) {
|
|
456
|
+
if (c.reqs.length < 2)
|
|
457
|
+
continue;
|
|
458
|
+
const withNums = [];
|
|
459
|
+
for (const ref of c.reqs) {
|
|
460
|
+
const req = requirements.find(r => r.ref === ref);
|
|
461
|
+
if (!req?.text)
|
|
462
|
+
continue;
|
|
463
|
+
const nm = req.text.match(numericExtract);
|
|
464
|
+
if (nm)
|
|
465
|
+
withNums.push({ ref, value: parseFloat(nm[1]), negated: negPattern.test(req.text) });
|
|
466
|
+
}
|
|
467
|
+
for (let i = 0; i < withNums.length; i++) {
|
|
468
|
+
for (let j = i + 1; j < withNums.length; j++) {
|
|
469
|
+
const a = withNums[i], b = withNums[j];
|
|
470
|
+
if (a.negated !== b.negated) {
|
|
471
|
+
const pos = a.negated ? b : a;
|
|
472
|
+
const neg = a.negated ? a : b;
|
|
473
|
+
if (neg.value < pos.value) {
|
|
474
|
+
findings.push({
|
|
475
|
+
severity: "medium",
|
|
476
|
+
category: "Contradiction",
|
|
477
|
+
title: `Contradictory thresholds on "${c.name}": ${pos.ref} vs ${neg.ref}`,
|
|
478
|
+
description: `${pos.ref} requires exceeding ${pos.value} but ${neg.ref} caps at ${neg.value} (lower).`,
|
|
479
|
+
affectedReqs: [pos.ref, neg.ref],
|
|
480
|
+
recommendation: `Reconcile conflicting thresholds for "${c.name}".`,
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
// ── 10. Undocumented acronyms ─────────────────────────────
|
|
488
|
+
const acronymPattern = /\b([A-Z]{2,})\b/g;
|
|
489
|
+
const allText = requirements.map(r => `${r.text ?? ""} ${r.rationale ?? ""}`).join(" ");
|
|
490
|
+
const allAcronyms = new Map();
|
|
491
|
+
for (const req of requirements) {
|
|
492
|
+
if (!req.text || !req.ref)
|
|
493
|
+
continue;
|
|
494
|
+
let m;
|
|
495
|
+
while ((m = acronymPattern.exec(req.text)) !== null) {
|
|
496
|
+
const acr = m[1];
|
|
497
|
+
if (WELL_KNOWN_ACRONYMS.has(acr))
|
|
498
|
+
continue;
|
|
499
|
+
if (!allAcronyms.has(acr))
|
|
500
|
+
allAcronyms.set(acr, []);
|
|
501
|
+
const refs = allAcronyms.get(acr);
|
|
502
|
+
if (!refs.includes(req.ref))
|
|
503
|
+
refs.push(req.ref);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
for (const [acr, refs] of allAcronyms) {
|
|
507
|
+
// Check for expansion: "Full Name (ACR)" or "ACR (Full Name)"
|
|
508
|
+
const defPat = new RegExp(`[A-Za-z][a-z]+(?:\\s+[A-Za-z][a-z]+)*\\s*\\(${acr}\\)|${acr}\\s*\\([A-Za-z][a-z]+(?:\\s+[A-Za-z][a-z]+)*\\)`, "i");
|
|
509
|
+
if (!defPat.test(allText)) {
|
|
510
|
+
findings.push({
|
|
511
|
+
severity: "low",
|
|
512
|
+
category: "Documentation Quality",
|
|
513
|
+
title: `Acronym "${acr}" used but never defined`,
|
|
514
|
+
description: `"${acr}" appears in ${refs.length} requirement(s) but no expansion was found.`,
|
|
515
|
+
affectedReqs: refs.slice(0, 10),
|
|
516
|
+
recommendation: `Define "${acr}" on first use or in a glossary document.`,
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
}
|
|
205
520
|
return findings.sort((a, b) => {
|
|
206
521
|
const sev = { high: 0, medium: 1, low: 2 };
|
|
207
522
|
return sev[a.severity] - sev[b.severity];
|
|
208
523
|
});
|
|
209
524
|
}
|
|
210
525
|
// ── Report formatting ────────────────────────────────────────
|
|
211
|
-
function formatReport(tenant, project, requirements, concepts, comparisons, findings) {
|
|
526
|
+
function formatReport(tenant, project, requirements, traceLinks, concepts, comparisons, findings) {
|
|
212
527
|
const lines = [];
|
|
213
528
|
const high = findings.filter(f => f.severity === "high").length;
|
|
214
529
|
const med = findings.filter(f => f.severity === "medium").length;
|
|
@@ -216,17 +531,16 @@ function formatReport(tenant, project, requirements, concepts, comparisons, find
|
|
|
216
531
|
lines.push(" Semantic Lint Report");
|
|
217
532
|
lines.push(" ════════════════════");
|
|
218
533
|
lines.push(` Project: ${project} (${tenant})`);
|
|
219
|
-
lines.push(` Requirements: ${requirements.length} |
|
|
534
|
+
lines.push(` Requirements: ${requirements.length} | Trace links: ${traceLinks.length} | Concepts: ${concepts.length}`);
|
|
220
535
|
lines.push(` Findings: ${findings.length} (${high} high, ${med} medium, ${low} low)`);
|
|
221
536
|
lines.push("");
|
|
222
|
-
// Concept classifications
|
|
537
|
+
// Concept classifications
|
|
223
538
|
lines.push(" ┄┄ Concept Classifications ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄");
|
|
224
539
|
lines.push("");
|
|
225
540
|
const nameW = Math.max(...concepts.map(c => c.name.length), 10);
|
|
226
541
|
for (const c of concepts) {
|
|
227
|
-
const phys = c.isPhysical ? "Physical" : "Abstract";
|
|
228
542
|
const pad = " ".repeat(Math.max(0, nameW - c.name.length));
|
|
229
|
-
lines.push(` ${c.name}${pad} ${c.hexCode} ${
|
|
543
|
+
lines.push(` ${c.name}${pad} ${c.hexCode} ${c.traits.slice(0, 5).join(", ")}${c.traits.length > 5 ? "..." : ""}`);
|
|
230
544
|
}
|
|
231
545
|
lines.push("");
|
|
232
546
|
// Cross-comparison highlights
|
|
@@ -236,8 +550,7 @@ function formatReport(tenant, project, requirements, concepts, comparisons, find
|
|
|
236
550
|
for (const batch of comparisons) {
|
|
237
551
|
for (const comp of batch.comparisons) {
|
|
238
552
|
if (comp.jaccard_similarity >= 0.4) {
|
|
239
|
-
|
|
240
|
-
lines.push(` ${batch.entity} ↔ ${comp.candidate}: ${pct}% Jaccard`);
|
|
553
|
+
lines.push(` ${batch.entity} ↔ ${comp.candidate}: ${(comp.jaccard_similarity * 100).toFixed(0)}% Jaccard`);
|
|
241
554
|
}
|
|
242
555
|
}
|
|
243
556
|
}
|
|
@@ -262,25 +575,23 @@ function formatReport(tenant, project, requirements, concepts, comparisons, find
|
|
|
262
575
|
}
|
|
263
576
|
return lines.join("\n");
|
|
264
577
|
}
|
|
265
|
-
function formatMarkdown(tenant, project, requirements, concepts, comparisons, findings) {
|
|
578
|
+
function formatMarkdown(tenant, project, requirements, traceLinks, concepts, comparisons, findings) {
|
|
266
579
|
const lines = [];
|
|
267
580
|
const high = findings.filter(f => f.severity === "high").length;
|
|
268
581
|
const med = findings.filter(f => f.severity === "medium").length;
|
|
269
582
|
const low = findings.filter(f => f.severity === "low").length;
|
|
270
583
|
lines.push("## Semantic Lint Report");
|
|
271
584
|
lines.push(`**Project:** ${project} (\`${tenant}\`) `);
|
|
272
|
-
lines.push(`**Requirements:** ${requirements.length} | **
|
|
585
|
+
lines.push(`**Requirements:** ${requirements.length} | **Trace links:** ${traceLinks.length} | **Concepts:** ${concepts.length} `);
|
|
273
586
|
lines.push(`**Findings:** ${findings.length} (${high} high, ${med} medium, ${low} low)`);
|
|
274
587
|
lines.push("");
|
|
275
|
-
// Concept table
|
|
276
588
|
lines.push("### Concept Classifications");
|
|
277
|
-
lines.push("| Concept | UHT Code |
|
|
278
|
-
lines.push("
|
|
589
|
+
lines.push("| Concept | UHT Code | Key Traits |");
|
|
590
|
+
lines.push("|---|---|---|");
|
|
279
591
|
for (const c of concepts) {
|
|
280
|
-
lines.push(`| ${c.name} | \`${c.hexCode}\` | ${c.
|
|
592
|
+
lines.push(`| ${c.name} | \`${c.hexCode}\` | ${c.traits.slice(0, 5).join(", ")} |`);
|
|
281
593
|
}
|
|
282
594
|
lines.push("");
|
|
283
|
-
// Similarities
|
|
284
595
|
if (comparisons.length > 0) {
|
|
285
596
|
lines.push("### Key Similarities");
|
|
286
597
|
lines.push("| Pair | Jaccard |");
|
|
@@ -294,7 +605,6 @@ function formatMarkdown(tenant, project, requirements, concepts, comparisons, fi
|
|
|
294
605
|
}
|
|
295
606
|
lines.push("");
|
|
296
607
|
}
|
|
297
|
-
// Findings
|
|
298
608
|
lines.push("### Findings");
|
|
299
609
|
lines.push("| # | Severity | Title | Affected |");
|
|
300
610
|
lines.push("|---|---|---|---|");
|
|
@@ -317,7 +627,7 @@ function formatMarkdown(tenant, project, requirements, concepts, comparisons, fi
|
|
|
317
627
|
export function registerLintCommands(program, client) {
|
|
318
628
|
program
|
|
319
629
|
.command("lint")
|
|
320
|
-
.description("Semantic requirements lint — classifies
|
|
630
|
+
.description("Semantic requirements lint — classifies concepts via UHT, checks trait consistency, analyzes trace quality")
|
|
321
631
|
.argument("<tenant>", "Tenant slug")
|
|
322
632
|
.argument("<project>", "Project slug")
|
|
323
633
|
.option("--concepts <n>", "Max concepts to classify", "15")
|
|
@@ -326,6 +636,8 @@ export function registerLintCommands(program, client) {
|
|
|
326
636
|
.option("--suppress <titles...>", "Suppress findings by title substring (repeatable)")
|
|
327
637
|
.option("--baseline <file>", "Suppress findings listed in a baseline file (one title per line)")
|
|
328
638
|
.option("--save-baseline <file>", "Write current finding titles to a baseline file for future suppression")
|
|
639
|
+
.option("--threshold <n>", "Jaccard similarity threshold (0.0-1.0)", "0.6")
|
|
640
|
+
.option("--spray-threshold <n>", "Outgoing trace link count for spray pattern detection", "8")
|
|
329
641
|
.action(async (tenant, project, opts) => {
|
|
330
642
|
const uht = new UhtClient();
|
|
331
643
|
if (!uht.isConfigured) {
|
|
@@ -334,44 +646,59 @@ export function registerLintCommands(program, client) {
|
|
|
334
646
|
process.exit(1);
|
|
335
647
|
}
|
|
336
648
|
const maxConcepts = parseInt(opts.concepts, 10) || 15;
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
649
|
+
const jaccardThreshold = parseFloat(opts.threshold) || 0.6;
|
|
650
|
+
const sprayThreshold = parseInt(opts.sprayThreshold, 10) || 8;
|
|
651
|
+
// Step 1: Fetch requirements, trace links, and documents in parallel
|
|
652
|
+
console.error("Fetching project data...");
|
|
653
|
+
const [requirements, traceLinks, docTierMap] = await Promise.all([
|
|
654
|
+
fetchAllRequirements(client, tenant, project),
|
|
655
|
+
fetchTraceLinks(client, tenant, project),
|
|
656
|
+
fetchDocumentTiers(client, tenant, project),
|
|
657
|
+
]);
|
|
340
658
|
if (requirements.length === 0) {
|
|
341
659
|
console.error("No requirements found.");
|
|
342
660
|
process.exit(1);
|
|
343
661
|
}
|
|
344
|
-
|
|
662
|
+
const reqTierMap = buildReqTierMap(requirements, docTierMap);
|
|
663
|
+
console.error(` ${requirements.length} requirements, ${traceLinks.length} trace links, ${docTierMap.size} documents loaded.`);
|
|
345
664
|
// Step 2: Extract domain concepts
|
|
346
665
|
console.error("Extracting domain concepts...");
|
|
347
666
|
const conceptRefs = extractConcepts(requirements);
|
|
348
667
|
const top = topConcepts(conceptRefs, maxConcepts);
|
|
349
668
|
console.error(` ${conceptRefs.size} unique concepts found, classifying top ${top.length}.`);
|
|
350
|
-
// Step 3: Classify
|
|
669
|
+
// Step 3: Classify concepts via UHT (parallelized)
|
|
351
670
|
console.error("Classifying concepts via UHT...");
|
|
671
|
+
const classifyResults = await parallelMap(top, async ([name]) => uht.classify(name), 5);
|
|
352
672
|
const concepts = [];
|
|
353
|
-
for (
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
673
|
+
for (let i = 0; i < classifyResults.length; i++) {
|
|
674
|
+
const entry = classifyResults[i];
|
|
675
|
+
const [name, refs] = top[i];
|
|
676
|
+
if (entry.result) {
|
|
677
|
+
const traitNames = entry.result.traits.map(t => t.name).filter(Boolean);
|
|
678
|
+
const has = (t) => traitNames.includes(t);
|
|
357
679
|
concepts.push({
|
|
358
680
|
name,
|
|
359
|
-
hexCode: result.hex_code,
|
|
360
|
-
isPhysical: traitNames.includes("Physical Object"),
|
|
681
|
+
hexCode: entry.result.hex_code,
|
|
361
682
|
traits: traitNames,
|
|
683
|
+
isPhysical: has("Physical Object"),
|
|
684
|
+
isPowered: has("Powered"),
|
|
685
|
+
isHumanInteractive: has("Human-Interactive"),
|
|
686
|
+
isAutonomous: has("Functionally Autonomous"),
|
|
687
|
+
isSystemEssential: has("System-Essential"),
|
|
688
|
+
isDigitalVirtual: has("Digital/Virtual"),
|
|
689
|
+
isRegulated: has("Regulated"),
|
|
362
690
|
reqs: refs,
|
|
363
691
|
});
|
|
364
|
-
console.error(` ✓ ${name} → ${result.hex_code} (${traitNames.length} traits)`);
|
|
692
|
+
console.error(` ✓ ${name} → ${entry.result.hex_code} (${traitNames.length} traits)`);
|
|
365
693
|
}
|
|
366
|
-
|
|
367
|
-
console.error(` ✗ ${name}: ${
|
|
694
|
+
else if (entry.error) {
|
|
695
|
+
console.error(` ✗ ${name}: ${entry.error.message}`);
|
|
368
696
|
}
|
|
369
697
|
}
|
|
370
698
|
// Step 4: Cross-compare concepts in batches
|
|
371
699
|
console.error("Cross-comparing concepts...");
|
|
372
700
|
const comparisons = [];
|
|
373
701
|
if (concepts.length >= 2) {
|
|
374
|
-
// Compare top concept against others, then second against rest
|
|
375
702
|
const names = concepts.map(c => c.name);
|
|
376
703
|
const batchSize = Math.min(names.length - 1, 15);
|
|
377
704
|
try {
|
|
@@ -397,8 +724,8 @@ export function registerLintCommands(program, client) {
|
|
|
397
724
|
}
|
|
398
725
|
// Step 5: Analyze findings
|
|
399
726
|
console.error("Analyzing...");
|
|
400
|
-
let findings = analyzeFindings(concepts, comparisons, requirements);
|
|
401
|
-
// Step 5b: Save baseline
|
|
727
|
+
let findings = analyzeFindings(concepts, comparisons, requirements, traceLinks, reqTierMap, conceptRefs, { sprayThreshold, jaccardThreshold });
|
|
728
|
+
// Step 5b: Save baseline (before suppression)
|
|
402
729
|
if (opts.saveBaseline) {
|
|
403
730
|
const titles = findings.map(f => f.title);
|
|
404
731
|
writeFileSync(opts.saveBaseline, titles.join("\n") + "\n", "utf-8");
|
|
@@ -408,28 +735,30 @@ export function registerLintCommands(program, client) {
|
|
|
408
735
|
const suppressions = [...(opts.suppress ?? [])];
|
|
409
736
|
if (opts.baseline && existsSync(opts.baseline)) {
|
|
410
737
|
const baselineContent = readFileSync(opts.baseline, "utf-8");
|
|
411
|
-
|
|
412
|
-
suppressions.push(...baselineLines);
|
|
738
|
+
suppressions.push(...baselineContent.split("\n").map(l => l.trim()).filter(Boolean));
|
|
413
739
|
}
|
|
414
740
|
if (suppressions.length > 0) {
|
|
415
741
|
const before = findings.length;
|
|
416
742
|
findings = findings.filter(f => !suppressions.some(s => f.title.includes(s)));
|
|
417
743
|
const suppressed = before - findings.length;
|
|
418
|
-
if (suppressed > 0)
|
|
744
|
+
if (suppressed > 0)
|
|
419
745
|
console.error(`Suppressed ${suppressed} known finding(s).`);
|
|
420
|
-
}
|
|
421
746
|
}
|
|
422
747
|
// Step 6: Output report
|
|
423
748
|
let report;
|
|
424
749
|
if (opts.format === "json" || isJsonMode()) {
|
|
425
|
-
|
|
426
|
-
|
|
750
|
+
report = JSON.stringify({
|
|
751
|
+
tenant, project,
|
|
752
|
+
requirements: requirements.length,
|
|
753
|
+
traceLinks: traceLinks.length,
|
|
754
|
+
concepts, comparisons, findings,
|
|
755
|
+
}, null, 2);
|
|
427
756
|
}
|
|
428
757
|
else if (opts.format === "markdown") {
|
|
429
|
-
report = formatMarkdown(tenant, project, requirements, concepts, comparisons, findings);
|
|
758
|
+
report = formatMarkdown(tenant, project, requirements, traceLinks, concepts, comparisons, findings);
|
|
430
759
|
}
|
|
431
760
|
else {
|
|
432
|
-
report = formatReport(tenant, project, requirements, concepts, comparisons, findings);
|
|
761
|
+
report = formatReport(tenant, project, requirements, traceLinks, concepts, comparisons, findings);
|
|
433
762
|
}
|
|
434
763
|
if (opts.output) {
|
|
435
764
|
writeFileSync(opts.output, report + "\n", "utf-8");
|