softwareobservatory 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 11 sensor families
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 Machine-readable output (full precision; stable for agents)
36
- --plain Force human-readable output (default when stdout is a TTY)
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
- function parseFlags(argv) {
40
- const flags = { json: false, plain: false, family: null };
41
- const rest = [];
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 (arg === "--json") flags.json = true;
45
- else if (arg === "--plain") flags.plain = true;
46
- else if (arg === "--family") {
47
- flags.family = argv[i + 1];
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
- if (!process.stdout.isTTY) flags.json = true;
56
- if (flags.plain) flags.json = false;
57
- return { flags, rest };
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("\nRecommendations:");
377
+ console.log("\nUncovered families (one example entry each, not a ranked pick):");
181
378
  for (const rec of report.recommendations) {
182
- console.log(` + ${rec.title} (${rec.id}) - ${rec.reason}`);
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, rest } = parseFlags(process.argv.slice(2));
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
- console.error(`Unknown family '${flags.family}'. Run 'families' to see valid slugs.`);
200
- process.exit(1);
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
- if (args.length === 0) {
212
- console.error("Usage: softwareobservatory get <id|slug|title>");
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
- console.error(`No sensor matches '${args.join(" ")}'.`);
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
- if (args.length === 0) {
248
- console.error("Usage: softwareobservatory values <field>");
249
- process.exit(1);
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(args[0]);
252
- emit(flags, { field: args[0], values }, () => values.forEach((v) => console.log(v)));
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
- console.error(`Unknown command '${command}'.\n`);
298
- process.stdout.write(USAGE);
299
- process.exit(1);
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