airgen-cli 0.12.0 → 0.14.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/diagrams.js +4 -0
- package/dist/commands/lint.js +425 -85
- package/dist/commands/requirements.js +137 -1
- package/package.json +1 -1
|
@@ -264,6 +264,7 @@ export function registerDiagramCommands(program, client) {
|
|
|
264
264
|
.option("--direction <dir>", "Layout direction for mermaid: TB, LR, BT, RL", "TB")
|
|
265
265
|
.option("-o, --output <file>", "Write to file instead of stdout")
|
|
266
266
|
.option("--wrap", "Wrap mermaid in markdown fenced code block")
|
|
267
|
+
.option("--clean", "Strip classDef/class/style lines from mermaid output (maximum compatibility)")
|
|
267
268
|
.action(async (tenant, project, id, opts) => {
|
|
268
269
|
// Fetch diagram metadata + blocks + connectors in parallel
|
|
269
270
|
const [diagramData, blocksData, connectorsData] = await Promise.all([
|
|
@@ -281,6 +282,9 @@ export function registerDiagramCommands(program, client) {
|
|
|
281
282
|
let rendered;
|
|
282
283
|
if (opts.format === "mermaid") {
|
|
283
284
|
rendered = renderMermaid(blocks, connectors, opts.direction);
|
|
285
|
+
if (opts.clean) {
|
|
286
|
+
rendered = rendered.split("\n").filter(l => !l.trim().startsWith("classDef ") && !l.trim().startsWith("class ") && !l.trim().startsWith("style ")).join("\n");
|
|
287
|
+
}
|
|
284
288
|
if (isJsonMode()) {
|
|
285
289
|
output({ mermaid: rendered, blocks: blocks.length, connectors: connectors.length });
|
|
286
290
|
return;
|
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,9 @@ 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")
|
|
641
|
+
.option("--min-severity <level>", "Only show findings at this severity or above: low, medium, high", "low")
|
|
329
642
|
.action(async (tenant, project, opts) => {
|
|
330
643
|
const uht = new UhtClient();
|
|
331
644
|
if (!uht.isConfigured) {
|
|
@@ -334,44 +647,59 @@ export function registerLintCommands(program, client) {
|
|
|
334
647
|
process.exit(1);
|
|
335
648
|
}
|
|
336
649
|
const maxConcepts = parseInt(opts.concepts, 10) || 15;
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
650
|
+
const jaccardThreshold = parseFloat(opts.threshold) || 0.6;
|
|
651
|
+
const sprayThreshold = parseInt(opts.sprayThreshold, 10) || 8;
|
|
652
|
+
// Step 1: Fetch requirements, trace links, and documents in parallel
|
|
653
|
+
console.error("Fetching project data...");
|
|
654
|
+
const [requirements, traceLinks, docTierMap] = await Promise.all([
|
|
655
|
+
fetchAllRequirements(client, tenant, project),
|
|
656
|
+
fetchTraceLinks(client, tenant, project),
|
|
657
|
+
fetchDocumentTiers(client, tenant, project),
|
|
658
|
+
]);
|
|
340
659
|
if (requirements.length === 0) {
|
|
341
660
|
console.error("No requirements found.");
|
|
342
661
|
process.exit(1);
|
|
343
662
|
}
|
|
344
|
-
|
|
663
|
+
const reqTierMap = buildReqTierMap(requirements, docTierMap);
|
|
664
|
+
console.error(` ${requirements.length} requirements, ${traceLinks.length} trace links, ${docTierMap.size} documents loaded.`);
|
|
345
665
|
// Step 2: Extract domain concepts
|
|
346
666
|
console.error("Extracting domain concepts...");
|
|
347
667
|
const conceptRefs = extractConcepts(requirements);
|
|
348
668
|
const top = topConcepts(conceptRefs, maxConcepts);
|
|
349
669
|
console.error(` ${conceptRefs.size} unique concepts found, classifying top ${top.length}.`);
|
|
350
|
-
// Step 3: Classify
|
|
670
|
+
// Step 3: Classify concepts via UHT (parallelized)
|
|
351
671
|
console.error("Classifying concepts via UHT...");
|
|
672
|
+
const classifyResults = await parallelMap(top, async ([name]) => uht.classify(name), 5);
|
|
352
673
|
const concepts = [];
|
|
353
|
-
for (
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
674
|
+
for (let i = 0; i < classifyResults.length; i++) {
|
|
675
|
+
const entry = classifyResults[i];
|
|
676
|
+
const [name, refs] = top[i];
|
|
677
|
+
if (entry.result) {
|
|
678
|
+
const traitNames = entry.result.traits.map(t => t.name).filter(Boolean);
|
|
679
|
+
const has = (t) => traitNames.includes(t);
|
|
357
680
|
concepts.push({
|
|
358
681
|
name,
|
|
359
|
-
hexCode: result.hex_code,
|
|
360
|
-
isPhysical: traitNames.includes("Physical Object"),
|
|
682
|
+
hexCode: entry.result.hex_code,
|
|
361
683
|
traits: traitNames,
|
|
684
|
+
isPhysical: has("Physical Object"),
|
|
685
|
+
isPowered: has("Powered"),
|
|
686
|
+
isHumanInteractive: has("Human-Interactive"),
|
|
687
|
+
isAutonomous: has("Functionally Autonomous"),
|
|
688
|
+
isSystemEssential: has("System-Essential"),
|
|
689
|
+
isDigitalVirtual: has("Digital/Virtual"),
|
|
690
|
+
isRegulated: has("Regulated"),
|
|
362
691
|
reqs: refs,
|
|
363
692
|
});
|
|
364
|
-
console.error(` ✓ ${name} → ${result.hex_code} (${traitNames.length} traits)`);
|
|
693
|
+
console.error(` ✓ ${name} → ${entry.result.hex_code} (${traitNames.length} traits)`);
|
|
365
694
|
}
|
|
366
|
-
|
|
367
|
-
console.error(` ✗ ${name}: ${
|
|
695
|
+
else if (entry.error) {
|
|
696
|
+
console.error(` ✗ ${name}: ${entry.error.message}`);
|
|
368
697
|
}
|
|
369
698
|
}
|
|
370
699
|
// Step 4: Cross-compare concepts in batches
|
|
371
700
|
console.error("Cross-comparing concepts...");
|
|
372
701
|
const comparisons = [];
|
|
373
702
|
if (concepts.length >= 2) {
|
|
374
|
-
// Compare top concept against others, then second against rest
|
|
375
703
|
const names = concepts.map(c => c.name);
|
|
376
704
|
const batchSize = Math.min(names.length - 1, 15);
|
|
377
705
|
try {
|
|
@@ -397,8 +725,8 @@ export function registerLintCommands(program, client) {
|
|
|
397
725
|
}
|
|
398
726
|
// Step 5: Analyze findings
|
|
399
727
|
console.error("Analyzing...");
|
|
400
|
-
let findings = analyzeFindings(concepts, comparisons, requirements);
|
|
401
|
-
// Step 5b: Save baseline
|
|
728
|
+
let findings = analyzeFindings(concepts, comparisons, requirements, traceLinks, reqTierMap, conceptRefs, { sprayThreshold, jaccardThreshold });
|
|
729
|
+
// Step 5b: Save baseline (before suppression)
|
|
402
730
|
if (opts.saveBaseline) {
|
|
403
731
|
const titles = findings.map(f => f.title);
|
|
404
732
|
writeFileSync(opts.saveBaseline, titles.join("\n") + "\n", "utf-8");
|
|
@@ -408,28 +736,40 @@ export function registerLintCommands(program, client) {
|
|
|
408
736
|
const suppressions = [...(opts.suppress ?? [])];
|
|
409
737
|
if (opts.baseline && existsSync(opts.baseline)) {
|
|
410
738
|
const baselineContent = readFileSync(opts.baseline, "utf-8");
|
|
411
|
-
|
|
412
|
-
suppressions.push(...baselineLines);
|
|
739
|
+
suppressions.push(...baselineContent.split("\n").map(l => l.trim()).filter(Boolean));
|
|
413
740
|
}
|
|
414
741
|
if (suppressions.length > 0) {
|
|
415
742
|
const before = findings.length;
|
|
416
743
|
findings = findings.filter(f => !suppressions.some(s => f.title.includes(s)));
|
|
417
744
|
const suppressed = before - findings.length;
|
|
418
|
-
if (suppressed > 0)
|
|
745
|
+
if (suppressed > 0)
|
|
419
746
|
console.error(`Suppressed ${suppressed} known finding(s).`);
|
|
420
|
-
|
|
747
|
+
}
|
|
748
|
+
// Step 5d: Apply severity filter
|
|
749
|
+
const sevOrder = { high: 0, medium: 1, low: 2 };
|
|
750
|
+
const minSev = sevOrder[opts.minSeverity] ?? 2;
|
|
751
|
+
if (minSev < 2) {
|
|
752
|
+
const before = findings.length;
|
|
753
|
+
findings = findings.filter(f => sevOrder[f.severity] <= minSev);
|
|
754
|
+
const filtered = before - findings.length;
|
|
755
|
+
if (filtered > 0)
|
|
756
|
+
console.error(`Filtered ${filtered} finding(s) below ${opts.minSeverity} severity.`);
|
|
421
757
|
}
|
|
422
758
|
// Step 6: Output report
|
|
423
759
|
let report;
|
|
424
760
|
if (opts.format === "json" || isJsonMode()) {
|
|
425
|
-
|
|
426
|
-
|
|
761
|
+
report = JSON.stringify({
|
|
762
|
+
tenant, project,
|
|
763
|
+
requirements: requirements.length,
|
|
764
|
+
traceLinks: traceLinks.length,
|
|
765
|
+
concepts, comparisons, findings,
|
|
766
|
+
}, null, 2);
|
|
427
767
|
}
|
|
428
768
|
else if (opts.format === "markdown") {
|
|
429
|
-
report = formatMarkdown(tenant, project, requirements, concepts, comparisons, findings);
|
|
769
|
+
report = formatMarkdown(tenant, project, requirements, traceLinks, concepts, comparisons, findings);
|
|
430
770
|
}
|
|
431
771
|
else {
|
|
432
|
-
report = formatReport(tenant, project, requirements, concepts, comparisons, findings);
|
|
772
|
+
report = formatReport(tenant, project, requirements, traceLinks, concepts, comparisons, findings);
|
|
433
773
|
}
|
|
434
774
|
if (opts.output) {
|
|
435
775
|
writeFileSync(opts.output, report + "\n", "utf-8");
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
1
2
|
import { output, printTable, isJsonMode, truncate } from "../output.js";
|
|
2
3
|
import { resolveRequirementId } from "../resolve.js";
|
|
3
4
|
export function registerRequirementCommands(program, client) {
|
|
@@ -8,12 +9,38 @@ export function registerRequirementCommands(program, client) {
|
|
|
8
9
|
.argument("<tenant>", "Tenant slug")
|
|
9
10
|
.argument("<project>", "Project slug")
|
|
10
11
|
.option("-p, --page <n>", "Page number", "1")
|
|
11
|
-
.option("-l, --limit <n>", "Items per page", "
|
|
12
|
+
.option("-l, --limit <n>", "Items per page (use 'all' to fetch everything)", "50")
|
|
12
13
|
.option("--sort <field>", "Sort by: ref, createdAt, qaScore")
|
|
13
14
|
.option("--order <dir>", "Sort order: asc, desc")
|
|
14
15
|
.option("--tags <tags>", "Comma-separated tags to filter by (server-side)")
|
|
15
16
|
.option("--document <slug>", "Filter by document slug (server-side)")
|
|
16
17
|
.action(async (tenant, project, opts) => {
|
|
18
|
+
// Handle --limit all: fetch all pages
|
|
19
|
+
if (opts.limit.toLowerCase() === "all") {
|
|
20
|
+
const all = [];
|
|
21
|
+
for (let page = 1; page <= 50; page++) {
|
|
22
|
+
const params = {
|
|
23
|
+
page: String(page), limit: "500",
|
|
24
|
+
sortBy: opts.sort, sortOrder: opts.order,
|
|
25
|
+
};
|
|
26
|
+
if (opts.tags)
|
|
27
|
+
params.tags = opts.tags;
|
|
28
|
+
if (opts.document)
|
|
29
|
+
params.documentSlug = opts.document;
|
|
30
|
+
const data = await client.get(`/requirements/${tenant}/${project}`, params);
|
|
31
|
+
all.push(...(data.data ?? []));
|
|
32
|
+
if (page >= (data.meta?.totalPages ?? 1))
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
if (isJsonMode()) {
|
|
36
|
+
output({ data: all, meta: { totalItems: all.length } });
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
console.log(`All requirements: ${all.length}\n`);
|
|
40
|
+
printTable(["Ref", "Text", "Pattern", "QA", "Tags"], all.map(r => [r.ref ?? "?", truncate(r.text ?? "", 60), r.pattern ?? "", r.qaScore != null ? String(r.qaScore) : "-", (r.tags ?? []).join(", ")]));
|
|
41
|
+
}
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
17
44
|
const params = {
|
|
18
45
|
page: opts.page,
|
|
19
46
|
limit: opts.limit,
|
|
@@ -258,4 +285,113 @@ export function registerRequirementCommands(program, client) {
|
|
|
258
285
|
]));
|
|
259
286
|
}
|
|
260
287
|
});
|
|
288
|
+
// ── reassign: move requirement to a different document/section ──
|
|
289
|
+
cmd
|
|
290
|
+
.command("reassign")
|
|
291
|
+
.description("Move a requirement to a different document/section (preserves ID and trace links)")
|
|
292
|
+
.argument("<tenant>", "Tenant slug")
|
|
293
|
+
.argument("<project>", "Project slug")
|
|
294
|
+
.argument("<id>", "Requirement ref, ID, or hashId")
|
|
295
|
+
.requiredOption("--section <id>", "Target section ID (determines the target document)")
|
|
296
|
+
.action(async (tenant, project, id, opts) => {
|
|
297
|
+
const resolvedId = await resolveRequirementId(client, tenant, project, id);
|
|
298
|
+
await client.patch(`/requirements/${tenant}/${project}/${resolvedId}`, {
|
|
299
|
+
sectionId: opts.section,
|
|
300
|
+
});
|
|
301
|
+
if (isJsonMode()) {
|
|
302
|
+
output({ ok: true, movedTo: opts.section });
|
|
303
|
+
}
|
|
304
|
+
else {
|
|
305
|
+
console.log(`Requirement reassigned to section ${opts.section}. Ref will update to match new document prefix.`);
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
// ── bulk-create: create multiple requirements from JSON array ──
|
|
309
|
+
cmd
|
|
310
|
+
.command("bulk-create")
|
|
311
|
+
.description("Create multiple requirements from a JSON file (array of objects)")
|
|
312
|
+
.argument("<tenant>", "Tenant slug")
|
|
313
|
+
.argument("<project-key>", "Project key")
|
|
314
|
+
.requiredOption("--file <path>", "Path to JSON file (array of {text, document?, section?, verification?, rationale?, tags?, idempotencyKey?})")
|
|
315
|
+
.option("--dry-run", "Validate without creating")
|
|
316
|
+
.action(async (tenant, projectKey, opts) => {
|
|
317
|
+
const content = readFileSync(opts.file, "utf-8");
|
|
318
|
+
let items;
|
|
319
|
+
try {
|
|
320
|
+
items = JSON.parse(content);
|
|
321
|
+
if (!Array.isArray(items))
|
|
322
|
+
throw new Error("File must contain a JSON array");
|
|
323
|
+
}
|
|
324
|
+
catch (err) {
|
|
325
|
+
console.error(`Invalid JSON: ${err.message}`);
|
|
326
|
+
process.exit(1);
|
|
327
|
+
}
|
|
328
|
+
console.error(`Processing ${items.length} requirement(s)...`);
|
|
329
|
+
let created = 0, skipped = 0, errors = 0;
|
|
330
|
+
for (let i = 0; i < items.length; i++) {
|
|
331
|
+
const item = items[i];
|
|
332
|
+
const text = String(item.text ?? "").trim();
|
|
333
|
+
if (!text || text.length < 10) {
|
|
334
|
+
console.error(` [${i}] Skipped: text too short`);
|
|
335
|
+
skipped++;
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
if (opts.dryRun) {
|
|
339
|
+
console.log(` [dry-run] ${truncate(text, 80)}`);
|
|
340
|
+
created++;
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
try {
|
|
344
|
+
const body = { tenant, projectKey, text };
|
|
345
|
+
if (item.document || item.documentSlug)
|
|
346
|
+
body.documentSlug = item.document ?? item.documentSlug;
|
|
347
|
+
if (item.section || item.sectionId)
|
|
348
|
+
body.sectionId = item.section ?? item.sectionId;
|
|
349
|
+
if (item.verification)
|
|
350
|
+
body.verification = item.verification;
|
|
351
|
+
if (item.rationale)
|
|
352
|
+
body.rationale = item.rationale;
|
|
353
|
+
if (item.pattern)
|
|
354
|
+
body.pattern = item.pattern;
|
|
355
|
+
if (item.compliance)
|
|
356
|
+
body.complianceStatus = item.compliance;
|
|
357
|
+
if (item.idempotencyKey)
|
|
358
|
+
body.idempotencyKey = item.idempotencyKey;
|
|
359
|
+
if (Array.isArray(item.tags))
|
|
360
|
+
body.tags = item.tags;
|
|
361
|
+
else if (typeof item.tags === "string")
|
|
362
|
+
body.tags = item.tags.split(",").map(t => t.trim());
|
|
363
|
+
await client.post("/requirements", body);
|
|
364
|
+
created++;
|
|
365
|
+
}
|
|
366
|
+
catch (err) {
|
|
367
|
+
console.error(` [${i}] Error: ${err.message}`);
|
|
368
|
+
errors++;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
console.log(`${opts.dryRun ? "Would create" : "Created"} ${created}. Skipped: ${skipped}. Errors: ${errors}.`);
|
|
372
|
+
});
|
|
373
|
+
// ── text-search: search requirements by text content ──
|
|
374
|
+
cmd
|
|
375
|
+
.command("text-search")
|
|
376
|
+
.description("Search requirements by text content (case-insensitive substring match)")
|
|
377
|
+
.argument("<tenant>", "Tenant slug")
|
|
378
|
+
.argument("<project>", "Project slug")
|
|
379
|
+
.requiredOption("--text <query>", "Text to search for")
|
|
380
|
+
.option("-l, --limit <n>", "Max results", "50")
|
|
381
|
+
.action(async (tenant, project, opts) => {
|
|
382
|
+
const data = await client.get(`/requirements/${tenant}/${project}`, { textContains: opts.text, limit: opts.limit, sortBy: "ref", sortOrder: "asc" });
|
|
383
|
+
const reqs = data.data ?? [];
|
|
384
|
+
if (isJsonMode()) {
|
|
385
|
+
output(reqs);
|
|
386
|
+
}
|
|
387
|
+
else {
|
|
388
|
+
console.log(`Found ${reqs.length} requirement(s) matching "${opts.text}":\n`);
|
|
389
|
+
printTable(["Ref", "Text", "Document", "QA"], reqs.map(r => [
|
|
390
|
+
r.ref ?? "?",
|
|
391
|
+
truncate(r.text ?? "", 55),
|
|
392
|
+
r.documentSlug ?? "",
|
|
393
|
+
r.qaScore != null ? String(r.qaScore) : "-",
|
|
394
|
+
]));
|
|
395
|
+
}
|
|
396
|
+
});
|
|
261
397
|
}
|