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.
- package/dist/commands/diagrams.js +255 -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 +1 -1
|
@@ -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,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
|
+
}
|