softwareobservatory 0.2.1 → 0.3.1
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/bin/softwareobservatory.mjs +250 -51
- package/data/sensors.json +326 -92
- package/lib/core.mjs +188 -22
- package/lib/mcp.mjs +3 -3
- package/package.json +9 -2
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
findSensor,
|
|
10
10
|
getRelated,
|
|
11
11
|
listValues,
|
|
12
|
+
listFields,
|
|
12
13
|
suggestSensors,
|
|
13
14
|
stackCoverage,
|
|
14
15
|
} from "../lib/core.mjs";
|
|
@@ -20,7 +21,7 @@ Usage: softwareobservatory [--json] [--plain] <command> [args]
|
|
|
20
21
|
|
|
21
22
|
Commands:
|
|
22
23
|
list [--family <slug>] List sensors (all, or within one family)
|
|
23
|
-
families List the
|
|
24
|
+
families List the sensor families
|
|
24
25
|
get <id|slug|title> Show one sensor in full, with related entries
|
|
25
26
|
search <term...> Substring search over titles and entry text
|
|
26
27
|
values <field> Distinct frontmatter values (oracle, latency, type, ...)
|
|
@@ -32,29 +33,225 @@ Commands:
|
|
|
32
33
|
help This message
|
|
33
34
|
|
|
34
35
|
Flags:
|
|
35
|
-
--json
|
|
36
|
-
--plain
|
|
36
|
+
--json Machine-readable output (full precision; stable for agents)
|
|
37
|
+
--plain Force human-readable output (default when stdout is a TTY)
|
|
38
|
+
--help, -h This message
|
|
39
|
+
|
|
40
|
+
Unknown flags, unknown commands, missing flag values and stray arguments are
|
|
41
|
+
errors (exit 1), never silently ignored. In JSON mode errors are written to
|
|
42
|
+
stderr as JSON.
|
|
37
43
|
`;
|
|
38
44
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
45
|
+
const BOOLEAN_FLAGS = ["--json", "--plain", "--help", "-h"];
|
|
46
|
+
const VALUE_FLAGS = ["--family"];
|
|
47
|
+
const ALL_FLAGS = [...BOOLEAN_FLAGS, ...VALUE_FLAGS];
|
|
48
|
+
|
|
49
|
+
// The contract for every command: which flags it accepts and how many
|
|
50
|
+
// positional arguments it takes. Anything outside the contract is an error.
|
|
51
|
+
// Silently dropping an argument -- which is how `list --familly structural`
|
|
52
|
+
// came to return all 59 sensors and exit 0 -- is the failure mode this catalog
|
|
53
|
+
// exists to warn about.
|
|
54
|
+
const COMMANDS = {
|
|
55
|
+
list: { flags: ["--family"], min: 0, max: 0, usage: "list [--family <slug>]" },
|
|
56
|
+
families: { flags: [], min: 0, max: 0, usage: "families" },
|
|
57
|
+
get: { flags: [], min: 1, max: Infinity, usage: "get <id|slug|title>" },
|
|
58
|
+
search: { flags: [], min: 1, max: Infinity, usage: "search <term...>" },
|
|
59
|
+
values: { flags: [], min: 1, max: 1, usage: "values <field>" },
|
|
60
|
+
suggest: { flags: [], min: 1, max: Infinity, usage: "suggest <question...>" },
|
|
61
|
+
gaps: { flags: [], min: 1, max: Infinity, usage: "gaps <question...>" },
|
|
62
|
+
stack: { flags: [], min: 1, max: Infinity, usage: "stack <id,slug,...>" },
|
|
63
|
+
mcp: { flags: [], min: 0, max: 0, usage: "mcp" },
|
|
64
|
+
version: { flags: [], min: 0, max: 0, usage: "version" },
|
|
65
|
+
help: { flags: [], min: 0, max: 0, usage: "help" },
|
|
66
|
+
};
|
|
67
|
+
const COMMAND_NAMES = Object.keys(COMMANDS);
|
|
68
|
+
|
|
69
|
+
function levenshtein(a, b) {
|
|
70
|
+
let previous = Array.from({ length: b.length + 1 }, (_, j) => j);
|
|
71
|
+
for (let i = 1; i <= a.length; i += 1) {
|
|
72
|
+
const current = [i];
|
|
73
|
+
for (let j = 1; j <= b.length; j += 1) {
|
|
74
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
75
|
+
current[j] = Math.min(previous[j] + 1, current[j - 1] + 1, previous[j - 1] + cost);
|
|
76
|
+
}
|
|
77
|
+
previous = current;
|
|
78
|
+
}
|
|
79
|
+
return previous[b.length];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Nearest valid spelling, if there is one close enough to be worth offering.
|
|
83
|
+
function nearest(word, candidates) {
|
|
84
|
+
let best = null;
|
|
85
|
+
let bestDistance = Infinity;
|
|
86
|
+
for (const candidate of candidates) {
|
|
87
|
+
const distance = levenshtein(word, candidate);
|
|
88
|
+
if (distance < bestDistance) {
|
|
89
|
+
bestDistance = distance;
|
|
90
|
+
best = candidate;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return bestDistance <= (word.length <= 4 ? 1 : 2) ? best : null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function hint(suggestion) {
|
|
97
|
+
return suggestion ? ` Did you mean '${suggestion}'?` : "";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Errors go to stderr in both modes. In JSON mode they are machine-readable, so
|
|
101
|
+
// an agent can parse the failure instead of parsing a valid-looking success.
|
|
102
|
+
function fail(jsonMode, payload, message, { usage = false } = {}) {
|
|
103
|
+
if (jsonMode) {
|
|
104
|
+
process.stderr.write(JSON.stringify({ ...payload, message }) + "\n");
|
|
105
|
+
} else {
|
|
106
|
+
console.error(message);
|
|
107
|
+
if (usage) process.stderr.write("\n" + USAGE);
|
|
108
|
+
}
|
|
109
|
+
process.exit(1);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function parseArgv(argv) {
|
|
113
|
+
const flags = { json: false, plain: false, help: false, family: null };
|
|
114
|
+
const used = new Set();
|
|
115
|
+
const positionals = [];
|
|
116
|
+
const errors = [];
|
|
117
|
+
let literal = false;
|
|
118
|
+
|
|
42
119
|
for (let i = 0; i < argv.length; i += 1) {
|
|
43
120
|
const arg = argv[i];
|
|
44
|
-
if (
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
121
|
+
if (literal) {
|
|
122
|
+
positionals.push(arg);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (arg === "--") {
|
|
126
|
+
literal = true;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (!arg.startsWith("-")) {
|
|
130
|
+
positionals.push(arg);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
let name = arg;
|
|
135
|
+
let value = null;
|
|
136
|
+
if (arg.startsWith("--") && arg.includes("=")) {
|
|
137
|
+
const eq = arg.indexOf("=");
|
|
138
|
+
name = arg.slice(0, eq);
|
|
139
|
+
value = arg.slice(eq + 1);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (!ALL_FLAGS.includes(name)) {
|
|
143
|
+
const guess = nearest(name, ALL_FLAGS);
|
|
144
|
+
errors.push({
|
|
145
|
+
error: "unknown flag",
|
|
146
|
+
flag: name,
|
|
147
|
+
did_you_mean: guess || undefined,
|
|
148
|
+
message: `Unknown flag '${name}'.${hint(guess)} Run 'softwareobservatory help' for usage.`,
|
|
149
|
+
});
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (used.has(name)) {
|
|
153
|
+
errors.push({
|
|
154
|
+
error: "repeated flag",
|
|
155
|
+
flag: name,
|
|
156
|
+
message: `Flag '${name}' was given more than once.`,
|
|
157
|
+
});
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
used.add(name);
|
|
161
|
+
|
|
162
|
+
if (BOOLEAN_FLAGS.includes(name)) {
|
|
163
|
+
if (value !== null) {
|
|
164
|
+
errors.push({
|
|
165
|
+
error: "unexpected flag value",
|
|
166
|
+
flag: name,
|
|
167
|
+
message: `Flag '${name}' does not take a value.`,
|
|
168
|
+
});
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (name === "--json") flags.json = true;
|
|
172
|
+
else if (name === "--plain") flags.plain = true;
|
|
173
|
+
else flags.help = true;
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (value === null) {
|
|
178
|
+
const next = argv[i + 1];
|
|
179
|
+
if (next === undefined || next.startsWith("-")) {
|
|
180
|
+
errors.push({
|
|
181
|
+
error: "missing flag value",
|
|
182
|
+
flag: name,
|
|
183
|
+
message: `Flag '${name}' requires a value, e.g. '${name} structural'.`,
|
|
184
|
+
});
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
value = next;
|
|
48
188
|
i += 1;
|
|
49
|
-
} else if (arg === "--help" || arg === "-h") {
|
|
50
|
-
flags.help = true;
|
|
51
|
-
} else {
|
|
52
|
-
rest.push(arg);
|
|
53
189
|
}
|
|
190
|
+
if (value === "") {
|
|
191
|
+
errors.push({
|
|
192
|
+
error: "missing flag value",
|
|
193
|
+
flag: name,
|
|
194
|
+
message: `Flag '${name}' requires a non-empty value.`,
|
|
195
|
+
});
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (name === "--family") flags.family = value;
|
|
54
199
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
200
|
+
|
|
201
|
+
return { flags, used, positionals, errors };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Resolve argv into a validated (command, args, flags) triple, or exit 1.
|
|
205
|
+
function resolveInvocation(argv) {
|
|
206
|
+
const { flags, used, positionals, errors } = parseArgv(argv);
|
|
207
|
+
const jsonMode = flags.plain ? false : flags.json || !process.stdout.isTTY;
|
|
208
|
+
const [command, ...args] = positionals;
|
|
209
|
+
|
|
210
|
+
if (flags.help) {
|
|
211
|
+
process.stdout.write(USAGE);
|
|
212
|
+
process.exit(0);
|
|
213
|
+
}
|
|
214
|
+
if (!command) {
|
|
215
|
+
process.stderr.write(USAGE);
|
|
216
|
+
process.exit(1);
|
|
217
|
+
}
|
|
218
|
+
if (flags.json && flags.plain) {
|
|
219
|
+
fail(jsonMode, { error: "conflicting flags", flags: ["--json", "--plain"] },
|
|
220
|
+
"Flags '--json' and '--plain' conflict; pass at most one.");
|
|
221
|
+
}
|
|
222
|
+
if (!Object.prototype.hasOwnProperty.call(COMMANDS, command)) {
|
|
223
|
+
const guess = nearest(command, COMMAND_NAMES);
|
|
224
|
+
fail(jsonMode, { error: "unknown command", command, did_you_mean: guess || undefined },
|
|
225
|
+
`Unknown command '${command}'.${hint(guess)}`, { usage: true });
|
|
226
|
+
}
|
|
227
|
+
const spec = COMMANDS[command];
|
|
228
|
+
|
|
229
|
+
if (errors.length > 0) {
|
|
230
|
+
const first = errors[0];
|
|
231
|
+
fail(jsonMode, { error: first.error, flag: first.flag, did_you_mean: first.did_you_mean }, first.message);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
for (const flag of used) {
|
|
235
|
+
if (BOOLEAN_FLAGS.includes(flag)) continue;
|
|
236
|
+
if (!spec.flags.includes(flag)) {
|
|
237
|
+
const accepted = [...spec.flags, "--json", "--plain"].join(", ");
|
|
238
|
+
fail(jsonMode, { error: "flag not valid for command", flag, command, accepted_flags: [...spec.flags, "--json", "--plain"] },
|
|
239
|
+
`Flag '${flag}' is not valid for '${command}'. '${command}' accepts: ${accepted}.`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (args.length < spec.min) {
|
|
244
|
+
fail(jsonMode, { error: "missing argument", command, usage: `softwareobservatory ${spec.usage}` },
|
|
245
|
+
`Usage: softwareobservatory ${spec.usage}`);
|
|
246
|
+
}
|
|
247
|
+
if (args.length > spec.max) {
|
|
248
|
+
const extra = args[spec.max];
|
|
249
|
+
const guess = command === "list" && getFamily(extra) ? `--family ${extra}` : null;
|
|
250
|
+
fail(jsonMode, { error: "unexpected argument", command, argument: extra, did_you_mean: guess || undefined, usage: `softwareobservatory ${spec.usage}` },
|
|
251
|
+
`Unexpected argument '${extra}' for '${command}'.${hint(guess)} Usage: softwareobservatory ${spec.usage}`);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return { flags: { ...flags, json: jsonMode }, command, args };
|
|
58
255
|
}
|
|
59
256
|
|
|
60
257
|
function emit(flags, data, renderHuman) {
|
|
@@ -177,27 +374,31 @@ function humanStack(report) {
|
|
|
177
374
|
console.log("\nStack levels: " + Object.keys(report.coverage.stack_levels).join(", "));
|
|
178
375
|
}
|
|
179
376
|
if (report.recommendations.length > 0) {
|
|
180
|
-
console.log("\
|
|
377
|
+
console.log("\nUncovered families (one example entry each, not a ranked pick):");
|
|
181
378
|
for (const rec of report.recommendations) {
|
|
182
|
-
|
|
379
|
+
const family = getFamily(rec.family);
|
|
380
|
+
console.log(` + ${family ? family.name : rec.family}: e.g. ${rec.title} (${rec.id})`);
|
|
381
|
+
if (rec.alternatives.length > 0) {
|
|
382
|
+
console.log(` or any of: ${rec.alternatives.map((a) => `${a.title} (${a.id})`).join(", ")}`);
|
|
383
|
+
}
|
|
183
384
|
}
|
|
385
|
+
console.log(`\nCoverage rule: ${report.composition_rule.description}`);
|
|
184
386
|
}
|
|
185
387
|
}
|
|
186
388
|
|
|
187
389
|
function main() {
|
|
188
|
-
const { flags,
|
|
189
|
-
const [command, ...args] = rest;
|
|
190
|
-
|
|
191
|
-
if (flags.help || !command) {
|
|
192
|
-
process.stdout.write(USAGE);
|
|
193
|
-
process.exit(command ? 0 : 1);
|
|
194
|
-
}
|
|
390
|
+
const { flags, command, args } = resolveInvocation(process.argv.slice(2));
|
|
195
391
|
|
|
196
392
|
switch (command) {
|
|
393
|
+
case "help": {
|
|
394
|
+
process.stdout.write(USAGE);
|
|
395
|
+
break;
|
|
396
|
+
}
|
|
197
397
|
case "list": {
|
|
198
398
|
if (flags.family && !getFamily(flags.family)) {
|
|
199
|
-
|
|
200
|
-
|
|
399
|
+
const guess = nearest(flags.family, listFamilies().map((f) => f.slug));
|
|
400
|
+
fail(flags.json, { error: "unknown family", family: flags.family, did_you_mean: guess || undefined },
|
|
401
|
+
`Unknown family '${flags.family}'.${hint(guess)} Run 'families' to see valid slugs.`);
|
|
201
402
|
}
|
|
202
403
|
const sensors = listSensors({ family: flags.family });
|
|
203
404
|
emit(flags, sensors.map(sensorSummary), () => humanList(sensors));
|
|
@@ -208,14 +409,10 @@ function main() {
|
|
|
208
409
|
break;
|
|
209
410
|
}
|
|
210
411
|
case "get": {
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
process.exit(1);
|
|
214
|
-
}
|
|
215
|
-
const sensor = findSensor(args.join(" "));
|
|
412
|
+
const query = args.join(" ");
|
|
413
|
+
const sensor = findSensor(query);
|
|
216
414
|
if (!sensor) {
|
|
217
|
-
|
|
218
|
-
process.exit(1);
|
|
415
|
+
fail(flags.json, { error: "no match", query }, `No sensor matches '${query}'.`);
|
|
219
416
|
}
|
|
220
417
|
const related = getRelated(sensor);
|
|
221
418
|
emit(
|
|
@@ -244,20 +441,21 @@ function main() {
|
|
|
244
441
|
break;
|
|
245
442
|
}
|
|
246
443
|
case "values": {
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
444
|
+
// An unknown field used to return an empty list and exit 0, which is
|
|
445
|
+
// indistinguishable from a field that genuinely has no values.
|
|
446
|
+
const field = args[0];
|
|
447
|
+
const fields = listFields();
|
|
448
|
+
if (!fields.includes(field)) {
|
|
449
|
+
const guess = nearest(field, fields);
|
|
450
|
+
fail(flags.json, { error: "unknown field", field, did_you_mean: guess || undefined, valid_fields: fields },
|
|
451
|
+
`Unknown field '${field}'.${hint(guess)} Valid fields: ${fields.join(", ")}.`);
|
|
250
452
|
}
|
|
251
|
-
const values = listValues(
|
|
252
|
-
emit(flags, { field
|
|
453
|
+
const values = listValues(field);
|
|
454
|
+
emit(flags, { field, values }, () => values.forEach((v) => console.log(v)));
|
|
253
455
|
break;
|
|
254
456
|
}
|
|
255
457
|
case "suggest":
|
|
256
458
|
case "gaps": {
|
|
257
|
-
if (args.length === 0) {
|
|
258
|
-
console.error(`Usage: softwareobservatory ${command} <question...>`);
|
|
259
|
-
process.exit(1);
|
|
260
|
-
}
|
|
261
459
|
const results = suggestSensors(args.join(" "));
|
|
262
460
|
const gapsOnly = command === "gaps";
|
|
263
461
|
emit(
|
|
@@ -268,11 +466,11 @@ function main() {
|
|
|
268
466
|
break;
|
|
269
467
|
}
|
|
270
468
|
case "stack": {
|
|
271
|
-
if (args.length === 0) {
|
|
272
|
-
console.error("Usage: softwareobservatory stack <id,slug,...>");
|
|
273
|
-
process.exit(1);
|
|
274
|
-
}
|
|
275
469
|
const ids = args.join(" ").split(/[,\s]+/).filter(Boolean);
|
|
470
|
+
if (ids.length === 0) {
|
|
471
|
+
fail(flags.json, { error: "missing argument", command, usage: "softwareobservatory stack <id,slug,...>" },
|
|
472
|
+
"Usage: softwareobservatory stack <id,slug,...>");
|
|
473
|
+
}
|
|
276
474
|
const report = stackCoverage(ids);
|
|
277
475
|
emit(flags, report, () => humanStack(report));
|
|
278
476
|
break;
|
|
@@ -293,10 +491,11 @@ function main() {
|
|
|
293
491
|
);
|
|
294
492
|
break;
|
|
295
493
|
}
|
|
494
|
+
/* c8 ignore next 3 */
|
|
296
495
|
default:
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
496
|
+
// resolveInvocation rejects anything not in COMMANDS, so this is
|
|
497
|
+
// unreachable unless COMMANDS and this switch drift apart.
|
|
498
|
+
fail(flags.json, { error: "unimplemented command", command }, `Command '${command}' is declared but not implemented.`);
|
|
300
499
|
}
|
|
301
500
|
}
|
|
302
501
|
|