airgen-cli 0.15.0 → 0.17.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.
@@ -225,9 +225,13 @@ export function registerDiagramCommands(program, client) {
225
225
  .description("List all diagrams in a project")
226
226
  .argument("<tenant>", "Tenant slug")
227
227
  .argument("<project>", "Project slug")
228
- .action(async (tenant, project) => {
228
+ .option("--name <name>", "Filter by exact name match")
229
+ .action(async (tenant, project, opts) => {
229
230
  const data = await client.get(`/architecture/diagrams/${tenant}/${project}`);
230
- const diagrams = data.diagrams ?? [];
231
+ let diagrams = data.diagrams ?? [];
232
+ if (opts.name) {
233
+ diagrams = diagrams.filter(d => d.name === opts.name);
234
+ }
231
235
  if (isJsonMode()) {
232
236
  output(diagrams);
233
237
  }
@@ -283,7 +287,11 @@ export function registerDiagramCommands(program, client) {
283
287
  if (opts.format === "mermaid") {
284
288
  rendered = renderMermaid(blocks, connectors, opts.direction);
285
289
  if (opts.clean) {
286
- rendered = rendered.split("\n").filter(l => !l.trim().startsWith("classDef ") && !l.trim().startsWith("class ") && !l.trim().startsWith("style ")).join("\n");
290
+ rendered = rendered
291
+ .split("\n")
292
+ .filter(l => !l.trim().startsWith("classDef ") && !l.trim().startsWith("class ") && !l.trim().startsWith("style "))
293
+ .map(l => l.replace(/<<[^>]+>><br\/>/g, "")) // Strip <<stereotype>><br/> from node labels
294
+ .join("\n");
287
295
  }
288
296
  if (isJsonMode()) {
289
297
  output({ mermaid: rendered, blocks: blocks.length, connectors: connectors.length });
@@ -1,5 +1,5 @@
1
1
  import { readFileSync, writeFileSync, existsSync } from "node:fs";
2
- import { UhtClient } from "../uht-client.js";
2
+ import { UhtClient, decodeHexTraits } from "../uht-client.js";
3
3
  import { isJsonMode } from "../output.js";
4
4
  const TRAIT_CHECKS = [
5
5
  // Physical Layer (1-8)
@@ -693,6 +693,7 @@ export function registerLintCommands(program, client) {
693
693
  .option("--threshold <n>", "Jaccard similarity threshold (0.0-1.0)", "0.6")
694
694
  .option("--spray-threshold <n>", "Outgoing trace link count for spray pattern detection", "8")
695
695
  .option("--min-severity <level>", "Only show findings at this severity or above: low, medium, high", "low")
696
+ .option("--substrate-namespace <ns>", "Use existing Substrate entity classifications instead of re-classifying (e.g. SE, SE:naval)")
696
697
  .action(async (tenant, project, opts) => {
697
698
  const uht = new UhtClient();
698
699
  if (!uht.isConfigured) {
@@ -721,26 +722,69 @@ export function registerLintCommands(program, client) {
721
722
  const conceptRefs = extractConcepts(requirements);
722
723
  const top = topConcepts(conceptRefs, maxConcepts);
723
724
  console.error(` ${conceptRefs.size} unique concepts found, classifying top ${top.length}.`);
724
- // Step 3: Classify concepts via UHT (parallelized)
725
- console.error("Classifying concepts via UHT...");
726
- const classifyResults = await parallelMap(top, async ([name]) => uht.classify(name), 5);
725
+ // Step 3: Classify concepts use Substrate namespace if provided, else fresh classify
726
+ const ns = opts.substrateNamespace;
727
+ console.error(ns
728
+ ? `Looking up concepts in Substrate namespace "${ns}"...`
729
+ : "Classifying concepts via UHT...");
727
730
  const concepts = [];
728
- for (let i = 0; i < classifyResults.length; i++) {
729
- const entry = classifyResults[i];
730
- const [name, refs] = top[i];
731
- if (entry.result) {
732
- const traitNames = entry.result.traits.map(t => t.name).filter(Boolean);
733
- concepts.push({
734
- name,
735
- hexCode: entry.result.hex_code,
736
- traits: traitNames,
737
- traitSet: new Set(traitNames),
738
- reqs: refs,
739
- });
740
- console.error(` ✓ ${name} → ${entry.result.hex_code} (${traitNames.length} traits)`);
731
+ if (ns) {
732
+ // Namespace mode: look up pre-classified entities
733
+ const lookupResults = await parallelMap(top, async ([name]) => uht.lookupEntity(name, ns), 5);
734
+ for (let i = 0; i < lookupResults.length; i++) {
735
+ const [name, refs] = top[i];
736
+ const entity = lookupResults[i].result;
737
+ if (entity) {
738
+ const traitNames = decodeHexTraits(entity.hex_code);
739
+ concepts.push({
740
+ name: entity.name, // Use canonical name from Substrate
741
+ hexCode: entity.hex_code,
742
+ traits: traitNames,
743
+ traitSet: new Set(traitNames),
744
+ reqs: refs,
745
+ });
746
+ console.error(` ✓ ${name} → ${entity.name} (${entity.hex_code}, ${traitNames.length} traits)`);
747
+ }
748
+ else {
749
+ // Fall back to fresh classification
750
+ try {
751
+ const result = await uht.classify(name);
752
+ const traitNames = result.traits.map(t => t.name).filter(Boolean);
753
+ concepts.push({
754
+ name,
755
+ hexCode: result.hex_code,
756
+ traits: traitNames,
757
+ traitSet: new Set(traitNames),
758
+ reqs: refs,
759
+ });
760
+ console.error(` ~ ${name} → ${result.hex_code} (${traitNames.length} traits, not in namespace — classified fresh)`);
761
+ }
762
+ catch (err) {
763
+ console.error(` ✗ ${name}: ${err.message}`);
764
+ }
765
+ }
741
766
  }
742
- else if (entry.error) {
743
- console.error(` ✗ ${name}: ${entry.error.message}`);
767
+ }
768
+ else {
769
+ // Fresh classification mode (original behavior)
770
+ const classifyResults = await parallelMap(top, async ([name]) => uht.classify(name), 5);
771
+ for (let i = 0; i < classifyResults.length; i++) {
772
+ const entry = classifyResults[i];
773
+ const [name, refs] = top[i];
774
+ if (entry.result) {
775
+ const traitNames = entry.result.traits.map(t => t.name).filter(Boolean);
776
+ concepts.push({
777
+ name,
778
+ hexCode: entry.result.hex_code,
779
+ traits: traitNames,
780
+ traitSet: new Set(traitNames),
781
+ reqs: refs,
782
+ });
783
+ console.error(` ✓ ${name} → ${entry.result.hex_code} (${traitNames.length} traits)`);
784
+ }
785
+ else if (entry.error) {
786
+ console.error(` ✗ ${name}: ${entry.error.message}`);
787
+ }
744
788
  }
745
789
  }
746
790
  // Step 4: Cross-compare concepts in batches
@@ -14,7 +14,29 @@ export function registerRequirementCommands(program, client) {
14
14
  .option("--order <dir>", "Sort order: asc, desc")
15
15
  .option("--tags <tags>", "Comma-separated tags to filter by (server-side)")
16
16
  .option("--document <slug>", "Filter by document slug (server-side)")
17
+ .option("--homeless", "Show only requirements not assigned to any document")
17
18
  .action(async (tenant, project, opts) => {
19
+ // Handle --homeless: fetch all and filter to unassigned
20
+ if (opts.homeless) {
21
+ const all = [];
22
+ for (let page = 1; page <= 50; page++) {
23
+ const data = await client.get(`/requirements/${tenant}/${project}`, { page: String(page), limit: "500" });
24
+ all.push(...(data.data ?? []));
25
+ if (page >= (data.meta?.totalPages ?? 1))
26
+ break;
27
+ }
28
+ const homeless = all.filter(r => !r.documentSlug);
29
+ if (isJsonMode()) {
30
+ output({ data: homeless, meta: { totalItems: homeless.length } });
31
+ }
32
+ else {
33
+ console.log(`Homeless requirements (no document): ${homeless.length}/${all.length}\n`);
34
+ if (homeless.length > 0) {
35
+ printTable(["Ref", "Text", "QA"], homeless.map(r => [r.ref ?? "?", truncate(r.text ?? "", 65), r.qaScore != null ? String(r.qaScore) : "-"]));
36
+ }
37
+ }
38
+ return;
39
+ }
18
40
  // Handle --limit all: fetch all pages
19
41
  if (opts.limit.toLowerCase() === "all") {
20
42
  const all = [];
@@ -116,6 +138,10 @@ export function registerRequirementCommands(program, client) {
116
138
  .option("--tags <tags>", "Comma-separated tags")
117
139
  .option("--idempotency-key <key>", "Prevent duplicates on retry — returns existing if key was already used")
118
140
  .action(async (tenant, projectKey, opts) => {
141
+ if (!opts.section && !opts.document) {
142
+ console.error("Warning: No --section or --document specified. Requirement will be project-level with a generic ref (e.g., REQ-PROJECTNAME-001).");
143
+ console.error(" Use --section <id> to assign to a document section for proper ref prefixing.");
144
+ }
119
145
  const data = await client.post("/requirements", {
120
146
  tenant,
121
147
  projectKey,
@@ -268,31 +268,61 @@ export function registerVerifyCommands(program, client) {
268
268
  // ── Engine ─────────────────────────────────────────────
269
269
  cmd
270
270
  .command("run")
271
- .description("Run the verification engine — check for gaps, conflicts, and drift")
271
+ .description("Run the verification engine — checks both verification activities and 'verifies' trace links")
272
272
  .argument("<tenant>", "Tenant slug")
273
273
  .argument("<project>", "Project slug")
274
274
  .action(async (tenant, project) => {
275
- const data = await client.get(`/verification/engine/${tenant}/${project}`);
276
- const report = data.report;
275
+ // Fetch engine report (activities-based) and trace links in parallel
276
+ const [engineData, linkData, reqData] = await Promise.all([
277
+ client.get(`/verification/engine/${tenant}/${project}`).catch(() => ({ report: null })),
278
+ client.get(`/trace-links/${tenant}/${project}`).catch(() => ({ traceLinks: [] })),
279
+ client.get(`/requirements/${tenant}/${project}`, { page: "1", limit: "1" }).catch(() => ({ meta: { totalItems: 0 } })),
280
+ ]);
281
+ const report = engineData.report;
282
+ const totalReqs = reqData.meta?.totalItems ?? 0;
283
+ // Compute trace-link-based coverage (requirements targeted by 'verifies' links)
284
+ const verifiedByLinks = new Set();
285
+ for (const link of linkData.traceLinks ?? []) {
286
+ if (link.linkType === "verifies" && link.targetRequirementId) {
287
+ verifiedByLinks.add(link.targetRequirementId);
288
+ }
289
+ }
290
+ const traceCoverage = totalReqs > 0 ? Math.round((verifiedByLinks.size / totalReqs) * 100) : 0;
277
291
  if (isJsonMode()) {
278
- output(report);
292
+ output({
293
+ activities: report,
294
+ traceLinkCoverage: {
295
+ totalRequirements: totalReqs,
296
+ verifiedByTraceLinks: verifiedByLinks.size,
297
+ coveragePercent: traceCoverage,
298
+ },
299
+ });
279
300
  return;
280
301
  }
281
- const s = report.summary;
282
- console.log(`Verification Report\n`);
283
- console.log(`Coverage: ${s.coveragePercent}% (${s.verified}/${s.totalRequirements} verified)`);
284
- console.log(`Unverified: ${s.unverified} | Incomplete: ${s.incomplete} | Drifted: ${s.driftedEvidence}\n`);
285
- if (report.findings.length === 0) {
286
- console.log("No findings. All clear.");
302
+ console.log("Verification Report\n");
303
+ // Trace link coverage (most common approach)
304
+ console.log(`Trace Link Coverage: ${traceCoverage}% (${verifiedByLinks.size}/${totalReqs} have 'verifies' links)`);
305
+ // Activities coverage (formal V&V workflow)
306
+ if (report) {
307
+ const s = report.summary;
308
+ console.log(`Activity Coverage: ${s.coveragePercent}% (${s.verified}/${s.totalRequirements} have verification activities)`);
309
+ console.log(` Unverified: ${s.unverified} | Incomplete: ${s.incomplete} | Drifted: ${s.driftedEvidence}`);
310
+ if (report.findings.length > 0) {
311
+ console.log(`\nFindings (${report.findings.length}):`);
312
+ printTable(["Severity", "Type", "Req", "Message"], report.findings.map(f => [
313
+ severityIcon(f.severity) + " " + f.severity,
314
+ f.type,
315
+ f.requirementRef ?? "",
316
+ truncate(f.message, 60),
317
+ ]));
318
+ }
287
319
  }
288
320
  else {
289
- printTable(["Severity", "Type", "Req", "Message"], report.findings.map(f => [
290
- severityIcon(f.severity) + " " + f.severity,
291
- f.type,
292
- f.requirementRef ?? "",
293
- truncate(f.message, 60),
294
- ]));
295
- console.log(`\n${report.findings.length} finding(s) total.`);
321
+ console.log("Activity Coverage: N/A (no verification activities configured)");
322
+ }
323
+ if (traceCoverage === 0 && (!report || report.summary.coveragePercent === 0)) {
324
+ console.log("\nTip: Create 'verifies' trace links from VER requirements to SYS/SUB requirements,");
325
+ console.log("or set up verification activities via 'airgen verify activities create'.");
296
326
  }
297
327
  });
298
328
  // ── Matrix ─────────────────────────────────────────────
@@ -4,6 +4,8 @@
4
4
  * Talks to the UHT Substrate factory API for entity classification and comparison.
5
5
  * Token resolution: UHT_TOKEN env → UHT_API_KEY env → ~/.config/uht-substrate/config.json
6
6
  */
7
+ /** Decode an 8-char hex code into trait names by reading the 32-bit field. */
8
+ export declare function decodeHexTraits(hexCode: string): string[];
7
9
  export interface UhtClassification {
8
10
  entity: string;
9
11
  hex_code: string;
@@ -12,6 +14,13 @@ export interface UhtClassification {
12
14
  justification: string;
13
15
  }>;
14
16
  }
17
+ export interface UhtEntityRecord {
18
+ uuid: string;
19
+ name: string;
20
+ hex_code: string;
21
+ description: string | null;
22
+ source: string;
23
+ }
15
24
  export interface UhtComparison {
16
25
  candidate: string;
17
26
  hex_code: string;
@@ -42,4 +51,6 @@ export declare class UhtClient {
42
51
  private request;
43
52
  classify(entity: string): Promise<UhtClassification>;
44
53
  batchCompare(entity: string, candidates: string[]): Promise<UhtBatchResult>;
54
+ /** Look up an entity by name in a Substrate namespace. Returns null if not found. */
55
+ lookupEntity(name: string, namespace: string): Promise<UhtEntityRecord | null>;
45
56
  }
@@ -8,6 +8,30 @@ import { readFileSync } from "node:fs";
8
8
  import { join } from "node:path";
9
9
  import { homedir } from "node:os";
10
10
  const DEFAULT_UHT_URL = "https://substrate.universalhex.org/api";
11
+ // All 32 UHT v2 canonical trait names, indexed by bit position (1-based)
12
+ const TRAIT_NAMES = {
13
+ 1: "Physical Object", 2: "Synthetic", 3: "Biological/Biomimetic", 4: "Powered",
14
+ 5: "Structural", 6: "Observable", 7: "Physical Medium", 8: "Active",
15
+ 9: "Intentionally Designed", 10: "Outputs Effect", 11: "Processes Signals/Logic", 12: "State-Transforming",
16
+ 13: "Human-Interactive", 14: "System-integrated", 15: "Functionally Autonomous", 16: "System-Essential",
17
+ 17: "Symbolic", 18: "Signalling", 19: "Rule-governed", 20: "Compositional",
18
+ 21: "Normative", 22: "Meta", 23: "Temporal", 24: "Digital/Virtual",
19
+ 25: "Social Construct", 26: "Institutionally Defined", 27: "Identity-Linked", 28: "Regulated",
20
+ 29: "Economically Significant", 30: "Politicised", 31: "Ritualised", 32: "Ethically Significant",
21
+ };
22
+ /** Decode an 8-char hex code into trait names by reading the 32-bit field. */
23
+ export function decodeHexTraits(hexCode) {
24
+ const binary = parseInt(hexCode, 16).toString(2).padStart(32, "0");
25
+ const traits = [];
26
+ for (let i = 0; i < 32; i++) {
27
+ if (binary[i] === "1") {
28
+ const name = TRAIT_NAMES[i + 1];
29
+ if (name)
30
+ traits.push(name);
31
+ }
32
+ }
33
+ return traits;
34
+ }
11
35
  function loadUhtConfigToken() {
12
36
  try {
13
37
  const configPath = join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "uht-substrate", "config.json");
@@ -52,4 +76,11 @@ export class UhtClient {
52
76
  async batchCompare(entity, candidates) {
53
77
  return this.request("POST", "/batch-compare", { entity, candidates });
54
78
  }
79
+ /** Look up an entity by name in a Substrate namespace. Returns null if not found. */
80
+ async lookupEntity(name, namespace) {
81
+ const data = await this.request("GET", `/entities?namespace=${encodeURIComponent(namespace)}&name=${encodeURIComponent(name)}&limit=10`);
82
+ // Prefer exact match (case-insensitive), fall back to first result
83
+ const exact = data.entities.find(e => e.name.toLowerCase() === name.toLowerCase());
84
+ return exact ?? data.entities[0] ?? null;
85
+ }
55
86
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "airgen-cli",
3
- "version": "0.15.0",
3
+ "version": "0.17.0",
4
4
  "description": "AIRGen CLI — requirements engineering from the command line",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",