airgen-cli 0.1.6 → 0.1.8

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,205 @@
1
+ import { writeFileSync } from "node:fs";
1
2
  import { output, printTable, isJsonMode } from "../output.js";
3
+ // ── Mermaid rendering helpers ─────────────────────────────────
4
+ function sanitizeId(id) {
5
+ return id.replace(/[^a-zA-Z0-9_-]/g, "_");
6
+ }
7
+ function mermaidNodeShape(block) {
8
+ const id = sanitizeId(block.id);
9
+ const label = block.name.replace(/"/g, "'");
10
+ const stereo = block.stereotype?.replace(/[«»<>]/g, "") ?? block.kind ?? "block";
11
+ const display = `"«${stereo}»\\n${label}"`;
12
+ switch (block.kind) {
13
+ case "system": return `${id}[${display}]`;
14
+ case "subsystem": return `${id}[${display}]`;
15
+ case "actor": return `${id}([${display}])`;
16
+ case "external": return `${id}>${display}]`;
17
+ case "interface": return `${id}{{${display}}}`;
18
+ default: return `${id}[${display}]`; // component
19
+ }
20
+ }
21
+ function mermaidArrow(kind) {
22
+ switch (kind) {
23
+ case "flow": return "==>";
24
+ case "dependency": return "-.->";
25
+ case "composition": return "--*";
26
+ default: return "-->"; // association
27
+ }
28
+ }
29
+ function mermaidStyle(block) {
30
+ const id = sanitizeId(block.id);
31
+ switch (block.kind) {
32
+ case "system": return `style ${id} fill:#ebf8ff,stroke:#1a365d,color:#1a365d`;
33
+ case "subsystem": return `style ${id} fill:#f0f5ff,stroke:#2c5282,color:#2c5282`;
34
+ case "actor": return `style ${id} fill:#f0fff4,stroke:#276749,color:#276749`;
35
+ case "external": return `style ${id} fill:#fffbeb,stroke:#92400e,color:#92400e`;
36
+ case "interface": return `style ${id} fill:#faf5ff,stroke:#6b21a8,color:#6b21a8`;
37
+ default: return null;
38
+ }
39
+ }
40
+ // ── Terminal (layered) rendering ─────────────────────────────
41
+ const KIND_ICONS = {
42
+ system: "■", subsystem: "□", component: "◦",
43
+ actor: "☺", external: "◇", interface: "◈",
44
+ };
45
+ function kindArrow(kind) {
46
+ switch (kind) {
47
+ case "flow": return "══▶";
48
+ case "dependency": return "--▷";
49
+ case "composition": return "◆──";
50
+ default: return "──▶";
51
+ }
52
+ }
53
+ function renderTerminal(blocks, connectors) {
54
+ const blockMap = new Map(blocks.map(b => [b.id, b]));
55
+ // Build adjacency
56
+ const outEdges = new Map();
57
+ const inEdges = new Map();
58
+ const inDegree = new Map();
59
+ for (const b of blocks)
60
+ inDegree.set(b.id, 0);
61
+ for (const c of connectors) {
62
+ const out = outEdges.get(c.source) ?? [];
63
+ out.push({ target: c.target, label: c.label ?? "", kind: c.kind });
64
+ outEdges.set(c.source, out);
65
+ const inn = inEdges.get(c.target) ?? [];
66
+ inn.push({ source: c.source, label: c.label ?? "", kind: c.kind });
67
+ inEdges.set(c.target, inn);
68
+ inDegree.set(c.target, (inDegree.get(c.target) ?? 0) + 1);
69
+ }
70
+ // BFS layering (Kahn's algorithm, cycles go to last layer)
71
+ const layer = new Map();
72
+ const queue = [];
73
+ for (const b of blocks) {
74
+ if ((inDegree.get(b.id) ?? 0) === 0) {
75
+ queue.push(b.id);
76
+ layer.set(b.id, 0);
77
+ }
78
+ }
79
+ let maxLayer = 0;
80
+ while (queue.length > 0) {
81
+ const id = queue.shift();
82
+ const curLayer = layer.get(id);
83
+ for (const e of outEdges.get(id) ?? []) {
84
+ if (layer.has(e.target))
85
+ continue;
86
+ const newDeg = (inDegree.get(e.target) ?? 1) - 1;
87
+ inDegree.set(e.target, newDeg);
88
+ if (newDeg <= 0) {
89
+ const nextLayer = curLayer + 1;
90
+ layer.set(e.target, nextLayer);
91
+ maxLayer = Math.max(maxLayer, nextLayer);
92
+ queue.push(e.target);
93
+ }
94
+ }
95
+ }
96
+ // Assign remaining (cycle members) to maxLayer + 1
97
+ for (const b of blocks) {
98
+ if (!layer.has(b.id)) {
99
+ layer.set(b.id, maxLayer + 1);
100
+ maxLayer = maxLayer + 1;
101
+ }
102
+ }
103
+ // Group blocks by layer
104
+ const layers = [];
105
+ for (let i = 0; i <= maxLayer; i++)
106
+ layers.push([]);
107
+ for (const b of blocks) {
108
+ layers[layer.get(b.id)].push(b);
109
+ }
110
+ // Compute column widths for the block name column
111
+ const nameColWidth = Math.max(...blocks.map(b => b.name.length + 4), 20);
112
+ const icon = (b) => KIND_ICONS[b.kind ?? ""] ?? "□";
113
+ const lines = [];
114
+ const PAD = " ";
115
+ for (let li = 0; li < layers.length; li++) {
116
+ const layerBlocks = layers[li];
117
+ if (layerBlocks.length === 0)
118
+ continue;
119
+ // Layer header
120
+ const layerLabel = li === 0 ? "Sources" : li === layers.length - 1 ? "Outputs" : `Layer ${li}`;
121
+ lines.push(`${PAD}┄┄ ${layerLabel} ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄`);
122
+ lines.push("");
123
+ for (const b of layerBlocks) {
124
+ const blockIcon = icon(b);
125
+ const stereo = b.stereotype?.replace(/[«»<>]/g, "") ?? b.kind ?? "block";
126
+ const nameStr = `${blockIcon} ${b.name}`;
127
+ const paddedName = nameStr + " ".repeat(Math.max(0, nameColWidth - nameStr.length));
128
+ // Incoming connections for this block
129
+ const incoming = inEdges.get(b.id) ?? [];
130
+ // Outgoing connections
131
+ const outgoing = outEdges.get(b.id) ?? [];
132
+ // Build the box line
133
+ const boxTop = `${PAD} ┌${"─".repeat(nameColWidth + 2)}┐`;
134
+ const boxSte = `${PAD} │ «${stereo}»${" ".repeat(Math.max(0, nameColWidth - stereo.length - 2))} │`;
135
+ const boxNam = `${PAD} │ ${paddedName} │`;
136
+ const boxBot = `${PAD} └${"─".repeat(nameColWidth + 2)}┘`;
137
+ lines.push(boxTop);
138
+ lines.push(boxSte);
139
+ lines.push(boxNam);
140
+ lines.push(boxBot);
141
+ // Show incoming connections (who feeds this block)
142
+ if (incoming.length > 0) {
143
+ for (const e of incoming) {
144
+ const srcBlock = blockMap.get(e.source);
145
+ const srcName = srcBlock?.name ?? "?";
146
+ const arrow = kindArrow(e.kind);
147
+ const label = e.label ? ` (${e.label})` : "";
148
+ lines.push(`${PAD} ◀── ${srcName}${label}`);
149
+ }
150
+ }
151
+ // Show outgoing connections (where this block sends data)
152
+ if (outgoing.length > 0) {
153
+ for (const e of outgoing) {
154
+ const tgtBlock = blockMap.get(e.target);
155
+ const tgtName = tgtBlock?.name ?? "?";
156
+ const arrow = kindArrow(e.kind);
157
+ const label = e.label ? ` ${e.label} ` : " ";
158
+ lines.push(`${PAD} ${arrow}${label}──▶ ${tgtName}`);
159
+ }
160
+ }
161
+ lines.push("");
162
+ }
163
+ // Visual separator between layers
164
+ if (li < layers.length - 1) {
165
+ lines.push(`${PAD} │`);
166
+ lines.push(`${PAD} ▼`);
167
+ lines.push("");
168
+ }
169
+ }
170
+ // Legend
171
+ lines.push(`${PAD}┄┄ Legend ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄`);
172
+ lines.push(`${PAD} ☺ actor ■ system □ subsystem ◦ component ◇ external ◈ interface`);
173
+ lines.push(`${PAD} ──▶ association ══▶ flow ◆── composition --▷ dependency`);
174
+ return lines.join("\n");
175
+ }
176
+ function renderMermaid(blocks, connectors, direction) {
177
+ const lines = [`flowchart ${direction}`];
178
+ // Nodes
179
+ for (const b of blocks) {
180
+ lines.push(` ${mermaidNodeShape(b)}`);
181
+ }
182
+ // Edges
183
+ for (const c of connectors) {
184
+ const src = sanitizeId(c.source);
185
+ const tgt = sanitizeId(c.target);
186
+ const arrow = mermaidArrow(c.kind);
187
+ if (c.label) {
188
+ lines.push(` ${src} ${arrow}|"${c.label.replace(/"/g, "'")}"| ${tgt}`);
189
+ }
190
+ else {
191
+ lines.push(` ${src} ${arrow} ${tgt}`);
192
+ }
193
+ }
194
+ // Styles
195
+ const styles = blocks.map(mermaidStyle).filter(Boolean);
196
+ if (styles.length > 0) {
197
+ lines.push("");
198
+ for (const s of styles)
199
+ lines.push(` ${s}`);
200
+ }
201
+ return lines.join("\n");
202
+ }
2
203
  export function registerDiagramCommands(program, client) {
3
204
  const cmd = program.command("diagrams").alias("diag").description("Architecture diagrams");
4
205
  cmd
@@ -35,6 +236,60 @@ export function registerDiagramCommands(program, client) {
35
236
  ]);
36
237
  output({ blocks, connectors });
37
238
  });
