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.
Files changed (2) hide show
  1. package/dist/commands/lint.js +414 -85
  2. package/package.json +1 -1
@@ -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
- * Extract domain concepts from requirement text.
23
- * Looks for:
24
- * - Subjects: "The <concept> shall..."
25
- * - References: "using the <concept>", "via the <concept>", "from the <concept>"
26
- * - Named systems: multi-word capitalized terms, known patterns
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
- // References: "using the X", "via X", "from the X", "to the X"
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
- // 1. Physical mismatch: non-physical concepts with environmental/physical requirements
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 the Physical Object trait, but ${envReqs.length} requirement(s) impose physical/environmental constraints on it.`,
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}" (e.g., housing, LRU, equipment rack).`,
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; // very abstract = few traits
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 (very abstract). Requirements set thresholds but don't specify confidence level, sample size, or test conditions.`,
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 requirements mixed with functional requirements
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. This makes traceability harder.`,
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. Create trace links to parent functional requirements.",
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: requirements mentioning "manual", "reversion", "fallback" without performance criteria
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
- const hasPerf = perfPattern.test(req.text ?? "");
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 for performance in that mode.`,
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 (e.g., acceptable accuracy, response time, available subsystems).",
360
+ recommendation: "Add measurable performance criteria for degraded operation.",
170
361
  });
171
362
  }
172
363
  }
173
- // 5. Cross-comparison: high similarity between concepts in different categories
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
- // Different physical classification but high similarity = potential confusion
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}" are similar (${(comp.jaccard_similarity * 100).toFixed(0)}%) but differ in physical classification`,
186
- description: `"${a.name}" is ${a.isPhysical ? "" : "not "}a Physical Object; "${b.name}" is ${b.isPhysical ? "" : "not "}. High Jaccard similarity (${comp.jaccard_similarity.toFixed(3)}) suggests they should be treated consistently.`,
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: `Review whether both concepts should have consistent physical classification. Consider adding clarifying requirements.`,
378
+ recommendation: "Review whether both concepts should have consistent physical classification.",
189
379
  });
190
380
  }
191
381
  }
192
382
  }
193
- // 6. Requirements without "shall" (weak language)
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: `Requirements without "shall" may be informational text rather than testable requirements.`,
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: 'Rephrase using "shall" for testable requirements, or move informational text to notes/rationale.',
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} | Concepts classified: ${concepts.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 table
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} ${phys.padEnd(8)} ${c.traits.slice(0, 4).join(", ")}${c.traits.length > 4 ? "..." : ""}`);
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
- const pct = (comp.jaccard_similarity * 100).toFixed(0);
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} | **Concepts classified:** ${concepts.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 | Physical? | Key Traits |");
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.isPhysical ? "Yes" : "No"} | ${c.traits.slice(0, 4).join(", ")} |`);
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 domain concepts via UHT and flags ontological issues")
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
- // Step 1: Fetch all requirements
338
- console.error("Fetching requirements...");
339
- const requirements = await fetchAllRequirements(client, tenant, project);
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
- console.error(` ${requirements.length} requirements loaded.`);
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 each concept via UHT
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 (const [name, refs] of top) {
354
- try {
355
- const result = await uht.classify(name);
356
- const traitNames = result.traits.map(t => t.name).filter(Boolean);
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
- catch (err) {
367
- console.error(` ✗ ${name}: ${err.message}`);
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 if requested (before suppression)
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
- const baselineLines = baselineContent.split("\n").map(l => l.trim()).filter(Boolean);
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
- const data = { tenant, project, requirements: requirements.length, concepts, comparisons, findings };
426
- report = JSON.stringify(data, null, 2);
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");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "airgen-cli",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "AIRGen CLI — requirements engineering from the command line",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",