airgen-cli 0.1.7 → 0.2.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/README.md +261 -0
- package/dist/commands/lint.d.ts +3 -0
- package/dist/commands/lint.js +415 -0
- package/dist/index.js +2 -0
- package/dist/uht-client.d.ts +45 -0
- package/dist/uht-client.js +55 -0
- package/package.json +9 -3
package/README.md
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
# airgen-cli
|
|
2
|
+
|
|
3
|
+
Requirements engineering from the command line. Manage requirements, architecture diagrams, traceability, baselines, and more — all from your terminal.
|
|
4
|
+
|
|
5
|
+
Pairs with [AIRGen Studio](https://airgen.studio) and the AIRGen MCP server.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install -g airgen-cli
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Configuration
|
|
14
|
+
|
|
15
|
+
Set credentials via environment variables or `~/.airgenrc`:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
# Environment variables
|
|
19
|
+
export AIRGEN_API_URL=https://api.airgen.studio/api
|
|
20
|
+
export AIRGEN_EMAIL=you@example.com
|
|
21
|
+
export AIRGEN_PASSWORD=your-password
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Or create `~/.airgenrc`:
|
|
25
|
+
|
|
26
|
+
```json
|
|
27
|
+
{
|
|
28
|
+
"apiUrl": "https://api.airgen.studio/api",
|
|
29
|
+
"email": "you@example.com",
|
|
30
|
+
"password": "your-password"
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
For semantic linting, also set a UHT token:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
export UHT_API_KEY=your-token # or UHT_TOKEN
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Quick start
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
# List your tenants and projects
|
|
44
|
+
airgen tenants list
|
|
45
|
+
airgen projects list my-tenant
|
|
46
|
+
|
|
47
|
+
# List requirements
|
|
48
|
+
airgen reqs list my-tenant my-project
|
|
49
|
+
|
|
50
|
+
# Render a diagram in the terminal
|
|
51
|
+
airgen diag list my-tenant my-project
|
|
52
|
+
airgen diag render my-tenant my-project diagram-123
|
|
53
|
+
|
|
54
|
+
# Run semantic lint
|
|
55
|
+
airgen lint my-tenant my-project
|
|
56
|
+
|
|
57
|
+
# Get a compliance report
|
|
58
|
+
airgen report compliance my-tenant my-project
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Global options
|
|
62
|
+
|
|
63
|
+
| Flag | Description |
|
|
64
|
+
|---|---|
|
|
65
|
+
| `--json` | Output as JSON (works with any command) |
|
|
66
|
+
| `-V, --version` | Print version |
|
|
67
|
+
| `-h, --help` | Show help |
|
|
68
|
+
|
|
69
|
+
## Commands
|
|
70
|
+
|
|
71
|
+
### Tenants & Projects
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
airgen tenants list # List all tenants
|
|
75
|
+
airgen projects list <tenant> # List projects in a tenant
|
|
76
|
+
airgen projects create <tenant> --name "X" # Create a project
|
|
77
|
+
airgen projects delete <tenant> <project> # Delete a project
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Requirements
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
airgen reqs list <tenant> <project> # List (paginated)
|
|
84
|
+
airgen reqs list <tenant> <project> --page 2 --limit 50
|
|
85
|
+
airgen reqs get <tenant> <project> <ref> # Full detail
|
|
86
|
+
airgen reqs create <tenant> <project> --text "The system shall..."
|
|
87
|
+
airgen reqs update <tenant> <project> <id> --text "..." --tags safety,critical
|
|
88
|
+
airgen reqs delete <tenant> <project> <id> # Soft-delete
|
|
89
|
+
airgen reqs history <tenant> <project> <id> # Version history
|
|
90
|
+
airgen reqs search <tenant> <project> --query "thermal" --mode semantic
|
|
91
|
+
airgen reqs filter <tenant> <project> --pattern functional --tag safety
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### Architecture Diagrams
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
airgen diag list <tenant> <project> # List diagrams
|
|
98
|
+
airgen diag get <tenant> <project> <id> # Blocks + connectors JSON
|
|
99
|
+
airgen diag render <tenant> <project> <id> # Terminal display (default)
|
|
100
|
+
airgen diag render <tenant> <project> <id> --format mermaid # Mermaid syntax
|
|
101
|
+
airgen diag render <tenant> <project> <id> --format mermaid --wrap -o diagram.md
|
|
102
|
+
airgen diag create <tenant> <project> --name "X" --view block
|
|
103
|
+
airgen diag update <tenant> <project> <id> --name "Y"
|
|
104
|
+
airgen diag delete <tenant> <project> <id>
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
**Blocks:**
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
airgen diag blocks library <tenant> <project>
|
|
111
|
+
airgen diag blocks create <tenant> <project> --diagram <id> --name "X" --kind subsystem
|
|
112
|
+
airgen diag blocks delete <tenant> <project> <block-id>
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
**Connectors:**
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
airgen diag conn create <tenant> <project> --diagram <id> --source <id> --target <id> --kind flow --label "data"
|
|
119
|
+
airgen diag conn delete <tenant> <project> <conn-id> --diagram <id>
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### Traceability
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
airgen trace list <tenant> <project> # List trace links
|
|
126
|
+
airgen trace create <tenant> <project> --source <id> --target <id> --type derives
|
|
127
|
+
airgen trace delete <tenant> <project> <link-id>
|
|
128
|
+
airgen trace linksets list <tenant> <project> # Document linksets
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### Baselines
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
airgen bl list <tenant> <project>
|
|
135
|
+
airgen bl create <tenant> <project> --name "v1.0"
|
|
136
|
+
airgen bl compare <tenant> <project> --from <id1> --to <id2>
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### Quality & AI
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
airgen qa analyze "The system shall..." # Analyze single requirement
|
|
143
|
+
airgen qa score start <tenant> <project> # Background QA scoring
|
|
144
|
+
airgen qa draft "user needs thermal imaging" # Draft requirements from NL
|
|
145
|
+
|
|
146
|
+
airgen ai generate <tenant> <project> --prompt "..." # Generate candidates
|
|
147
|
+
airgen ai candidates <tenant> <project> # List pending candidates
|
|
148
|
+
airgen ai accept <candidate-id> # Promote to requirement
|
|
149
|
+
airgen ai reject <candidate-id> # Reject candidate
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### Reports
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
airgen report stats <tenant> <project> # Overview statistics
|
|
156
|
+
airgen report quality <tenant> <project> # QA score summary
|
|
157
|
+
airgen report compliance <tenant> <project> # Compliance + impl status
|
|
158
|
+
airgen report orphans <tenant> <project> # Untraced requirements
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
All report commands auto-paginate through the full requirement set (up to 5000).
|
|
162
|
+
|
|
163
|
+
### Implementation Tracking
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
airgen impl status <tenant> <project> <req> --status implemented --notes "done in v2"
|
|
167
|
+
airgen impl summary <tenant> <project> # Coverage breakdown
|
|
168
|
+
airgen impl list <tenant> <project> --status blocked # Filter by status
|
|
169
|
+
airgen impl bulk-update <tenant> <project> --file updates.json
|
|
170
|
+
|
|
171
|
+
# Artifact linking
|
|
172
|
+
airgen impl link <tenant> <project> <req> --type file --path src/engine.ts
|
|
173
|
+
airgen impl unlink <tenant> <project> <req> --artifact <id>
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
**Statuses:** `not_started`, `in_progress`, `implemented`, `verified`, `blocked`
|
|
177
|
+
|
|
178
|
+
**Bulk update file format:**
|
|
179
|
+
|
|
180
|
+
```json
|
|
181
|
+
[
|
|
182
|
+
{ "ref": "REQ-001", "status": "implemented", "notes": "shipped" },
|
|
183
|
+
{ "ref": "REQ-002", "status": "in_progress" }
|
|
184
|
+
]
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
### Import / Export
|
|
188
|
+
|
|
189
|
+
```bash
|
|
190
|
+
airgen import requirements <tenant> <project> --file reqs.csv
|
|
191
|
+
airgen export requirements <tenant> <project> # Markdown
|
|
192
|
+
airgen export requirements <tenant> <project> --json # JSON
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
### Activity
|
|
196
|
+
|
|
197
|
+
```bash
|
|
198
|
+
airgen activity list <tenant> <project> # Recent activity
|
|
199
|
+
airgen activity list <tenant> <project> --limit 50
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
### Documents
|
|
203
|
+
|
|
204
|
+
```bash
|
|
205
|
+
airgen docs list <tenant> <project>
|
|
206
|
+
airgen docs get <tenant> <project> <slug>
|
|
207
|
+
airgen docs create <tenant> <project> --title "X" --kind structured
|
|
208
|
+
airgen docs delete <tenant> <project> <slug>
|
|
209
|
+
airgen docs export <tenant> <project> <slug> # Markdown export
|
|
210
|
+
airgen docs sec list <tenant> <project> <slug> # List sections
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
### Semantic Lint
|
|
214
|
+
|
|
215
|
+
Classifies domain concepts from your requirements using the [Universal Hex Taxonomy](https://universalhex.org) and flags ontological issues, structural problems, and coverage gaps.
|
|
216
|
+
|
|
217
|
+
```bash
|
|
218
|
+
airgen lint <tenant> <project> # Full lint (top 15 concepts)
|
|
219
|
+
airgen lint <tenant> <project> --concepts 20 # Classify more concepts
|
|
220
|
+
airgen lint <tenant> <project> --format markdown -o lint-report.md
|
|
221
|
+
airgen lint <tenant> <project> --format json # Machine-readable
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
**What it detects:**
|
|
225
|
+
|
|
226
|
+
- Ontological mismatches (e.g., non-physical entity with physical constraints)
|
|
227
|
+
- Abstract metrics missing statistical parameters
|
|
228
|
+
- Verification requirements mixed with functional requirements
|
|
229
|
+
- Degraded modes without performance criteria
|
|
230
|
+
- Ontological ambiguity between similar concepts
|
|
231
|
+
- Requirements lacking "shall" keyword
|
|
232
|
+
|
|
233
|
+
**Requires:** `UHT_TOKEN` or `UHT_API_KEY` environment variable. Get a token at [universalhex.org](https://universalhex.org).
|
|
234
|
+
|
|
235
|
+
## JSON mode
|
|
236
|
+
|
|
237
|
+
Append `--json` to any command for machine-readable output:
|
|
238
|
+
|
|
239
|
+
```bash
|
|
240
|
+
airgen reqs list my-tenant my-project --json | jq '.[].ref'
|
|
241
|
+
airgen report compliance my-tenant my-project --json | jq '.summary'
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
## Aliases
|
|
245
|
+
|
|
246
|
+
| Full command | Alias |
|
|
247
|
+
|---|---|
|
|
248
|
+
| `requirements` | `reqs` |
|
|
249
|
+
| `diagrams` | `diag` |
|
|
250
|
+
| `documents` | `docs` |
|
|
251
|
+
| `connectors` | `conn` |
|
|
252
|
+
| `baselines` | `bl` |
|
|
253
|
+
| `traces` | `trace` |
|
|
254
|
+
| `quality` | `qa` |
|
|
255
|
+
| `reports` | `report` |
|
|
256
|
+
| `projects` | `proj` |
|
|
257
|
+
| `sections` | `sec` |
|
|
258
|
+
|
|
259
|
+
## License
|
|
260
|
+
|
|
261
|
+
MIT
|
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
import { writeFileSync } from "node:fs";
|
|
2
|
+
import { UhtClient } from "../uht-client.js";
|
|
3
|
+
import { isJsonMode } from "../output.js";
|
|
4
|
+
// ── Constants ────────────────────────────────────────────────
|
|
5
|
+
const PAGE_SIZE = 100;
|
|
6
|
+
const MAX_PAGES = 50;
|
|
7
|
+
// ── Helpers ──────────────────────────────────────────────────
|
|
8
|
+
async function fetchAllRequirements(client, tenant, project) {
|
|
9
|
+
const all = [];
|
|
10
|
+
for (let page = 1; page <= MAX_PAGES; page++) {
|
|
11
|
+
const data = await client.get(`/requirements/${tenant}/${project}`, {
|
|
12
|
+
page: String(page),
|
|
13
|
+
limit: String(PAGE_SIZE),
|
|
14
|
+
});
|
|
15
|
+
all.push(...(data.data ?? []));
|
|
16
|
+
if (page >= (data.meta?.totalPages ?? 1))
|
|
17
|
+
break;
|
|
18
|
+
}
|
|
19
|
+
return all.filter(r => !r.deleted && !r.deletedAt);
|
|
20
|
+
}
|
|
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
|
+
*/
|
|
28
|
+
function extractConcepts(requirements) {
|
|
29
|
+
const conceptRefs = new Map();
|
|
30
|
+
function addConcept(concept, ref) {
|
|
31
|
+
const normalized = concept.toLowerCase().trim();
|
|
32
|
+
if (normalized.length < 3 || normalized.length > 60)
|
|
33
|
+
return;
|
|
34
|
+
// Skip generic words
|
|
35
|
+
const skip = new Set(["system", "the system", "it", "this", "all", "each", "any"]);
|
|
36
|
+
if (skip.has(normalized))
|
|
37
|
+
return;
|
|
38
|
+
const refs = conceptRefs.get(normalized) ?? [];
|
|
39
|
+
if (!refs.includes(ref))
|
|
40
|
+
refs.push(ref);
|
|
41
|
+
conceptRefs.set(normalized, refs);
|
|
42
|
+
}
|
|
43
|
+
for (const req of requirements) {
|
|
44
|
+
if (!req.text || !req.ref)
|
|
45
|
+
continue;
|
|
46
|
+
const text = req.text;
|
|
47
|
+
// "The <concept> shall"
|
|
48
|
+
const subjectMatch = text.match(/^(?:the|a|an)\s+(.+?)\s+shall\b/i);
|
|
49
|
+
if (subjectMatch)
|
|
50
|
+
addConcept(subjectMatch[1], req.ref);
|
|
51
|
+
// "If the <concept> detects/is/has..."
|
|
52
|
+
const ifMatch = text.match(/^if\s+the\s+(.+?)\s+(?:detects?|is|has|does|fails?|receives?)\b/i);
|
|
53
|
+
if (ifMatch)
|
|
54
|
+
addConcept(ifMatch[1], req.ref);
|
|
55
|
+
// "While the <concept> is..."
|
|
56
|
+
const whileMatch = text.match(/^while\s+(?:the\s+)?(.+?)\s+is\b/i);
|
|
57
|
+
if (whileMatch)
|
|
58
|
+
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);
|
|
61
|
+
if (whenMatch)
|
|
62
|
+
addConcept(whenMatch[1], req.ref);
|
|
63
|
+
// References: "using the X", "via X", "from the X", "to the X"
|
|
64
|
+
const refPatterns = [
|
|
65
|
+
/using\s+(?:the\s+)?(.+?)(?:\s+(?:for|to|at|in|with)\b|[.,;]|$)/gi,
|
|
66
|
+
/via\s+(?:the\s+)?(.+?)(?:\s+(?:for|to|at|in|with)\b|[.,;]|$)/gi,
|
|
67
|
+
/from\s+the\s+(.+?)(?:\s+(?:for|to|at|in|with)\b|[.,;]|$)/gi,
|
|
68
|
+
/(?:to|into)\s+the\s+(.+?)(?:\s+(?:for|to|at|in|with)\b|[.,;]|$)/gi,
|
|
69
|
+
/(?:against|per|in accordance with)\s+(.+?)(?:\s+(?:for|to|at|in|with)\b|[.,;]|$)/gi,
|
|
70
|
+
];
|
|
71
|
+
for (const pat of refPatterns) {
|
|
72
|
+
let m;
|
|
73
|
+
while ((m = pat.exec(text)) !== null) {
|
|
74
|
+
addConcept(m[1], req.ref);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return conceptRefs;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Rank concepts by frequency and pick top N.
|
|
82
|
+
*/
|
|
83
|
+
function topConcepts(conceptRefs, maxCount) {
|
|
84
|
+
return [...conceptRefs.entries()]
|
|
85
|
+
.sort((a, b) => b[1].length - a[1].length)
|
|
86
|
+
.slice(0, maxCount);
|
|
87
|
+
}
|
|
88
|
+
// ── Analysis ─────────────────────────────────────────────────
|
|
89
|
+
function analyzeFindings(concepts, comparisons, requirements) {
|
|
90
|
+
const findings = [];
|
|
91
|
+
const conceptMap = new Map(concepts.map(c => [c.name, c]));
|
|
92
|
+
// 1. Physical mismatch: non-physical concepts with environmental/physical requirements
|
|
93
|
+
const envKeywords = /temperature|shock|vibrat|humidity|nbc|contamina|electromagnetic|emc|climatic/i;
|
|
94
|
+
for (const c of concepts) {
|
|
95
|
+
if (c.isPhysical)
|
|
96
|
+
continue;
|
|
97
|
+
const envReqs = c.reqs.filter(ref => {
|
|
98
|
+
const req = requirements.find(r => r.ref === ref);
|
|
99
|
+
return req?.text && envKeywords.test(req.text);
|
|
100
|
+
});
|
|
101
|
+
if (envReqs.length > 0) {
|
|
102
|
+
findings.push({
|
|
103
|
+
severity: "high",
|
|
104
|
+
category: "Ontological Mismatch",
|
|
105
|
+
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.`,
|
|
107
|
+
affectedReqs: envReqs,
|
|
108
|
+
recommendation: `Add a requirement defining the physical embodiment of "${c.name}" (e.g., housing, LRU, equipment rack).`,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// 2. Abstract metrics without statistical parameters
|
|
113
|
+
const metricKeywords = /probability|rate|percentage|ratio|mtbf|availability/i;
|
|
114
|
+
const statKeywords = /confidence|sample size|number of|minimum of \d+ |statistical/i;
|
|
115
|
+
for (const c of concepts) {
|
|
116
|
+
if (c.traits.length > 3)
|
|
117
|
+
continue; // very abstract = few traits
|
|
118
|
+
const metricReqs = c.reqs.filter(ref => {
|
|
119
|
+
const req = requirements.find(r => r.ref === ref);
|
|
120
|
+
return req?.text && metricKeywords.test(req.text);
|
|
121
|
+
});
|
|
122
|
+
if (metricReqs.length === 0)
|
|
123
|
+
continue;
|
|
124
|
+
const hasStats = metricReqs.some(ref => {
|
|
125
|
+
const req = requirements.find(r => r.ref === ref);
|
|
126
|
+
return req?.text && statKeywords.test(req.text);
|
|
127
|
+
});
|
|
128
|
+
if (!hasStats) {
|
|
129
|
+
findings.push({
|
|
130
|
+
severity: "medium",
|
|
131
|
+
category: "Missing Statistical Context",
|
|
132
|
+
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.`,
|
|
134
|
+
affectedReqs: metricReqs,
|
|
135
|
+
recommendation: `Add statistical parameters (confidence level, sample size, conditions) to requirements referencing "${c.name}".`,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
// 3. Verification requirements mixed with functional requirements
|
|
140
|
+
const verificationReqs = requirements.filter(r => r.text && /shall be verified|verification|shall be demonstrated|shall be tested/i.test(r.text));
|
|
141
|
+
const functionalReqs = requirements.filter(r => r.text && /shall\b/i.test(r.text) && !/shall be verified|verification/i.test(r.text));
|
|
142
|
+
if (verificationReqs.length > 0 && functionalReqs.length > 0) {
|
|
143
|
+
const ratio = verificationReqs.length / requirements.length;
|
|
144
|
+
if (ratio > 0.05 && ratio < 0.95) {
|
|
145
|
+
findings.push({
|
|
146
|
+
severity: "medium",
|
|
147
|
+
category: "Structural Issue",
|
|
148
|
+
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.`,
|
|
150
|
+
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.",
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
// 4. Degraded mode gaps: requirements mentioning "manual", "reversion", "fallback" without performance criteria
|
|
156
|
+
const degradedReqs = requirements.filter(r => r.text && /manual\s+(?:reversion|mode|override|backup)|fallback|degraded/i.test(r.text));
|
|
157
|
+
for (const req of degradedReqs) {
|
|
158
|
+
const hasPerf = /\d+%|\d+\s*(?:second|ms|metre|meter|m\b)/i.test(req.text ?? "");
|
|
159
|
+
if (!hasPerf) {
|
|
160
|
+
findings.push({
|
|
161
|
+
severity: "medium",
|
|
162
|
+
category: "Coverage Gap",
|
|
163
|
+
title: `Degraded mode without performance criteria: ${req.ref}`,
|
|
164
|
+
description: `${req.ref} specifies a degraded/manual mode but provides no acceptance criteria for performance in that mode.`,
|
|
165
|
+
affectedReqs: [req.ref],
|
|
166
|
+
recommendation: "Add measurable performance criteria for degraded operation (e.g., acceptable accuracy, response time, available subsystems).",
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
// 5. Cross-comparison: high similarity between concepts in different categories
|
|
171
|
+
for (const batch of comparisons) {
|
|
172
|
+
for (const comp of batch.comparisons) {
|
|
173
|
+
const a = conceptMap.get(batch.entity);
|
|
174
|
+
const b = conceptMap.get(comp.candidate);
|
|
175
|
+
if (!a || !b)
|
|
176
|
+
continue;
|
|
177
|
+
// Different physical classification but high similarity = potential confusion
|
|
178
|
+
if (comp.jaccard_similarity > 0.6 && a.isPhysical !== b.isPhysical) {
|
|
179
|
+
findings.push({
|
|
180
|
+
severity: "low",
|
|
181
|
+
category: "Ontological Ambiguity",
|
|
182
|
+
title: `"${a.name}" and "${b.name}" are similar (${(comp.jaccard_similarity * 100).toFixed(0)}%) but differ in physical classification`,
|
|
183
|
+
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.`,
|
|
184
|
+
affectedReqs: [...a.reqs, ...b.reqs],
|
|
185
|
+
recommendation: `Review whether both concepts should have consistent physical classification. Consider adding clarifying requirements.`,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
// 6. Requirements without "shall" (weak language)
|
|
191
|
+
const weakReqs = requirements.filter(r => r.text && !/\bshall\b/i.test(r.text) && !/shall be verified/i.test(r.text));
|
|
192
|
+
if (weakReqs.length > 0) {
|
|
193
|
+
findings.push({
|
|
194
|
+
severity: "low",
|
|
195
|
+
category: "Language Quality",
|
|
196
|
+
title: `${weakReqs.length} requirement(s) lack "shall" keyword`,
|
|
197
|
+
description: `Requirements without "shall" may be informational text rather than testable requirements.`,
|
|
198
|
+
affectedReqs: weakReqs.map(r => r.ref).filter(Boolean),
|
|
199
|
+
recommendation: 'Rephrase using "shall" for testable requirements, or move informational text to notes/rationale.',
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
return findings.sort((a, b) => {
|
|
203
|
+
const sev = { high: 0, medium: 1, low: 2 };
|
|
204
|
+
return sev[a.severity] - sev[b.severity];
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
// ── Report formatting ────────────────────────────────────────
|
|
208
|
+
function formatReport(tenant, project, requirements, concepts, comparisons, findings) {
|
|
209
|
+
const lines = [];
|
|
210
|
+
const high = findings.filter(f => f.severity === "high").length;
|
|
211
|
+
const med = findings.filter(f => f.severity === "medium").length;
|
|
212
|
+
const low = findings.filter(f => f.severity === "low").length;
|
|
213
|
+
lines.push(" Semantic Lint Report");
|
|
214
|
+
lines.push(" ════════════════════");
|
|
215
|
+
lines.push(` Project: ${project} (${tenant})`);
|
|
216
|
+
lines.push(` Requirements: ${requirements.length} | Concepts classified: ${concepts.length}`);
|
|
217
|
+
lines.push(` Findings: ${findings.length} (${high} high, ${med} medium, ${low} low)`);
|
|
218
|
+
lines.push("");
|
|
219
|
+
// Concept classifications table
|
|
220
|
+
lines.push(" ┄┄ Concept Classifications ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄");
|
|
221
|
+
lines.push("");
|
|
222
|
+
const nameW = Math.max(...concepts.map(c => c.name.length), 10);
|
|
223
|
+
for (const c of concepts) {
|
|
224
|
+
const phys = c.isPhysical ? "Physical" : "Abstract";
|
|
225
|
+
const pad = " ".repeat(Math.max(0, nameW - c.name.length));
|
|
226
|
+
lines.push(` ${c.name}${pad} ${c.hexCode} ${phys.padEnd(8)} ${c.traits.slice(0, 4).join(", ")}${c.traits.length > 4 ? "..." : ""}`);
|
|
227
|
+
}
|
|
228
|
+
lines.push("");
|
|
229
|
+
// Cross-comparison highlights
|
|
230
|
+
if (comparisons.length > 0) {
|
|
231
|
+
lines.push(" ┄┄ Key Similarities ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄");
|
|
232
|
+
lines.push("");
|
|
233
|
+
for (const batch of comparisons) {
|
|
234
|
+
for (const comp of batch.comparisons) {
|
|
235
|
+
if (comp.jaccard_similarity >= 0.4) {
|
|
236
|
+
const pct = (comp.jaccard_similarity * 100).toFixed(0);
|
|
237
|
+
lines.push(` ${batch.entity} ↔ ${comp.candidate}: ${pct}% Jaccard`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
lines.push("");
|
|
242
|
+
}
|
|
243
|
+
// Findings
|
|
244
|
+
lines.push(" ┄┄ Findings ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄");
|
|
245
|
+
lines.push("");
|
|
246
|
+
for (let i = 0; i < findings.length; i++) {
|
|
247
|
+
const f = findings[i];
|
|
248
|
+
const sevIcon = f.severity === "high" ? "!!!" : f.severity === "medium" ? " ! " : " . ";
|
|
249
|
+
lines.push(` ${i + 1}. [${sevIcon}] ${f.title}`);
|
|
250
|
+
lines.push(` Category: ${f.category}`);
|
|
251
|
+
lines.push(` ${f.description}`);
|
|
252
|
+
lines.push(` Affects: ${f.affectedReqs.join(", ")}`);
|
|
253
|
+
lines.push(` Fix: ${f.recommendation}`);
|
|
254
|
+
lines.push("");
|
|
255
|
+
}
|
|
256
|
+
if (findings.length === 0) {
|
|
257
|
+
lines.push(" No findings — requirements look clean.");
|
|
258
|
+
lines.push("");
|
|
259
|
+
}
|
|
260
|
+
return lines.join("\n");
|
|
261
|
+
}
|
|
262
|
+
function formatMarkdown(tenant, project, requirements, concepts, comparisons, findings) {
|
|
263
|
+
const lines = [];
|
|
264
|
+
const high = findings.filter(f => f.severity === "high").length;
|
|
265
|
+
const med = findings.filter(f => f.severity === "medium").length;
|
|
266
|
+
const low = findings.filter(f => f.severity === "low").length;
|
|
267
|
+
lines.push("## Semantic Lint Report");
|
|
268
|
+
lines.push(`**Project:** ${project} (\`${tenant}\`) `);
|
|
269
|
+
lines.push(`**Requirements:** ${requirements.length} | **Concepts classified:** ${concepts.length} `);
|
|
270
|
+
lines.push(`**Findings:** ${findings.length} (${high} high, ${med} medium, ${low} low)`);
|
|
271
|
+
lines.push("");
|
|
272
|
+
// Concept table
|
|
273
|
+
lines.push("### Concept Classifications");
|
|
274
|
+
lines.push("| Concept | UHT Code | Physical? | Key Traits |");
|
|
275
|
+
lines.push("|---|---|---|---|");
|
|
276
|
+
for (const c of concepts) {
|
|
277
|
+
lines.push(`| ${c.name} | \`${c.hexCode}\` | ${c.isPhysical ? "Yes" : "No"} | ${c.traits.slice(0, 4).join(", ")} |`);
|
|
278
|
+
}
|
|
279
|
+
lines.push("");
|
|
280
|
+
// Similarities
|
|
281
|
+
if (comparisons.length > 0) {
|
|
282
|
+
lines.push("### Key Similarities");
|
|
283
|
+
lines.push("| Pair | Jaccard |");
|
|
284
|
+
lines.push("|---|---|");
|
|
285
|
+
for (const batch of comparisons) {
|
|
286
|
+
for (const comp of batch.comparisons) {
|
|
287
|
+
if (comp.jaccard_similarity >= 0.4) {
|
|
288
|
+
lines.push(`| ${batch.entity} / ${comp.candidate} | **${(comp.jaccard_similarity * 100).toFixed(0)}%** |`);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
lines.push("");
|
|
293
|
+
}
|
|
294
|
+
// Findings
|
|
295
|
+
lines.push("### Findings");
|
|
296
|
+
lines.push("| # | Severity | Title | Affected |");
|
|
297
|
+
lines.push("|---|---|---|---|");
|
|
298
|
+
for (let i = 0; i < findings.length; i++) {
|
|
299
|
+
const f = findings[i];
|
|
300
|
+
lines.push(`| ${i + 1} | **${f.severity}** | ${f.title} | ${f.affectedReqs.join(", ")} |`);
|
|
301
|
+
}
|
|
302
|
+
lines.push("");
|
|
303
|
+
for (const f of findings) {
|
|
304
|
+
lines.push(`#### ${f.title}`);
|
|
305
|
+
lines.push(`- **Severity:** ${f.severity} | **Category:** ${f.category}`);
|
|
306
|
+
lines.push(`- ${f.description}`);
|
|
307
|
+
lines.push(`- **Affects:** ${f.affectedReqs.join(", ")}`);
|
|
308
|
+
lines.push(`- **Recommendation:** ${f.recommendation}`);
|
|
309
|
+
lines.push("");
|
|
310
|
+
}
|
|
311
|
+
return lines.join("\n");
|
|
312
|
+
}
|
|
313
|
+
// ── Command registration ─────────────────────────────────────
|
|
314
|
+
export function registerLintCommands(program, client) {
|
|
315
|
+
program
|
|
316
|
+
.command("lint")
|
|
317
|
+
.description("Semantic requirements lint — classifies domain concepts via UHT and flags ontological issues")
|
|
318
|
+
.argument("<tenant>", "Tenant slug")
|
|
319
|
+
.argument("<project>", "Project slug")
|
|
320
|
+
.option("--concepts <n>", "Max concepts to classify", "15")
|
|
321
|
+
.option("--format <fmt>", "Output format: text, markdown, json", "text")
|
|
322
|
+
.option("-o, --output <file>", "Write report to file")
|
|
323
|
+
.action(async (tenant, project, opts) => {
|
|
324
|
+
const uht = new UhtClient();
|
|
325
|
+
if (!uht.isConfigured) {
|
|
326
|
+
console.error("UHT not configured. Set UHT_TOKEN environment variable.");
|
|
327
|
+
console.error("Get a token at https://universalhex.org");
|
|
328
|
+
process.exit(1);
|
|
329
|
+
}
|
|
330
|
+
const maxConcepts = parseInt(opts.concepts, 10) || 15;
|
|
331
|
+
// Step 1: Fetch all requirements
|
|
332
|
+
console.error("Fetching requirements...");
|
|
333
|
+
const requirements = await fetchAllRequirements(client, tenant, project);
|
|
334
|
+
if (requirements.length === 0) {
|
|
335
|
+
console.error("No requirements found.");
|
|
336
|
+
process.exit(1);
|
|
337
|
+
}
|
|
338
|
+
console.error(` ${requirements.length} requirements loaded.`);
|
|
339
|
+
// Step 2: Extract domain concepts
|
|
340
|
+
console.error("Extracting domain concepts...");
|
|
341
|
+
const conceptRefs = extractConcepts(requirements);
|
|
342
|
+
const top = topConcepts(conceptRefs, maxConcepts);
|
|
343
|
+
console.error(` ${conceptRefs.size} unique concepts found, classifying top ${top.length}.`);
|
|
344
|
+
// Step 3: Classify each concept via UHT
|
|
345
|
+
console.error("Classifying concepts via UHT...");
|
|
346
|
+
const concepts = [];
|
|
347
|
+
for (const [name, refs] of top) {
|
|
348
|
+
try {
|
|
349
|
+
const result = await uht.classify(name);
|
|
350
|
+
const traitNames = result.traits.map(t => t.name).filter(Boolean);
|
|
351
|
+
concepts.push({
|
|
352
|
+
name,
|
|
353
|
+
hexCode: result.hex_code,
|
|
354
|
+
isPhysical: traitNames.includes("Physical Object"),
|
|
355
|
+
traits: traitNames,
|
|
356
|
+
reqs: refs,
|
|
357
|
+
});
|
|
358
|
+
console.error(` ✓ ${name} → ${result.hex_code} (${traitNames.length} traits)`);
|
|
359
|
+
}
|
|
360
|
+
catch (err) {
|
|
361
|
+
console.error(` ✗ ${name}: ${err.message}`);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
// Step 4: Cross-compare concepts in batches
|
|
365
|
+
console.error("Cross-comparing concepts...");
|
|
366
|
+
const comparisons = [];
|
|
367
|
+
if (concepts.length >= 2) {
|
|
368
|
+
// Compare top concept against others, then second against rest
|
|
369
|
+
const names = concepts.map(c => c.name);
|
|
370
|
+
const batchSize = Math.min(names.length - 1, 15);
|
|
371
|
+
try {
|
|
372
|
+
const result = await uht.batchCompare(names[0], names.slice(1, batchSize + 1));
|
|
373
|
+
comparisons.push(result);
|
|
374
|
+
console.error(` ✓ ${names[0]} vs ${batchSize} others`);
|
|
375
|
+
}
|
|
376
|
+
catch (err) {
|
|
377
|
+
console.error(` ✗ batch compare: ${err.message}`);
|
|
378
|
+
}
|
|
379
|
+
if (names.length > 3) {
|
|
380
|
+
try {
|
|
381
|
+
const mid = Math.floor(names.length / 2);
|
|
382
|
+
const candidates = [...names.slice(0, mid), ...names.slice(mid + 1)].slice(0, 10);
|
|
383
|
+
const result = await uht.batchCompare(names[mid], candidates);
|
|
384
|
+
comparisons.push(result);
|
|
385
|
+
console.error(` ✓ ${names[mid]} vs ${candidates.length} others`);
|
|
386
|
+
}
|
|
387
|
+
catch (err) {
|
|
388
|
+
console.error(` ✗ batch compare: ${err.message}`);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
// Step 5: Analyze findings
|
|
393
|
+
console.error("Analyzing...");
|
|
394
|
+
const findings = analyzeFindings(concepts, comparisons, requirements);
|
|
395
|
+
// Step 6: Output report
|
|
396
|
+
let report;
|
|
397
|
+
if (opts.format === "json" || isJsonMode()) {
|
|
398
|
+
const data = { tenant, project, requirements: requirements.length, concepts, comparisons, findings };
|
|
399
|
+
report = JSON.stringify(data, null, 2);
|
|
400
|
+
}
|
|
401
|
+
else if (opts.format === "markdown") {
|
|
402
|
+
report = formatMarkdown(tenant, project, requirements, concepts, comparisons, findings);
|
|
403
|
+
}
|
|
404
|
+
else {
|
|
405
|
+
report = formatReport(tenant, project, requirements, concepts, comparisons, findings);
|
|
406
|
+
}
|
|
407
|
+
if (opts.output) {
|
|
408
|
+
writeFileSync(opts.output, report + "\n", "utf-8");
|
|
409
|
+
console.error(`Report written to ${opts.output}`);
|
|
410
|
+
}
|
|
411
|
+
else {
|
|
412
|
+
console.log(report);
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -19,6 +19,7 @@ import { registerReportCommands } from "./commands/reports.js";
|
|
|
19
19
|
import { registerImportExportCommands } from "./commands/import-export.js";
|
|
20
20
|
import { registerActivityCommands } from "./commands/activity.js";
|
|
21
21
|
import { registerImplementationCommands } from "./commands/implementation.js";
|
|
22
|
+
import { registerLintCommands } from "./commands/lint.js";
|
|
22
23
|
const program = new Command();
|
|
23
24
|
// Lazy-init: only create client when a command actually runs
|
|
24
25
|
let client = null;
|
|
@@ -67,6 +68,7 @@ registerReportCommands(program, clientProxy);
|
|
|
67
68
|
registerImportExportCommands(program, clientProxy);
|
|
68
69
|
registerActivityCommands(program, clientProxy);
|
|
69
70
|
registerImplementationCommands(program, clientProxy);
|
|
71
|
+
registerLintCommands(program, clientProxy);
|
|
70
72
|
// Handle async errors from Commander action handlers
|
|
71
73
|
process.on("uncaughtException", (err) => {
|
|
72
74
|
console.error(`Error: ${err.message}`);
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal UHT (Universal Hex Taxonomy) API client.
|
|
3
|
+
*
|
|
4
|
+
* Talks to the UHT Substrate factory API for entity classification and comparison.
|
|
5
|
+
* Token resolution: UHT_TOKEN env → UHT_API_KEY env → ~/.config/uht-substrate/config.json
|
|
6
|
+
*/
|
|
7
|
+
export interface UhtClassification {
|
|
8
|
+
entity: string;
|
|
9
|
+
hex_code: string;
|
|
10
|
+
traits: Array<{
|
|
11
|
+
name: string;
|
|
12
|
+
justification: string;
|
|
13
|
+
}>;
|
|
14
|
+
}
|
|
15
|
+
export interface UhtComparison {
|
|
16
|
+
candidate: string;
|
|
17
|
+
hex_code: string;
|
|
18
|
+
jaccard_similarity: number;
|
|
19
|
+
hamming_distance: number;
|
|
20
|
+
shared_traits: Array<{
|
|
21
|
+
name: string;
|
|
22
|
+
}>;
|
|
23
|
+
traits_entity_only: Array<{
|
|
24
|
+
name: string;
|
|
25
|
+
}>;
|
|
26
|
+
traits_candidate_only: Array<{
|
|
27
|
+
name: string;
|
|
28
|
+
}>;
|
|
29
|
+
}
|
|
30
|
+
export interface UhtBatchResult {
|
|
31
|
+
entity: string;
|
|
32
|
+
hex_code: string;
|
|
33
|
+
comparisons: UhtComparison[];
|
|
34
|
+
best_match: string;
|
|
35
|
+
best_jaccard: number;
|
|
36
|
+
}
|
|
37
|
+
export declare class UhtClient {
|
|
38
|
+
private baseUrl;
|
|
39
|
+
private token;
|
|
40
|
+
constructor();
|
|
41
|
+
get isConfigured(): boolean;
|
|
42
|
+
private request;
|
|
43
|
+
classify(entity: string): Promise<UhtClassification>;
|
|
44
|
+
batchCompare(entity: string, candidates: string[]): Promise<UhtBatchResult>;
|
|
45
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal UHT (Universal Hex Taxonomy) API client.
|
|
3
|
+
*
|
|
4
|
+
* Talks to the UHT Substrate factory API for entity classification and comparison.
|
|
5
|
+
* Token resolution: UHT_TOKEN env → UHT_API_KEY env → ~/.config/uht-substrate/config.json
|
|
6
|
+
*/
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
const DEFAULT_UHT_URL = "https://substrate.universalhex.org/api";
|
|
11
|
+
function loadUhtConfigToken() {
|
|
12
|
+
try {
|
|
13
|
+
const configPath = join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "uht-substrate", "config.json");
|
|
14
|
+
const config = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
15
|
+
return config.token ?? "";
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return "";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export class UhtClient {
|
|
22
|
+
baseUrl;
|
|
23
|
+
token;
|
|
24
|
+
constructor() {
|
|
25
|
+
this.baseUrl = (process.env.UHT_API_URL ?? DEFAULT_UHT_URL).replace(/\/+$/, "");
|
|
26
|
+
this.token = process.env.UHT_TOKEN || process.env.UHT_API_KEY || loadUhtConfigToken();
|
|
27
|
+
}
|
|
28
|
+
get isConfigured() {
|
|
29
|
+
return this.token.length > 0;
|
|
30
|
+
}
|
|
31
|
+
async request(method, path, body) {
|
|
32
|
+
const url = `${this.baseUrl}${path}`;
|
|
33
|
+
const headers = {};
|
|
34
|
+
if (body)
|
|
35
|
+
headers["Content-Type"] = "application/json";
|
|
36
|
+
if (this.token)
|
|
37
|
+
headers["Authorization"] = `Bearer ${this.token}`;
|
|
38
|
+
const res = await globalThis.fetch(url, {
|
|
39
|
+
method,
|
|
40
|
+
headers,
|
|
41
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
42
|
+
});
|
|
43
|
+
if (!res.ok) {
|
|
44
|
+
const text = await res.text();
|
|
45
|
+
throw new Error(`UHT API error (${res.status}): ${text}`);
|
|
46
|
+
}
|
|
47
|
+
return (await res.json());
|
|
48
|
+
}
|
|
49
|
+
async classify(entity) {
|
|
50
|
+
return this.request("POST", "/classify", { entity, context: "", use_semantic_priors: false });
|
|
51
|
+
}
|
|
52
|
+
async batchCompare(entity, candidates) {
|
|
53
|
+
return this.request("POST", "/batch-compare", { entity, candidates });
|
|
54
|
+
}
|
|
55
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "airgen-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "AIRGen CLI — requirements engineering from the command line",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
11
|
"dist",
|
|
12
|
-
"package.json"
|
|
12
|
+
"package.json",
|
|
13
|
+
"README.md"
|
|
13
14
|
],
|
|
14
15
|
"scripts": {
|
|
15
16
|
"build": "tsc -p tsconfig.json",
|
|
@@ -21,7 +22,12 @@
|
|
|
21
22
|
"requirements",
|
|
22
23
|
"engineering",
|
|
23
24
|
"cli",
|
|
24
|
-
"requirements-management"
|
|
25
|
+
"requirements-management",
|
|
26
|
+
"systems-engineering",
|
|
27
|
+
"traceability",
|
|
28
|
+
"sysml",
|
|
29
|
+
"mbse",
|
|
30
|
+
"uht"
|
|
25
31
|
],
|
|
26
32
|
"license": "MIT",
|
|
27
33
|
"repository": {
|