airgen-cli 0.1.7 → 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/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
|
@@ -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
|
+
}
|