airgen-cli 0.17.2 → 0.19.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.
|
@@ -176,12 +176,15 @@ function renderTerminal(blocks, connectors) {
|
|
|
176
176
|
function renderMermaid(blocks, connectors, direction) {
|
|
177
177
|
resetMermaidIds();
|
|
178
178
|
const lines = [`flowchart ${direction}`];
|
|
179
|
+
const blockIds = new Set(blocks.map(b => b.id));
|
|
179
180
|
// Nodes
|
|
180
181
|
for (const b of blocks) {
|
|
181
182
|
lines.push(` ${mermaidNodeShape(b)}`);
|
|
182
183
|
}
|
|
183
|
-
// Edges
|
|
184
|
+
// Edges (skip orphan connectors whose source or target block is missing)
|
|
184
185
|
for (const c of connectors) {
|
|
186
|
+
if (!blockIds.has(c.source) || !blockIds.has(c.target))
|
|
187
|
+
continue;
|
|
185
188
|
const src = sanitizeId(c.source);
|
|
186
189
|
const tgt = sanitizeId(c.target);
|
|
187
190
|
const arrow = mermaidArrow(c.kind);
|
|
@@ -288,7 +291,10 @@ export function registerDiagramCommands(program, client) {
|
|
|
288
291
|
if (opts.clean) {
|
|
289
292
|
rendered = rendered
|
|
290
293
|
.split("\n")
|
|
291
|
-
.filter(l =>
|
|
294
|
+
.filter(l => {
|
|
295
|
+
const t = l.trim();
|
|
296
|
+
return !t.startsWith("classDef ") && !t.startsWith("class ") && !t.startsWith("style ") && t !== "";
|
|
297
|
+
})
|
|
292
298
|
.map(l => l
|
|
293
299
|
.replace(/<<[^>]+>><br\/?>/g, "") // Strip <<stereotype>><br> or <<stereotype>><br/>
|
|
294
300
|
.replace(/[a-z]+<br>/gi, (m) => m) // Keep stereo<br>label (valid Mermaid)
|
|
@@ -459,7 +465,7 @@ export function registerDiagramCommands(program, client) {
|
|
|
459
465
|
});
|
|
460
466
|
blocks
|
|
461
467
|
.command("create")
|
|
462
|
-
.description("Create a new block")
|
|
468
|
+
.description("Create a new block (use --upsert to return existing if name matches)")
|
|
463
469
|
.argument("<tenant>", "Tenant slug")
|
|
464
470
|
.argument("<project-key>", "Project key")
|
|
465
471
|
.requiredOption("--diagram <id>", "Diagram ID")
|
|
@@ -471,7 +477,22 @@ export function registerDiagramCommands(program, client) {
|
|
|
471
477
|
.option("--height <n>", "Height")
|
|
472
478
|
.option("--stereotype <s>", "Stereotype")
|
|
473
479
|
.option("--description <desc>", "Description")
|
|
480
|
+
.option("--upsert", "Return existing block if name matches on this diagram instead of creating duplicate")
|
|
474
481
|
.action(async (tenant, projectKey, opts) => {
|
|
482
|
+
// Upsert: check for existing block with same name on this diagram
|
|
483
|
+
if (opts.upsert) {
|
|
484
|
+
const existing = await client.get(`/architecture/blocks/${tenant}/${projectKey}/${opts.diagram}`);
|
|
485
|
+
const match = (existing.blocks ?? []).find(b => b.name.toLowerCase() === opts.name.toLowerCase());
|
|
486
|
+
if (match) {
|
|
487
|
+
if (isJsonMode()) {
|
|
488
|
+
output({ block: match, deduplicated: true });
|
|
489
|
+
}
|
|
490
|
+
else {
|
|
491
|
+
console.log(`Block already exists: ${match.id} (${match.name})`);
|
|
492
|
+
}
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
475
496
|
const data = await client.post("/architecture/blocks", {
|
|
476
497
|
tenant,
|
|
477
498
|
projectKey,
|
|
@@ -493,6 +514,85 @@ export function registerDiagramCommands(program, client) {
|
|
|
493
514
|
output(data);
|
|
494
515
|
}
|
|
495
516
|
});
|
|
517
|
+
blocks
|
|
518
|
+
.command("deduplicate")
|
|
519
|
+
.description("Find and remove duplicate-named blocks on a diagram")
|
|
520
|
+
.argument("<tenant>", "Tenant slug")
|
|
521
|
+
.argument("<project>", "Project slug")
|
|
522
|
+
.option("--diagram <id>", "Specific diagram ID (otherwise checks all)")
|
|
523
|
+
.option("--dry-run", "Show duplicates without deleting (default)")
|
|
524
|
+
.option("--apply", "Actually delete duplicates")
|
|
525
|
+
.action(async (tenant, project, opts) => {
|
|
526
|
+
const diagramIds = [];
|
|
527
|
+
if (opts.diagram) {
|
|
528
|
+
diagramIds.push(opts.diagram);
|
|
529
|
+
}
|
|
530
|
+
else {
|
|
531
|
+
const data = await client.get(`/architecture/diagrams/${tenant}/${project}`);
|
|
532
|
+
diagramIds.push(...(data.diagrams ?? []).map(d => d.id));
|
|
533
|
+
}
|
|
534
|
+
let totalRemoved = 0;
|
|
535
|
+
for (const diagId of diagramIds) {
|
|
536
|
+
const data = await client.get(`/architecture/blocks/${tenant}/${project}/${diagId}`);
|
|
537
|
+
const allBlocks = data.blocks ?? [];
|
|
538
|
+
const byName = new Map();
|
|
539
|
+
for (const b of allBlocks) {
|
|
540
|
+
const name = b.name.toLowerCase().trim();
|
|
541
|
+
const group = byName.get(name) ?? [];
|
|
542
|
+
group.push(b);
|
|
543
|
+
byName.set(name, group);
|
|
544
|
+
}
|
|
545
|
+
for (const [, group] of byName) {
|
|
546
|
+
if (group.length <= 1)
|
|
547
|
+
continue;
|
|
548
|
+
// Keep oldest (first created)
|
|
549
|
+
const sorted = group.sort((a, b) => (a.id < b.id ? -1 : 1));
|
|
550
|
+
const keep = sorted[0];
|
|
551
|
+
const dupes = sorted.slice(1);
|
|
552
|
+
console.log(` "${keep.name}" on ${diagId}: keeping ${keep.id}, ${dupes.length} duplicate(s)`);
|
|
553
|
+
if (opts.apply) {
|
|
554
|
+
for (const dup of dupes) {
|
|
555
|
+
await client.delete(`/architecture/blocks/${tenant}/${project}/${dup.id}`);
|
|
556
|
+
totalRemoved++;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
else {
|
|
560
|
+
for (const dup of dupes) {
|
|
561
|
+
console.log(` [dry-run] would remove ${dup.id}`);
|
|
562
|
+
}
|
|
563
|
+
totalRemoved += dupes.length;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
console.log(`${opts.apply ? "Removed" : "Would remove"} ${totalRemoved} duplicate block(s).`);
|
|
568
|
+
});
|
|
569
|
+
blocks
|
|
570
|
+
.command("find")
|
|
571
|
+
.description("Search blocks by name across project or diagram")
|
|
572
|
+
.argument("<tenant>", "Tenant slug")
|
|
573
|
+
.argument("<project>", "Project slug")
|
|
574
|
+
.requiredOption("--name <name>", "Block name to search (case-insensitive substring)")
|
|
575
|
+
.option("--diagram <id>", "Limit to specific diagram")
|
|
576
|
+
.action(async (tenant, project, opts) => {
|
|
577
|
+
if (opts.diagram) {
|
|
578
|
+
const data = await client.get(`/architecture/blocks/${tenant}/${project}/${opts.diagram}`);
|
|
579
|
+
const matches = (data.blocks ?? []).filter(b => b.name.toLowerCase().includes(opts.name.toLowerCase()));
|
|
580
|
+
if (isJsonMode()) {
|
|
581
|
+
output(matches);
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
printTable(["ID", "Name", "Kind"], matches.map(b => [b.id, b.name, b.kind ?? ""]));
|
|
585
|
+
}
|
|
586
|
+
else {
|
|
587
|
+
const libData = await client.get(`/architecture/block-library/${tenant}/${project}`);
|
|
588
|
+
const matches = (libData.blocks ?? []).filter(b => b.name.toLowerCase().includes(opts.name.toLowerCase()));
|
|
589
|
+
if (isJsonMode()) {
|
|
590
|
+
output(matches);
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
printTable(["ID", "Name", "Kind"], matches.map(b => [b.id, b.name, b.kind ?? ""]));
|
|
594
|
+
}
|
|
595
|
+
});
|
|
496
596
|
blocks
|
|
497
597
|
.command("delete")
|
|
498
598
|
.description("Delete a block from the project")
|
|
@@ -505,6 +605,65 @@ export function registerDiagramCommands(program, client) {
|
|
|
505
605
|
});
|
|
506
606
|
// Connectors sub-group
|
|
507
607
|
const connectors = cmd.command("connectors").alias("conn").description("Manage connectors");
|
|
608
|
+
connectors
|
|
609
|
+
.command("list")
|
|
610
|
+
.description("List connectors in a diagram")
|
|
611
|
+
.argument("<tenant>", "Tenant slug")
|
|
612
|
+
.argument("<project>", "Project slug")
|
|
613
|
+
.argument("<diagram-id>", "Diagram ID")
|
|
614
|
+
.action(async (tenant, project, diagramId) => {
|
|
615
|
+
const data = await client.get(`/architecture/connectors/${tenant}/${project}/${diagramId}`);
|
|
616
|
+
const list = data.connectors ?? [];
|
|
617
|
+
if (isJsonMode()) {
|
|
618
|
+
output(list);
|
|
619
|
+
}
|
|
620
|
+
else {
|
|
621
|
+
if (list.length === 0) {
|
|
622
|
+
console.log("No connectors in this diagram.");
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
printTable(["ID", "Source", "Target", "Kind", "Label"], list.map(c => [c.id, c.source, c.target, c.kind ?? "", c.label ?? ""]));
|
|
626
|
+
}
|
|
627
|
+
});
|
|
628
|
+
connectors
|
|
629
|
+
.command("cleanup")
|
|
630
|
+
.description("Find and remove orphan connectors (source or target block missing)")
|
|
631
|
+
.argument("<tenant>", "Tenant slug")
|
|
632
|
+
.argument("<project>", "Project slug")
|
|
633
|
+
.option("--diagram <id>", "Specific diagram ID (otherwise checks all)")
|
|
634
|
+
.option("--apply", "Actually delete orphans (default is dry-run)")
|
|
635
|
+
.action(async (tenant, project, opts) => {
|
|
636
|
+
const diagramIds = [];
|
|
637
|
+
if (opts.diagram) {
|
|
638
|
+
diagramIds.push(opts.diagram);
|
|
639
|
+
}
|
|
640
|
+
else {
|
|
641
|
+
const data = await client.get(`/architecture/diagrams/${tenant}/${project}`);
|
|
642
|
+
diagramIds.push(...(data.diagrams ?? []).map(d => d.id));
|
|
643
|
+
}
|
|
644
|
+
let totalOrphans = 0;
|
|
645
|
+
for (const diagId of diagramIds) {
|
|
646
|
+
const [blockData, connData] = await Promise.all([
|
|
647
|
+
client.get(`/architecture/blocks/${tenant}/${project}/${diagId}`),
|
|
648
|
+
client.get(`/architecture/connectors/${tenant}/${project}/${diagId}`),
|
|
649
|
+
]);
|
|
650
|
+
const blockIds = new Set((blockData.blocks ?? []).map(b => b.id));
|
|
651
|
+
const orphans = (connData.connectors ?? []).filter(c => !blockIds.has(c.source) || !blockIds.has(c.target));
|
|
652
|
+
if (orphans.length === 0)
|
|
653
|
+
continue;
|
|
654
|
+
console.log(` ${diagId}: ${orphans.length} orphan connector(s)`);
|
|
655
|
+
for (const c of orphans) {
|
|
656
|
+
if (opts.apply) {
|
|
657
|
+
await client.delete(`/architecture/connectors/${tenant}/${project}/${c.id}?diagramId=${diagId}`);
|
|
658
|
+
}
|
|
659
|
+
else {
|
|
660
|
+
console.log(` [dry-run] ${c.id}: ${c.source} → ${c.target} (${c.kind ?? "?"})`);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
totalOrphans += orphans.length;
|
|
664
|
+
}
|
|
665
|
+
console.log(`${opts.apply ? "Removed" : "Would remove"} ${totalOrphans} orphan connector(s).`);
|
|
666
|
+
});
|
|
508
667
|
connectors
|
|
509
668
|
.command("create")
|
|
510
669
|
.description("Create a connector between blocks")
|
|
@@ -518,7 +677,21 @@ export function registerDiagramCommands(program, client) {
|
|
|
518
677
|
.option("--source-port <id>", "Source port ID")
|
|
519
678
|
.option("--target-port <id>", "Target port ID")
|
|
520
679
|
.option("--line-style <style>", "Line style: straight, smoothstep, step, polyline, bezier")
|
|
680
|
+
.option("--upsert", "Return existing connector if source+target+diagram matches")
|
|
521
681
|
.action(async (tenant, projectKey, opts) => {
|
|
682
|
+
if (opts.upsert) {
|
|
683
|
+
const existing = await client.get(`/architecture/connectors/${tenant}/${projectKey}/${opts.diagram}`);
|
|
684
|
+
const match = (existing.connectors ?? []).find(c => c.source === opts.source && c.target === opts.target);
|
|
685
|
+
if (match) {
|
|
686
|
+
if (isJsonMode()) {
|
|
687
|
+
output({ connector: match, deduplicated: true });
|
|
688
|
+
}
|
|
689
|
+
else {
|
|
690
|
+
console.log(`Connector already exists: ${match.id} (${match.source} → ${match.target})`);
|
|
691
|
+
}
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
522
695
|
const data = await client.post("/architecture/connectors", {
|
|
523
696
|
tenant,
|
|
524
697
|
projectKey,
|
|
@@ -180,7 +180,9 @@ export function registerRequirementCommands(program, client) {
|
|
|
180
180
|
.option("--rationale <r>", "Rationale")
|
|
181
181
|
.option("--compliance <status>", "Compliance status")
|
|
182
182
|
.option("--section <id>", "Section ID")
|
|
183
|
-
.option("--tags <tags>", "Comma-separated tags")
|
|
183
|
+
.option("--tags <tags>", "Comma-separated tags (replaces all)")
|
|
184
|
+
.option("--add-tags <tags>", "Comma-separated tags to add (keeps existing)")
|
|
185
|
+
.option("--remove-tags <tags>", "Comma-separated tags to remove")
|
|
184
186
|
.action(async (tenant, project, id, opts) => {
|
|
185
187
|
const resolvedId = await resolveRequirementId(client, tenant, project, id);
|
|
186
188
|
const body = {};
|
|
@@ -196,8 +198,23 @@ export function registerRequirementCommands(program, client) {
|
|
|
196
198
|
body.complianceStatus = opts.compliance;
|
|
197
199
|
if (opts.section)
|
|
198
200
|
body.sectionId = opts.section;
|
|
199
|
-
if (opts.
|
|
201
|
+
if (opts.addTags || opts.removeTags) {
|
|
202
|
+
// Read-modify-write for add/remove tags
|
|
203
|
+
const existing = await client.get(`/requirements/${tenant}/${project}/${resolvedId}`);
|
|
204
|
+
let currentTags = existing.record?.tags ?? [];
|
|
205
|
+
if (opts.addTags) {
|
|
206
|
+
const toAdd = opts.addTags.split(",").map(t => t.trim());
|
|
207
|
+
currentTags = [...new Set([...currentTags, ...toAdd])];
|
|
208
|
+
}
|
|
209
|
+
if (opts.removeTags) {
|
|
210
|
+
const toRemove = new Set(opts.removeTags.split(",").map(t => t.trim()));
|
|
211
|
+
currentTags = currentTags.filter(t => !toRemove.has(t));
|
|
212
|
+
}
|
|
213
|
+
body.tags = currentTags;
|
|
214
|
+
}
|
|
215
|
+
else if (opts.tags) {
|
|
200
216
|
body.tags = opts.tags.split(",").map(t => t.trim());
|
|
217
|
+
}
|
|
201
218
|
await client.patch(`/requirements/${tenant}/${project}/${resolvedId}`, body);
|
|
202
219
|
if (isJsonMode()) {
|
|
203
220
|
output({ ok: true });
|
|
@@ -299,7 +316,7 @@ export function registerRequirementCommands(program, client) {
|
|
|
299
316
|
const data = await client.get(`/requirements/${tenant}/${project}`, params);
|
|
300
317
|
const reqs = data.data ?? [];
|
|
301
318
|
if (isJsonMode()) {
|
|
302
|
-
output(reqs);
|
|
319
|
+
output({ data: reqs, meta: { totalItems: reqs.length } });
|
|
303
320
|
}
|
|
304
321
|
else {
|
|
305
322
|
console.log(`Filtered: ${reqs.length} requirements\n`);
|
|
@@ -396,22 +413,84 @@ export function registerRequirementCommands(program, client) {
|
|
|
396
413
|
}
|
|
397
414
|
console.log(`${opts.dryRun ? "Would create" : "Created"} ${created}. Skipped: ${skipped}. Errors: ${errors}.`);
|
|
398
415
|
});
|
|
416
|
+
// ── bulk-update: update multiple requirements from JSON array ──
|
|
417
|
+
cmd
|
|
418
|
+
.command("bulk-update")
|
|
419
|
+
.description("Update multiple requirements from a JSON file (array of {ref, text?, rationale?, tags?, ...})")
|
|
420
|
+
.argument("<tenant>", "Tenant slug")
|
|
421
|
+
.argument("<project>", "Project slug")
|
|
422
|
+
.requiredOption("--file <path>", "Path to JSON file")
|
|
423
|
+
.option("--dry-run", "Validate without updating")
|
|
424
|
+
.action(async (tenant, project, opts) => {
|
|
425
|
+
const content = readFileSync(opts.file, "utf-8");
|
|
426
|
+
let items;
|
|
427
|
+
try {
|
|
428
|
+
items = JSON.parse(content);
|
|
429
|
+
if (!Array.isArray(items))
|
|
430
|
+
throw new Error("File must contain a JSON array");
|
|
431
|
+
}
|
|
432
|
+
catch (err) {
|
|
433
|
+
console.error(`Invalid JSON: ${err.message}`);
|
|
434
|
+
process.exit(1);
|
|
435
|
+
}
|
|
436
|
+
console.error(`Processing ${items.length} update(s)...`);
|
|
437
|
+
let updated = 0, errors = 0;
|
|
438
|
+
for (let i = 0; i < items.length; i++) {
|
|
439
|
+
const item = items[i];
|
|
440
|
+
const ref = String(item.ref ?? item.id ?? "");
|
|
441
|
+
if (!ref) {
|
|
442
|
+
console.error(` [${i}] Skipped: no ref or id`);
|
|
443
|
+
errors++;
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
if (opts.dryRun) {
|
|
447
|
+
console.log(` [dry-run] ${ref}: would update ${Object.keys(item).filter(k => k !== "ref" && k !== "id").join(", ")}`);
|
|
448
|
+
updated++;
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
try {
|
|
452
|
+
const resolvedId = await resolveRequirementId(client, tenant, project, ref);
|
|
453
|
+
const body = {};
|
|
454
|
+
if (item.text)
|
|
455
|
+
body.text = item.text;
|
|
456
|
+
if (item.pattern)
|
|
457
|
+
body.pattern = item.pattern;
|
|
458
|
+
if (item.verification)
|
|
459
|
+
body.verification = item.verification;
|
|
460
|
+
if (item.rationale)
|
|
461
|
+
body.rationale = item.rationale;
|
|
462
|
+
if (item.complianceStatus || item.compliance)
|
|
463
|
+
body.complianceStatus = item.complianceStatus ?? item.compliance;
|
|
464
|
+
if (item.sectionId || item.section)
|
|
465
|
+
body.sectionId = item.sectionId ?? item.section;
|
|
466
|
+
if (item.tags)
|
|
467
|
+
body.tags = Array.isArray(item.tags) ? item.tags : String(item.tags).split(",").map(t => t.trim());
|
|
468
|
+
await client.patch(`/requirements/${tenant}/${project}/${resolvedId}`, body);
|
|
469
|
+
updated++;
|
|
470
|
+
}
|
|
471
|
+
catch (err) {
|
|
472
|
+
console.error(` [${i}] ${ref}: ${err.message}`);
|
|
473
|
+
errors++;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
console.log(`${opts.dryRun ? "Would update" : "Updated"} ${updated}. Errors: ${errors}.`);
|
|
477
|
+
});
|
|
399
478
|
// ── text-search: search requirements by text content ──
|
|
400
479
|
cmd
|
|
401
480
|
.command("text-search")
|
|
402
481
|
.description("Search requirements by text content (case-insensitive substring match)")
|
|
403
482
|
.argument("<tenant>", "Tenant slug")
|
|
404
483
|
.argument("<project>", "Project slug")
|
|
405
|
-
.requiredOption("--
|
|
484
|
+
.requiredOption("-q, --query <text>", "Text to search for")
|
|
406
485
|
.option("-l, --limit <n>", "Max results", "50")
|
|
407
486
|
.action(async (tenant, project, opts) => {
|
|
408
|
-
const data = await client.get(`/requirements/${tenant}/${project}`, { textContains: opts.
|
|
487
|
+
const data = await client.get(`/requirements/${tenant}/${project}`, { textContains: opts.query, limit: opts.limit, sortBy: "ref", sortOrder: "asc" });
|
|
409
488
|
const reqs = data.data ?? [];
|
|
410
489
|
if (isJsonMode()) {
|
|
411
490
|
output(reqs);
|
|
412
491
|
}
|
|
413
492
|
else {
|
|
414
|
-
console.log(`Found ${reqs.length} requirement(s) matching "${opts.
|
|
493
|
+
console.log(`Found ${reqs.length} requirement(s) matching "${opts.query}":\n`);
|
|
415
494
|
printTable(["Ref", "Text", "Document", "QA"], reqs.map(r => [
|
|
416
495
|
r.ref ?? "?",
|
|
417
496
|
truncate(r.text ?? "", 55),
|
|
@@ -8,16 +8,24 @@ export function registerTraceabilityCommands(program, client) {
|
|
|
8
8
|
.argument("<tenant>", "Tenant slug")
|
|
9
9
|
.argument("<project>", "Project slug")
|
|
10
10
|
.option("--requirement <id>", "Filter by requirement ID")
|
|
11
|
+
.option("-l, --limit <n>", "Max results to display")
|
|
12
|
+
.option("--type <type>", "Filter by link type")
|
|
11
13
|
.action(async (tenant, project, opts) => {
|
|
12
14
|
const path = opts.requirement
|
|
13
15
|
? `/trace-links/${tenant}/${project}/${opts.requirement}`
|
|
14
16
|
: `/trace-links/${tenant}/${project}`;
|
|
15
17
|
const data = await client.get(path);
|
|
16
|
-
|
|
18
|
+
let links = data.traceLinks ?? [];
|
|
19
|
+
if (opts.type)
|
|
20
|
+
links = links.filter(l => l.linkType === opts.type);
|
|
21
|
+
const total = links.length;
|
|
22
|
+
if (opts.limit)
|
|
23
|
+
links = links.slice(0, parseInt(opts.limit, 10));
|
|
17
24
|
if (isJsonMode()) {
|
|
18
|
-
output(links);
|
|
25
|
+
output({ data: links, meta: { totalItems: total, showing: links.length } });
|
|
19
26
|
}
|
|
20
27
|
else {
|
|
28
|
+
console.log(`Trace links: ${links.length}${total > links.length ? ` of ${total}` : ""}\n`);
|
|
21
29
|
printTable(["ID", "Source", "Target", "Type", "Description"], links.map(l => [
|
|
22
30
|
l.id,
|
|
23
31
|
l.sourceRef ?? l.sourceRequirementId ?? "",
|