239
+ cmd
240
+ .command("render")
241
+ .description("Render a diagram in the terminal or as Mermaid syntax")
242
+ .argument("<tenant>", "Tenant slug")
243
+ .argument("<project>", "Project slug")
244
+ .argument("<id>", "Diagram ID")
245
+ .option("--format <fmt>", "Output format: text, mermaid", "text")
246
+ .option("--direction <dir>", "Layout direction for mermaid: TB, LR, BT, RL", "TB")
247
+ .option("-o, --output <file>", "Write to file instead of stdout")
248
+ .option("--wrap", "Wrap mermaid in markdown fenced code block")
249
+ .action(async (tenant, project, id, opts) => {
250
+ // Fetch diagram metadata + blocks + connectors in parallel
251
+ const [diagramData, blocksData, connectorsData] = await Promise.all([
252
+ client.get(`/architecture/diagrams/${tenant}/${project}`),
253
+ client.get(`/architecture/blocks/${tenant}/${project}/${id}`),
254
+ client.get(`/architecture/connectors/${tenant}/${project}/${id}`),
255
+ ]);
256
+ const diagram = (diagramData.diagrams ?? []).find(d => d.id === id);
257
+ const blocks = blocksData.blocks ?? [];
258
+ const connectors = connectorsData.connectors ?? [];
259
+ if (blocks.length === 0) {
260
+ console.log("Empty diagram — no blocks to render.");
261
+ return;
262
+ }
263
+ let rendered;
264
+ if (opts.format === "mermaid") {
265
+ rendered = renderMermaid(blocks, connectors, opts.direction);
266
+ if (isJsonMode()) {
267
+ output({ mermaid: rendered, blocks: blocks.length, connectors: connectors.length });
268
+ return;
269
+ }
270
+ if (opts.wrap)
271
+ rendered = "```mermaid\n" + rendered + "\n```";
272
+ }
273
+ else {
274
+ // Terminal text format
275
+ const header = diagram?.name ?? id;
276
+ const headerLine = ` ${header}`;
277
+ const underline = ` ${"═".repeat(header.length)}`;
278
+ const stats = ` ${blocks.length} blocks, ${connectors.length} connectors`;
279
+ rendered = [headerLine, underline, stats, "", renderTerminal(blocks, connectors)].join("\n");
280
+ if (isJsonMode()) {
281
+ output({ text: rendered, blocks: blocks.length, connectors: connectors.length });
282
+ return;
283
+ }
284
+ }
285
+ if (opts.output) {
286
+ writeFileSync(opts.output, rendered + "\n", "utf-8");
287
+ console.log(`Diagram written to ${opts.output}`);
288
+ }
289
+ else {
290
+ console.log(rendered);
291
+ }
292
+ });
38
293
  cmd
39
294
  .command("create")
40
295
  .description("Create a new diagram")
@@ -0,0 +1,3 @@
1
+ import { Command } from "commander";
2
+ import type { AirgenClient } from "../client.js";
3
+ export declare function registerLintCommands(program: Command, client: AirgenClient): void;
@@ -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.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "AIRGen CLI — requirements engineering from the command line",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",