softwareobservatory 0.2.0 → 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.
package/lib/core.mjs CHANGED
@@ -81,6 +81,21 @@ export function listValues(field) {
81
81
  return [...values].sort();
82
82
  }
83
83
 
84
+ // Every frontmatter key present anywhere in the dataset. `values <field>` uses
85
+ // this to reject an unknown field instead of returning an empty list, which an
86
+ // agent cannot distinguish from "this field has no values".
87
+ export function listFields() {
88
+ const fields = new Set();
89
+ for (const sensor of loadData().sensors) {
90
+ for (const key of Object.keys(sensor.frontmatter)) fields.add(key);
91
+ }
92
+ return [...fields].sort();
93
+ }
94
+
95
+ // Common English plus the connective vocabulary people use to describe a
96
+ // symptom ("we keep shipping X", "our tests still fail"). These carry no
97
+ // signal about which sensor answers the question, and left in they dominate
98
+ // the ranking because they appear in every entry.
84
99
  const STOPWORDS = new Set([
85
100
  "the", "and", "for", "are", "but", "not", "you", "your", "yours", "all", "any",
86
101
  "can", "could", "should", "would", "will", "shall", "may", "might", "must",
@@ -91,36 +106,166 @@ const STOPWORDS = new Set([
91
106
  "about", "after", "before", "between", "through", "during", "without", "within",
92
107
  "they", "them", "then", "than", "too", "very", "just", "still", "even", "also",
93
108
  "know", "make", "made", "get", "got", "use", "used", "using", "want", "need",
109
+ "a", "an", "as", "at", "be", "by", "do", "if", "in", "is", "it", "me", "my",
110
+ "no", "of", "off", "on", "or", "out", "over", "own", "re", "so", "to", "up",
111
+ "us", "we", "ll", "ve", "same", "some", "such", "only", "more", "most",
112
+ "other", "another", "each", "every", "both", "keep", "keeps", "kept", "go",
113
+ "goes", "going", "lot", "lots", "one", "two", "let", "lets", "really", "new",
114
+ "thing", "things", "stuff", "way", "ways", "help", "problem", "problems",
94
115
  ]);
95
116
 
117
+ // Light suffix stripping so a symptom sentence written in one tense matches an
118
+ // entry written in another: "shipping" -> "ship", "tests" -> "test",
119
+ // "regressions" -> "regression". Deliberately conservative -- "pass" must not
120
+ // become "pas", or it stops matching anything.
121
+ function stem(term) {
122
+ if (term.length <= 3) return term;
123
+ let out = term;
124
+ if (out.endsWith("ies") && out.length > 4) return out.slice(0, -3) + "y";
125
+ if (out.endsWith("sses")) return out.slice(0, -2);
126
+ if (out.endsWith("ing") && out.length > 5) out = out.slice(0, -3);
127
+ else if (out.endsWith("ed") && out.length > 4) out = out.slice(0, -2);
128
+ else if (out.endsWith("es") && out.length > 4 && !/([sxz]|[cs]h)es$/.test(out)) out = out.slice(0, -2);
129
+ else if (out.endsWith("s") && !out.endsWith("ss") && out.length > 3) out = out.slice(0, -1);
130
+ if (/([bdfgklmnprt])\1$/.test(out)) out = out.slice(0, -1);
131
+ return out;
132
+ }
133
+
134
+ // Word-start matching, not substring. Substring matching is why "pass" used to
135
+ // hit the "pass" inside unrelated prose and why two-letter tokens like "ai"
136
+ // matched the "ai" in "Time-to-Repair". A word-start match still lets "test"
137
+ // find "testing" and "mutation" find "Mutation Testing".
138
+ const TERM_RE_CACHE = new Map();
139
+ function termRegExp(term) {
140
+ let re = TERM_RE_CACHE.get(term);
141
+ if (!re) {
142
+ re = new RegExp("(?:^|[^a-z0-9])" + term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g");
143
+ TERM_RE_CACHE.set(term, re);
144
+ }
145
+ re.lastIndex = 0;
146
+ return re;
147
+ }
148
+
149
+ function countHits(text, term) {
150
+ const re = termRegExp(term);
151
+ let n = 0;
152
+ while (re.exec(text) !== null) {
153
+ n += 1;
154
+ if (n >= 6) break;
155
+ }
156
+ return n;
157
+ }
158
+
159
+ // A match in what the entry is *about* outranks an incidental mention. The
160
+ // lede -- the opening paragraph, which is where every entry states the question
161
+ // it answers -- outranks the title, because someone describing a symptom is
162
+ // describing the question, not the name of the sensor. A deep body mention is
163
+ // worth almost nothing.
164
+ const FIELD_WEIGHTS = {
165
+ lede: 60,
166
+ title: 34,
167
+ slug: 28,
168
+ stack_level: 26,
169
+ categories: 20,
170
+ notes: 18,
171
+ family: 18,
172
+ body: 6,
173
+ };
174
+ const LEDE_CHARS = 320;
175
+ const NOTE_FIELDS = [
176
+ "oracle_note", "independence_note", "scope_note",
177
+ "latency_note", "actionability_note", "type_note",
178
+ ];
179
+
180
+ let searchIndex = null;
181
+ function buildIndex() {
182
+ if (searchIndex) return searchIndex;
183
+ const { sensors } = loadData();
184
+ const documents = sensors.map((sensor) => {
185
+ const fm = sensor.frontmatter;
186
+ const family = getFamily(sensor.family);
187
+ return {
188
+ sensor,
189
+ lede: sensor.body_text.slice(0, LEDE_CHARS).toLowerCase(),
190
+ title: sensor.title.toLowerCase(),
191
+ slug: sensor.slug.replace(/-/g, " "),
192
+ stack_level: String(fm.stack_level || "").replace(/-/g, " ").toLowerCase(),
193
+ categories: (fm.categories || []).join(" ").toLowerCase(),
194
+ notes: NOTE_FIELDS.map((k) => fm[k] || "").join(" ").toLowerCase(),
195
+ family: family ? `${family.name} ${family.question} ${family.examples}`.toLowerCase() : "",
196
+ body: sensor.body_text.toLowerCase(),
197
+ };
198
+ });
199
+ searchIndex = { documents, idf: new Map() };
200
+ return searchIndex;
201
+ }
202
+
203
+ // Inverse document frequency. "test" appears in nearly every entry in a catalog
204
+ // of test sensors, so it should barely move the ranking; "mutation" appears in
205
+ // a handful, so it should move it a lot. This is what stops a symptom sentence
206
+ // full of common catalog vocabulary from recommending whichever entry happens
207
+ // to have that vocabulary in its title.
208
+ function inverseDocumentFrequency(index, term) {
209
+ let value = index.idf.get(term);
210
+ if (value === undefined) {
211
+ const total = index.documents.length;
212
+ let seen = 0;
213
+ for (const doc of index.documents) {
214
+ if (countHits(doc.body, term) || countHits(doc.title, term) || countHits(doc.slug, term)) seen += 1;
215
+ }
216
+ value = Math.log(1 + total / (1 + seen)) / Math.log(1 + total);
217
+ index.idf.set(term, value);
218
+ }
219
+ return value;
220
+ }
221
+
222
+ function questionTerms(question) {
223
+ const words = String(question).toLowerCase().split(/[^a-z0-9+#]+/).filter(Boolean);
224
+ const terms = new Map(); // stem -> first original word that produced it
225
+ for (const word of words) {
226
+ if (word.length < 2 || STOPWORDS.has(word)) continue;
227
+ const key = stem(word);
228
+ if (!terms.has(key)) terms.set(key, word);
229
+ }
230
+ return terms;
231
+ }
232
+
96
233
  export function suggestSensors(question, { limit = 5 } = {}) {
97
- const { sensors, families } = loadData();
98
- const terms = String(question)
99
- .toLowerCase()
100
- .split(/[^\w]+/)
101
- .filter((t) => t.length >= 3 && !STOPWORDS.has(t));
102
- if (terms.length === 0) return [];
234
+ const index = buildIndex();
235
+ const terms = questionTerms(question);
236
+ if (terms.size === 0) return [];
103
237
 
104
238
  const scored = [];
105
- for (const sensor of sensors) {
106
- const title = sensor.title.toLowerCase();
107
- const family = getFamily(sensor.family);
108
- const familyText = family ? `${family.name} ${family.question} ${family.examples}`.toLowerCase() : "";
239
+ for (const doc of index.documents) {
109
240
  let score = 0;
110
- for (const term of terms) {
111
- if (title.includes(term)) score += 6;
112
- if (familyText.includes(term)) score += 2;
113
- if (sensor.body_text.includes(term)) score += 1;
241
+ const matched = [];
242
+ for (const [term, word] of terms) {
243
+ let weight = 0;
244
+ if (countHits(doc.lede, term)) {
245
+ weight = FIELD_WEIGHTS.lede;
246
+ if (countHits(doc.title, term)) weight += 20;
247
+ } else if (countHits(doc.title, term)) weight = FIELD_WEIGHTS.title;
248
+ else if (countHits(doc.slug, term)) weight = FIELD_WEIGHTS.slug;
249
+ else if (countHits(doc.stack_level, term)) weight = FIELD_WEIGHTS.stack_level;
250
+ else if (countHits(doc.categories, term)) weight = FIELD_WEIGHTS.categories;
251
+ else if (countHits(doc.notes, term)) weight = FIELD_WEIGHTS.notes;
252
+ else if (countHits(doc.family, term)) weight = FIELD_WEIGHTS.family;
253
+ else {
254
+ const n = countHits(doc.body, term);
255
+ if (n) weight = FIELD_WEIGHTS.body + Math.min(n - 1, 4) * 2;
256
+ }
257
+ if (!weight) continue;
258
+ // Covering another distinct term of the question is worth more than
259
+ // hitting the same one again, so each match carries a coverage bonus.
260
+ score += (weight + 18) * inverseDocumentFrequency(index, term);
261
+ matched.push(word);
114
262
  }
115
- if (score > 0) scored.push({ sensor, score });
263
+ if (matched.length > 0) scored.push({ sensor: doc.sensor, score, matched });
116
264
  }
117
265
  scored.sort((a, b) => b.score - a.score || a.sensor.slug.localeCompare(b.sensor.slug));
118
266
 
119
267
  const seenFamilies = new Set();
120
- return scored.slice(0, limit).map(({ sensor, score }) => {
121
- const matched = terms.filter(
122
- (t) => sensor.title.toLowerCase().includes(t) || sensor.body_text.includes(t)
123
- );
268
+ return scored.slice(0, limit).map(({ sensor, score, matched }) => {
124
269
  const firstOfFamily = !seenFamilies.has(sensor.family);
125
270
  seenFamilies.add(sensor.family);
126
271
  return {
@@ -128,8 +273,12 @@ export function suggestSensors(question, { limit = 5 } = {}) {
128
273
  slug: sensor.slug,
129
274
  title: sensor.title,
130
275
  family: sensor.family,
131
- score,
276
+ score: Math.round(score),
132
277
  matched_terms: matched,
278
+ // How this result was produced. Today the only path is the term scorer;
279
+ // a curated symptom -> sensor map would report "curated" here so a caller
280
+ // can tell an authored recommendation from a keyword guess.
281
+ basis: "keyword",
133
282
  gap: firstOfFamily,
134
283
  url: siteUrl(sensor.url_path),
135
284
  };
@@ -160,17 +309,28 @@ export function stackCoverage(ids) {
160
309
  if (fm.type) types.set(fm.type, (types.get(fm.type) || 0) + 1);
161
310
  }
162
311
 
312
+ // The only theory of composition here is "one sensor from each family", and
313
+ // within a family the pick is the first entry in file order -- not a ranking.
314
+ // Say so, and list the family's other entries, rather than presenting an
315
+ // arbitrary pick as a considered recommendation. Composing by distinct doubt
316
+ // eliminated (rather than by family) is the open question in issue #101.
163
317
  const missing = families.filter((f) => !coveredFamilies.has(f.slug));
164
318
  const recommendations = [];
165
319
  for (const family of missing.slice(0, 3)) {
166
- const candidate = sensors.find((s) => s.family === family.slug);
320
+ const candidates = sensors.filter((s) => s.family === family.slug);
321
+ const candidate = candidates[0];
167
322
  if (candidate) {
168
323
  recommendations.push({
169
324
  id: candidate.id,
170
325
  slug: candidate.slug,
171
326
  title: candidate.title,
172
327
  family: candidate.family,
173
- reason: `covers the ${family.name} family ("${family.question}")`,
328
+ basis: "family-coverage",
329
+ reason:
330
+ `nothing in this set covers the ${family.name} family ("${family.question}"). ` +
331
+ `Listed as an example entry point, not a ranked pick: this is the first entry ` +
332
+ `in the family, and any of its ${candidates.length} entries would close the same gap.`,
333
+ alternatives: candidates.slice(1).map((s) => ({ id: s.id, slug: s.slug, title: s.title })),
174
334
  });
175
335
  }
176
336
  }
@@ -186,6 +346,12 @@ export function stackCoverage(ids) {
186
346
  type: Object.fromEntries(types),
187
347
  },
188
348
  missing_families: missing.map((f) => ({ slug: f.slug, name: f.name, question: f.question })),
349
+ composition_rule: {
350
+ id: "one-per-family",
351
+ description:
352
+ "Coverage is scored as one sensor per family. It does not model whether two sensors " +
353
+ "eliminate the same doubt, so a set can look complete and still leave a doubt standing.",
354
+ },
189
355
  recommendations,
190
356
  };
191
357
  }
package/lib/mcp.mjs CHANGED
@@ -18,7 +18,7 @@ const SERVER_INFO = { name: "softwareobservatory", version: CLI_VERSION };
18
18
  const TOOLS = [
19
19
  {
20
20
  name: "list_families",
21
- description: "List the 11 sensor families of the Software Observatory catalog.",
21
+ description: "List the sensor families of the Software Observatory catalog.",
22
22
  inputSchema: { type: "object", properties: {} },
23
23
  },
24
24
  {
@@ -45,7 +45,7 @@ const TOOLS = [
45
45
  {
46
46
  name: "suggest_sensors",
47
47
  description:
48
- "Given a plain-language description of a project or concern, suggest sensors whose entries address it. Results flagged 'gap' are the first suggestion from a family not yet represented by a higher-scoring result.",
48
+ "Given a plain-language description of a project or concern, suggest sensors whose entries address it. Ranking is term-based (IDF-weighted, word-boundary matched against each entry's opening framing, title and body), so results carry basis: 'keyword' rather than an authored recommendation. Results flagged 'gap' are the first suggestion from a family not yet represented by a higher-scoring result.",
49
49
  inputSchema: {
50
50
  type: "object",
51
51
  properties: {
@@ -58,7 +58,7 @@ const TOOLS = [
58
58
  {
59
59
  name: "stack_coverage",
60
60
  description:
61
- "Assess a set of sensors (by id or slug) for family and confidence-stack coverage, and recommend sensors for uncovered families.",
61
+ "Assess a set of sensors (by id or slug) for family and confidence-stack coverage, and name the uncovered families. Coverage is scored as one sensor per family; each 'recommendation' is an example entry point into an uncovered family with its 'alternatives' listed, not a ranked pick.",
62
62
  inputSchema: {
63
63
  type: "object",
64
64
  properties: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "softwareobservatory",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Query the Software Observatory catalog of epistemic sensors for software correctness. Built for humans and agents: every command emits JSON with --json.",
5
5
  "keywords": [
6
6
  "observability",
@@ -9,7 +9,14 @@
9
9
  "sensors",
10
10
  "software-quality",
11
11
  "agents",
12
- "mcp"
12
+ "mcp",
13
+ "mcp-server",
14
+ "model-context-protocol",
15
+ "coding-agent",
16
+ "ai-code-review",
17
+ "harness",
18
+ "mutation-testing",
19
+ "code-review"
13
20
  ],
14
21
  "homepage": "https://softwareobservatory.com",
15
22
  "repository": {
@@ -17,7 +24,7 @@
17
24
  "url": "git+https://github.com/justinabrahms/software-observatory.git",
18
25
  "directory": "cli"
19
26
  },
20
- "license": "CC-BY-NC-SA-4.0",
27
+ "license": "MIT",
21
28
  "type": "module",
22
29
  "scripts": {
23
30
  "test": "node test/smoke.mjs"
@@ -33,7 +40,9 @@
33
40
  "bin/",
34
41
  "lib/",
35
42
  "data/",
36
- "README.md"
43
+ "README.md",
44
+ "LICENSE-CODE",
45
+ "LICENSE-CONTENT"
37
46
  ],
38
47
  "engines": {
39
48
  "node": ">=18"