airgen-cli 0.21.1 → 0.23.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.
|
@@ -1,4 +1,10 @@
|
|
|
1
|
+
import { writeFileSync } from "node:fs";
|
|
1
2
|
import { output, printTable, isJsonMode } from "../output.js";
|
|
3
|
+
function refFromRequirementId(id) {
|
|
4
|
+
// Composite IDs are tenant:project:REF
|
|
5
|
+
const parts = id.split(":");
|
|
6
|
+
return parts[parts.length - 1];
|
|
7
|
+
}
|
|
2
8
|
export function registerBaselineCommands(program, client) {
|
|
3
9
|
const cmd = program.command("baselines").alias("bl").description("Baseline snapshots");
|
|
4
10
|
cmd
|
|
@@ -52,4 +58,135 @@ export function registerBaselineCommands(program, client) {
|
|
|
52
58
|
const data = await client.get(`/baselines/${tenant}/${project}/compare`, { from: opts.from, to: opts.to });
|
|
53
59
|
output(data);
|
|
54
60
|
});
|
|
61
|
+
cmd
|
|
62
|
+
.command("show")
|
|
63
|
+
.description("Show baseline contents (requirements with full text, rationale, tags)")
|
|
64
|
+
.argument("<tenant>", "Tenant slug")
|
|
65
|
+
.argument("<project>", "Project slug")
|
|
66
|
+
.argument("<baseline-ref>", "Baseline ref")
|
|
67
|
+
.option("--ref <pattern>", "Filter to requirements whose ref matches this substring")
|
|
68
|
+
.action(async (tenant, project, baselineRef, opts) => {
|
|
69
|
+
const snap = await client.get(`/baselines/${tenant}/${project}/${encodeURIComponent(baselineRef)}`);
|
|
70
|
+
let reqs = snap.requirementVersions ?? [];
|
|
71
|
+
if (opts.ref) {
|
|
72
|
+
const needle = opts.ref;
|
|
73
|
+
reqs = reqs.filter(r => refFromRequirementId(r.requirementId).includes(needle));
|
|
74
|
+
}
|
|
75
|
+
if (isJsonMode()) {
|
|
76
|
+
output({ baseline: snap.baseline, requirementVersions: reqs });
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
console.log(`Baseline: ${snap.baseline.ref ?? snap.baseline.id}`);
|
|
80
|
+
console.log(`Label: ${snap.baseline.label ?? ""}`);
|
|
81
|
+
console.log(`Created: ${snap.baseline.createdAt ?? ""}`);
|
|
82
|
+
console.log(`Requirements: ${reqs.length}\n`);
|
|
83
|
+
for (const r of reqs) {
|
|
84
|
+
const ref = refFromRequirementId(r.requirementId);
|
|
85
|
+
console.log(`── ${ref} (v${r.versionNumber}) ──`);
|
|
86
|
+
console.log(`text: ${r.text}`);
|
|
87
|
+
if (r.pattern)
|
|
88
|
+
console.log(`pattern: ${r.pattern}`);
|
|
89
|
+
if (r.verification)
|
|
90
|
+
console.log(`verify: ${r.verification}`);
|
|
91
|
+
if (r.rationale)
|
|
92
|
+
console.log(`rationale: ${r.rationale}`);
|
|
93
|
+
if (r.complianceStatus)
|
|
94
|
+
console.log(`compliance: ${r.complianceStatus}`);
|
|
95
|
+
if (r.tags?.length)
|
|
96
|
+
console.log(`tags: ${r.tags.join(", ")}`);
|
|
97
|
+
console.log("");
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
cmd
|
|
101
|
+
.command("export")
|
|
102
|
+
.description("Export full baseline snapshot as JSON")
|
|
103
|
+
.argument("<tenant>", "Tenant slug")
|
|
104
|
+
.argument("<project>", "Project slug")
|
|
105
|
+
.argument("<baseline-ref>", "Baseline ref")
|
|
106
|
+
.option("-o, --output <file>", "Write JSON to file instead of stdout")
|
|
107
|
+
.action(async (tenant, project, baselineRef, opts) => {
|
|
108
|
+
const snap = await client.get(`/baselines/${tenant}/${project}/${encodeURIComponent(baselineRef)}`);
|
|
109
|
+
const json = JSON.stringify(snap, null, 2);
|
|
110
|
+
if (opts.output) {
|
|
111
|
+
writeFileSync(opts.output, json);
|
|
112
|
+
console.error(`Wrote ${opts.output} (${(json.length / 1024).toFixed(1)} KB, ${snap.requirementVersions?.length ?? 0} requirements)`);
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
process.stdout.write(json + "\n");
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
cmd
|
|
119
|
+
.command("restore")
|
|
120
|
+
.description("Restore requirements from a baseline (PATCHes live requirements with snapshot values)")
|
|
121
|
+
.argument("<tenant>", "Tenant slug")
|
|
122
|
+
.argument("<project>", "Project slug")
|
|
123
|
+
.argument("<baseline-ref>", "Baseline ref")
|
|
124
|
+
.option("--ref <pattern>", "Restore only requirements whose ref includes this substring")
|
|
125
|
+
.option("--fields <list>", "Comma-separated fields to restore (default: rationale,text,pattern,verification,complianceStatus,complianceRationale,tags)", "rationale,text,pattern,verification,complianceStatus,complianceRationale,tags")
|
|
126
|
+
.option("--dry-run", "Print what would be restored without making changes")
|
|
127
|
+
.option("--yes", "Skip confirmation prompt")
|
|
128
|
+
.action(async (tenant, project, baselineRef, opts) => {
|
|
129
|
+
const snap = await client.get(`/baselines/${tenant}/${project}/${encodeURIComponent(baselineRef)}`);
|
|
130
|
+
let reqs = snap.requirementVersions ?? [];
|
|
131
|
+
if (opts.ref) {
|
|
132
|
+
const needle = opts.ref;
|
|
133
|
+
reqs = reqs.filter(r => refFromRequirementId(r.requirementId).includes(needle));
|
|
134
|
+
}
|
|
135
|
+
const fields = opts.fields.split(",").map(f => f.trim()).filter(Boolean);
|
|
136
|
+
const allowed = new Set(["text", "pattern", "verification", "rationale", "complianceStatus", "complianceRationale", "tags"]);
|
|
137
|
+
const invalid = fields.filter(f => !allowed.has(f));
|
|
138
|
+
if (invalid.length > 0) {
|
|
139
|
+
console.error(`Invalid fields: ${invalid.join(", ")}`);
|
|
140
|
+
console.error(`Allowed: ${[...allowed].join(", ")}`);
|
|
141
|
+
process.exit(1);
|
|
142
|
+
}
|
|
143
|
+
console.error(`Baseline: ${snap.baseline.ref ?? snap.baseline.id}`);
|
|
144
|
+
console.error(`Label: ${snap.baseline.label ?? ""}`);
|
|
145
|
+
console.error(`Created: ${snap.baseline.createdAt ?? ""}`);
|
|
146
|
+
console.error(`Requirements to restore: ${reqs.length}`);
|
|
147
|
+
console.error(`Fields: ${fields.join(", ")}`);
|
|
148
|
+
console.error("");
|
|
149
|
+
if (opts.dryRun) {
|
|
150
|
+
for (const r of reqs) {
|
|
151
|
+
const ref = refFromRequirementId(r.requirementId);
|
|
152
|
+
const summary = fields.map(f => {
|
|
153
|
+
const v = r[f];
|
|
154
|
+
if (v == null)
|
|
155
|
+
return `${f}=(none)`;
|
|
156
|
+
const s = typeof v === "string" ? v : JSON.stringify(v);
|
|
157
|
+
return `${f}=${s.length > 40 ? s.slice(0, 40) + "…" : s}`;
|
|
158
|
+
}).join(" | ");
|
|
159
|
+
console.log(`[dry-run] ${ref}: ${summary}`);
|
|
160
|
+
}
|
|
161
|
+
console.error(`\n[dry-run] Would restore ${reqs.length} requirements. Re-run without --dry-run to apply.`);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (!opts.yes) {
|
|
165
|
+
console.error(`This will OVERWRITE current values for ${reqs.length} requirements.`);
|
|
166
|
+
console.error("Pass --yes to proceed, or --dry-run to preview.");
|
|
167
|
+
process.exit(1);
|
|
168
|
+
}
|
|
169
|
+
let ok = 0;
|
|
170
|
+
let failed = 0;
|
|
171
|
+
for (const r of reqs) {
|
|
172
|
+
const ref = refFromRequirementId(r.requirementId);
|
|
173
|
+
const body = {};
|
|
174
|
+
for (const f of fields) {
|
|
175
|
+
const v = r[f];
|
|
176
|
+
if (v !== undefined)
|
|
177
|
+
body[f] = v;
|
|
178
|
+
}
|
|
179
|
+
try {
|
|
180
|
+
await client.patch(`/requirements/${tenant}/${project}/${encodeURIComponent(ref)}`, body);
|
|
181
|
+
ok++;
|
|
182
|
+
if (ok % 25 === 0)
|
|
183
|
+
console.error(` restored ${ok}/${reqs.length}…`);
|
|
184
|
+
}
|
|
185
|
+
catch (err) {
|
|
186
|
+
failed++;
|
|
187
|
+
console.error(` FAILED ${ref}: ${err instanceof Error ? err.message : String(err)}`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
console.error(`\nDone. Restored ${ok}, failed ${failed}.`);
|
|
191
|
+
});
|
|
55
192
|
}
|
package/dist/commands/diff.js
CHANGED
|
@@ -159,10 +159,21 @@ export function registerDiffCommand(program, client) {
|
|
|
159
159
|
.argument("<tenant>", "Tenant slug")
|
|
160
160
|
.argument("<project>", "Project slug")
|
|
161
161
|
.requiredOption("--from <ref>", "Source baseline ref (earlier)")
|
|
162
|
-
.
|
|
162
|
+
.option("--to <ref>", "Target baseline ref (later). If omitted, lists available baselines.")
|
|
163
163
|
.option("--format <fmt>", "Output format: text, markdown", "text")
|
|
164
164
|
.option("-o, --output <file>", "Write report to file")
|
|
165
165
|
.action(async (tenant, project, opts) => {
|
|
166
|
+
if (!opts.to) {
|
|
167
|
+
// Show available baselines when --to is omitted
|
|
168
|
+
const blData = await client.get(`/baselines/${tenant}/${project}`);
|
|
169
|
+
const baselines = blData.items ?? [];
|
|
170
|
+
console.error(`Available baselines (use --to <ref>):\n`);
|
|
171
|
+
for (const b of baselines) {
|
|
172
|
+
const marker = b.ref === opts.from ? " ← --from" : "";
|
|
173
|
+
console.error(` ${b.ref} ${b.label ?? ""} ${b.createdAt ?? ""}${marker}`);
|
|
174
|
+
}
|
|
175
|
+
process.exit(1);
|
|
176
|
+
}
|
|
166
177
|
const data = await client.get(`/baselines/${tenant}/${project}/compare`, { from: opts.from, to: opts.to });
|
|
167
178
|
let result;
|
|
168
179
|
if (isJsonMode()) {
|
|
@@ -42,12 +42,18 @@ export function registerImportExportCommands(program, client) {
|
|
|
42
42
|
const exp = program.command("export").description("Export data");
|
|
43
43
|
exp
|
|
44
44
|
.command("requirements")
|
|
45
|
-
.description("Export requirements as markdown or JSON")
|
|
45
|
+
.description("Export requirements as markdown or JSON (current state or from a baseline)")
|
|
46
46
|
.argument("<tenant>", "Tenant slug")
|
|
47
47
|
.argument("<project>", "Project slug")
|
|
48
48
|
.option("--document <slug>", "Export specific document as markdown")
|
|
49
49
|
.option("--format <fmt>", "Format: json, markdown", "json")
|
|
50
|
+
.option("--baseline <ref>", "Export requirement state captured in a baseline (instead of current)")
|
|
50
51
|
.action(async (tenant, project, opts) => {
|
|
52
|
+
if (opts.baseline) {
|
|
53
|
+
const snap = await client.get(`/baselines/${tenant}/${project}/${encodeURIComponent(opts.baseline)}`);
|
|
54
|
+
output({ baseline: snap.baseline, requirements: snap.requirementVersions ?? [] });
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
51
57
|
if (opts.document && opts.format === "markdown") {
|
|
52
58
|
const data = await client.get(`/markdown/${tenant}/${project}/${opts.document}/content`);
|
|
53
59
|
if (isJsonMode()) {
|
package/dist/commands/lint.js
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
2
2
|
import { UhtClient, decodeHexTraits } from "../uht-client.js";
|
|
3
3
|
import { isJsonMode } from "../output.js";
|
|
4
|
+
function sourceRef(l) {
|
|
5
|
+
return l.sourceRequirement?.ref ?? l.sourceRequirementId ?? "";
|
|
6
|
+
}
|
|
7
|
+
function targetRef(l) {
|
|
8
|
+
return l.targetRequirement?.ref ?? l.targetRequirementId ?? "";
|
|
9
|
+
}
|
|
4
10
|
const TRAIT_CHECKS = [
|
|
5
11
|
// Physical Layer (1-8)
|
|
6
12
|
{ trait: "Physical Object", bit: 1, severity: "high", inverted: true,
|
|
@@ -225,12 +231,16 @@ async function parallelMap(items, fn, concurrency) {
|
|
|
225
231
|
function extractConcepts(requirements) {
|
|
226
232
|
const conceptRefs = new Map();
|
|
227
233
|
const skip = new Set(["system", "the system", "it", "this", "all", "each", "any", "user", "operator"]);
|
|
234
|
+
// Mode names are system-level operating states, not decomposable concepts
|
|
235
|
+
const modePattern = /^(?:normal|degraded|emergency|standby|idle|startup|shutdown|maintenance|manual|automatic|autonomous|safe|fault|failsafe|backup|override|test|diagnostic|calibration)\s+(?:mode|operation|state|condition|running|navigation)/i;
|
|
228
236
|
function addConcept(concept, ref) {
|
|
229
237
|
const normalized = concept.toLowerCase().trim();
|
|
230
238
|
if (normalized.length < 3 || normalized.length > 60)
|
|
231
239
|
return;
|
|
232
240
|
if (skip.has(normalized))
|
|
233
241
|
return;
|
|
242
|
+
if (modePattern.test(normalized))
|
|
243
|
+
return;
|
|
234
244
|
const refs = conceptRefs.get(normalized) ?? [];
|
|
235
245
|
if (!refs.includes(ref))
|
|
236
246
|
refs.push(ref);
|
|
@@ -450,7 +460,7 @@ function analyzeFindings(concepts, comparisons, requirements, traceLinks, reqTie
|
|
|
450
460
|
if (traceLinks.length > 0) {
|
|
451
461
|
const outgoing = new Map();
|
|
452
462
|
for (const link of traceLinks) {
|
|
453
|
-
const src =
|
|
463
|
+
const src = sourceRef(link);
|
|
454
464
|
if (!outgoing.has(src))
|
|
455
465
|
outgoing.set(src, []);
|
|
456
466
|
outgoing.get(src).push(link);
|
|
@@ -571,6 +581,80 @@ function analyzeFindings(concepts, comparisons, requirements, traceLinks, reqTie
|
|
|
571
581
|
});
|
|
572
582
|
}
|
|
573
583
|
}
|
|
584
|
+
// ── 11. Quantitative value consistency across subsystems ────
|
|
585
|
+
// Extract numeric values + units, group by value across document tiers, flag duplicates without derivation links
|
|
586
|
+
const valuePattern = /(\d+(?:\.\d+)?)\s*(ms|s|sec|seconds?|minutes?|min|hours?|hr|Hz|kHz|MHz|GHz|V|kV|mV|A|mA|W|kW|MW|m|km|cm|mm|m\/s|km\/h|mph|dB|dBm|°C|°F|K|Pa|kPa|MPa|bar|psi|kg|g|lb|N|kN|%)/gi;
|
|
587
|
+
const valueOccurrences = new Map();
|
|
588
|
+
for (const req of requirements) {
|
|
589
|
+
if (!req.text || !req.ref)
|
|
590
|
+
continue;
|
|
591
|
+
const tier = reqTierMap.get(req.ref) ?? "?";
|
|
592
|
+
let vm;
|
|
593
|
+
while ((vm = valuePattern.exec(req.text)) !== null) {
|
|
594
|
+
const key = `${vm[1]}${vm[2].toLowerCase()}`;
|
|
595
|
+
const list = valueOccurrences.get(key) ?? [];
|
|
596
|
+
list.push({ ref: req.ref, tier });
|
|
597
|
+
valueOccurrences.set(key, list);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
// Flag values appearing across different subsystems without trace links
|
|
601
|
+
for (const [value, occurrences] of valueOccurrences) {
|
|
602
|
+
const tiers = new Set(occurrences.map(o => o.tier));
|
|
603
|
+
if (tiers.size < 2)
|
|
604
|
+
continue; // Only flag cross-tier
|
|
605
|
+
// Check if any pair has a derives trace link
|
|
606
|
+
const refs = occurrences.map(o => o.ref);
|
|
607
|
+
const hasDerivation = traceLinks.some(l => l.linkType === "derives" &&
|
|
608
|
+
refs.includes(sourceRef(l)) && refs.includes(targetRef(l)));
|
|
609
|
+
if (!hasDerivation && occurrences.length >= 2) {
|
|
610
|
+
findings.push({
|
|
611
|
+
severity: "medium",
|
|
612
|
+
category: "Value Consistency",
|
|
613
|
+
title: `Threshold "${value}" appears across ${tiers.size} document tiers without derivation`,
|
|
614
|
+
description: `"${value}" appears in ${occurrences.length} requirements across tiers ${[...tiers].join(", ")} with no derives trace link connecting them.`,
|
|
615
|
+
affectedReqs: refs.slice(0, 10),
|
|
616
|
+
recommendation: `Add derivation rationale or trace links explaining why "${value}" is used across subsystems.`,
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
// ── 12. Round-number threshold detector ───────────────────
|
|
621
|
+
const roundPattern = /\b(\d+)\s*(ms|s|Hz|V|W|kW|MW|m|km|km\/h|mph|dB|%)\b/gi;
|
|
622
|
+
const roundCounts = new Map();
|
|
623
|
+
for (const req of requirements) {
|
|
624
|
+
if (!req.text || !req.ref)
|
|
625
|
+
continue;
|
|
626
|
+
let rm;
|
|
627
|
+
while ((rm = roundPattern.exec(req.text)) !== null) {
|
|
628
|
+
const num = parseInt(rm[1], 10);
|
|
629
|
+
// Only flag round numbers (multiples of 10, 50, 100, 500, 1000)
|
|
630
|
+
if (num >= 10 && (num % 10 === 0 || num % 50 === 0 || num % 100 === 0)) {
|
|
631
|
+
const key = `${num}${rm[2].toLowerCase()}`;
|
|
632
|
+
const refs = roundCounts.get(key) ?? [];
|
|
633
|
+
if (!refs.includes(req.ref))
|
|
634
|
+
refs.push(req.ref);
|
|
635
|
+
roundCounts.set(key, refs);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
for (const [value, refs] of roundCounts) {
|
|
640
|
+
if (refs.length <= 3)
|
|
641
|
+
continue; // Only flag if >3 occurrences
|
|
642
|
+
// Check if any has derivation rationale
|
|
643
|
+
const hasRationale = refs.some(ref => {
|
|
644
|
+
const req = requirements.find(r => r.ref === ref);
|
|
645
|
+
return req?.rationale && /derived from|allocated from|calculated|analysis|budget/i.test(req.rationale);
|
|
646
|
+
});
|
|
647
|
+
if (!hasRationale) {
|
|
648
|
+
findings.push({
|
|
649
|
+
severity: "low",
|
|
650
|
+
category: "Design Assumption",
|
|
651
|
+
title: `Round threshold "${value}" appears ${refs.length} times without derivation rationale`,
|
|
652
|
+
description: `"${value}" is used in ${refs.length} requirements — likely an undocumented design assumption.`,
|
|
653
|
+
affectedReqs: refs.slice(0, 10),
|
|
654
|
+
recommendation: `Add rationale explaining why "${value}" was chosen, or trace to the source requirement that allocates this value.`,
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
}
|
|
574
658
|
return findings.sort((a, b) => {
|
|
575
659
|
const sev = { high: 0, medium: 1, low: 2 };
|
|
576
660
|
return sev[a.severity] - sev[b.severity];
|
|
@@ -221,12 +221,16 @@ export function registerRequirementCommands(program, client) {
|
|
|
221
221
|
else if (opts.tags) {
|
|
222
222
|
body.tags = opts.tags.split(",").map(t => t.trim());
|
|
223
223
|
}
|
|
224
|
-
await client.patch(`/requirements/${tenant}/${project}/${resolvedId}`, body);
|
|
224
|
+
const result = await client.patch(`/requirements/${tenant}/${project}/${resolvedId}`, body);
|
|
225
|
+
const warnings = result?.warnings ?? [];
|
|
225
226
|
if (isJsonMode()) {
|
|
226
|
-
output({ ok: true });
|
|
227
|
+
output({ ok: true, warnings });
|
|
227
228
|
}
|
|
228
229
|
else {
|
|
229
230
|
console.log("Requirement updated.");
|
|
231
|
+
for (const w of warnings) {
|
|
232
|
+
console.error(` warning: ${w}`);
|
|
233
|
+
}
|
|
230
234
|
}
|
|
231
235
|
});
|
|
232
236
|
cmd
|