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/LICENSE-CODE +21 -0
- package/LICENSE-CONTENT +451 -0
- package/README.md +37 -3
- package/bin/softwareobservatory.mjs +253 -54
- package/data/sensors.json +13685 -12215
- package/lib/core.mjs +188 -22
- package/lib/mcp.mjs +3 -3
- package/package.json +13 -4
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
|
|
98
|
-
const terms =
|
|
99
|
-
|
|
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
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
if (
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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": "
|
|
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"
|