softwareobservatory 0.1.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 ADDED
@@ -0,0 +1,189 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { fileURLToPath } from "node:url";
3
+ import path from "node:path";
4
+
5
+ const DATA_PATH = fileURLToPath(new URL("../data/sensors.json", import.meta.url));
6
+
7
+ let cache = null;
8
+
9
+ export function loadData() {
10
+ if (!cache) {
11
+ cache = JSON.parse(readFileSync(DATA_PATH, "utf8"));
12
+ }
13
+ return cache;
14
+ }
15
+
16
+ export function siteUrl(path = "") {
17
+ const base = loadData().site.replace(/\/$/, "");
18
+ return path ? `${base}/${path.replace(/^\//, "")}` : base;
19
+ }
20
+
21
+ export function listFamilies() {
22
+ return loadData().families;
23
+ }
24
+
25
+ export function getFamily(slug) {
26
+ const normalized = String(slug).toLowerCase();
27
+ return loadData().families.find((f) => f.slug === normalized) || null;
28
+ }
29
+
30
+ export function listSensors({ family } = {}) {
31
+ const { sensors } = loadData();
32
+ if (!family) return sensors;
33
+ const normalized = String(family).toLowerCase();
34
+ return sensors.filter((s) => s.family === normalized);
35
+ }
36
+
37
+ export function findSensor(query) {
38
+ const { sensors } = loadData();
39
+ const normalized = String(query).toLowerCase();
40
+ return (
41
+ sensors.find((s) => s.id.toLowerCase() === normalized) ||
42
+ sensors.find((s) => s.slug === normalized) ||
43
+ sensors.find((s) => s.title.toLowerCase() === normalized) ||
44
+ null
45
+ );
46
+ }
47
+
48
+ export function getRelated(sensor) {
49
+ const related = [];
50
+ for (const id of sensor.see_also_ids) {
51
+ const target = findSensor(id);
52
+ if (target) {
53
+ related.push({ kind: "sensor", id: target.id, slug: target.slug, title: target.title, family: target.family });
54
+ }
55
+ }
56
+ for (const slug of sensor.see_also_families) {
57
+ const family = getFamily(slug);
58
+ if (family) {
59
+ related.push({ kind: "family", slug: family.slug, name: family.name });
60
+ }
61
+ }
62
+ for (const page of sensor.see_also_pages) {
63
+ related.push({ kind: "page", ref: page });
64
+ }
65
+ return related;
66
+ }
67
+
68
+ export function listValues(field) {
69
+ const values = new Set();
70
+ for (const sensor of loadData().sensors) {
71
+ const value = sensor.frontmatter[field];
72
+ if (value === undefined || value === null) continue;
73
+ if (Array.isArray(value)) {
74
+ for (const item of value) values.add(String(item));
75
+ } else {
76
+ values.add(String(value));
77
+ }
78
+ }
79
+ return [...values].sort();
80
+ }
81
+
82
+ const STOPWORDS = new Set([
83
+ "the", "and", "for", "are", "but", "not", "you", "your", "yours", "all", "any",
84
+ "can", "could", "should", "would", "will", "shall", "may", "might", "must",
85
+ "our", "ours", "their", "theirs", "his", "her", "hers", "its", "this", "that",
86
+ "these", "those", "there", "here", "what", "when", "where", "which", "who",
87
+ "whom", "why", "how", "has", "have", "had", "having", "does", "did", "doing",
88
+ "done", "was", "were", "been", "being", "with", "from", "into", "onto", "upon",
89
+ "about", "after", "before", "between", "through", "during", "without", "within",
90
+ "they", "them", "then", "than", "too", "very", "just", "still", "even", "also",
91
+ "know", "make", "made", "get", "got", "use", "used", "using", "want", "need",
92
+ ]);
93
+
94
+ export function suggestSensors(question, { limit = 5 } = {}) {
95
+ const { sensors, families } = loadData();
96
+ const terms = String(question)
97
+ .toLowerCase()
98
+ .split(/[^\w]+/)
99
+ .filter((t) => t.length >= 3 && !STOPWORDS.has(t));
100
+ if (terms.length === 0) return [];
101
+
102
+ const scored = [];
103
+ for (const sensor of sensors) {
104
+ const title = sensor.title.toLowerCase();
105
+ const family = getFamily(sensor.family);
106
+ const familyText = family ? `${family.name} ${family.question} ${family.examples}`.toLowerCase() : "";
107
+ let score = 0;
108
+ for (const term of terms) {
109
+ if (title.includes(term)) score += 6;
110
+ if (familyText.includes(term)) score += 2;
111
+ if (sensor.body_text.includes(term)) score += 1;
112
+ }
113
+ if (score > 0) scored.push({ sensor, score });
114
+ }
115
+ scored.sort((a, b) => b.score - a.score || a.sensor.slug.localeCompare(b.sensor.slug));
116
+
117
+ const seenFamilies = new Set();
118
+ return scored.slice(0, limit).map(({ sensor, score }) => {
119
+ const matched = terms.filter(
120
+ (t) => sensor.title.toLowerCase().includes(t) || sensor.body_text.includes(t)
121
+ );
122
+ const firstOfFamily = !seenFamilies.has(sensor.family);
123
+ seenFamilies.add(sensor.family);
124
+ return {
125
+ id: sensor.id,
126
+ slug: sensor.slug,
127
+ title: sensor.title,
128
+ family: sensor.family,
129
+ score,
130
+ matched_terms: matched,
131
+ gap: firstOfFamily,
132
+ url: siteUrl(sensor.url_path),
133
+ };
134
+ });
135
+ }
136
+
137
+ export function stackCoverage(ids) {
138
+ const { sensors, families } = loadData();
139
+ const selected = [];
140
+ const unknown = [];
141
+ for (const id of ids) {
142
+ const sensor = findSensor(id);
143
+ if (sensor) selected.push(sensor);
144
+ else unknown.push(id);
145
+ }
146
+
147
+ const coveredFamilies = new Map();
148
+ const stackLevels = new Map();
149
+ const oracles = new Map();
150
+ const latencies = new Map();
151
+ const types = new Map();
152
+ for (const sensor of selected) {
153
+ coveredFamilies.set(sensor.family, (coveredFamilies.get(sensor.family) || 0) + 1);
154
+ const fm = sensor.frontmatter;
155
+ if (fm.stack_level) stackLevels.set(fm.stack_level, (stackLevels.get(fm.stack_level) || 0) + 1);
156
+ if (fm.oracle) oracles.set(fm.oracle, (oracles.get(fm.oracle) || 0) + 1);
157
+ if (fm.latency) latencies.set(fm.latency, (latencies.get(fm.latency) || 0) + 1);
158
+ if (fm.type) types.set(fm.type, (types.get(fm.type) || 0) + 1);
159
+ }
160
+
161
+ const missing = families.filter((f) => !coveredFamilies.has(f.slug));
162
+ const recommendations = [];
163
+ for (const family of missing.slice(0, 3)) {
164
+ const candidate = sensors.find((s) => s.family === family.slug);
165
+ if (candidate) {
166
+ recommendations.push({
167
+ id: candidate.id,
168
+ slug: candidate.slug,
169
+ title: candidate.title,
170
+ family: candidate.family,
171
+ reason: `covers the ${family.name} family ("${family.question}")`,
172
+ });
173
+ }
174
+ }
175
+
176
+ return {
177
+ selected: selected.map((s) => ({ id: s.id, slug: s.slug, title: s.title, family: s.family })),
178
+ unknown_ids: unknown,
179
+ coverage: {
180
+ families: Object.fromEntries(coveredFamilies),
181
+ stack_levels: Object.fromEntries(stackLevels),
182
+ oracle: Object.fromEntries(oracles),
183
+ latency: Object.fromEntries(latencies),
184
+ type: Object.fromEntries(types),
185
+ },
186
+ missing_families: missing.map((f) => ({ slug: f.slug, name: f.name, question: f.question })),
187
+ recommendations,
188
+ };
189
+ }
package/lib/mcp.mjs ADDED
@@ -0,0 +1,190 @@
1
+ import readline from "node:readline";
2
+ import {
3
+ loadData,
4
+ siteUrl,
5
+ listFamilies,
6
+ getFamily,
7
+ listSensors,
8
+ findSensor,
9
+ getRelated,
10
+ suggestSensors,
11
+ stackCoverage,
12
+ } from "./core.mjs";
13
+
14
+ const PROTOCOL_VERSION = "2024-11-05";
15
+ const SERVER_INFO = { name: "softwareobservatory", version: "0.1.0" };
16
+
17
+ const TOOLS = [
18
+ {
19
+ name: "list_families",
20
+ description: "List the 11 sensor families of the Software Observatory catalog.",
21
+ inputSchema: { type: "object", properties: {} },
22
+ },
23
+ {
24
+ name: "list_sensors",
25
+ description: "List sensors, optionally filtered by family slug.",
26
+ inputSchema: {
27
+ type: "object",
28
+ properties: {
29
+ family: { type: "string", description: "Family slug, e.g. 'structural' or 'adversarial'." },
30
+ },
31
+ },
32
+ },
33
+ {
34
+ name: "get_sensor",
35
+ description: "Get one sensor by id (SO-###), slug, or exact title, including its full entry text and related sensors.",
36
+ inputSchema: {
37
+ type: "object",
38
+ properties: {
39
+ query: { type: "string", description: "Sensor id, slug, or exact title." },
40
+ },
41
+ required: ["query"],
42
+ },
43
+ },
44
+ {
45
+ name: "suggest_sensors",
46
+ description:
47
+ "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
+ inputSchema: {
49
+ type: "object",
50
+ properties: {
51
+ question: { type: "string", description: "The concern to match, e.g. 'our tests pass but bugs still ship'." },
52
+ limit: { type: "number", description: "Max results (default 5)." },
53
+ },
54
+ required: ["question"],
55
+ },
56
+ },
57
+ {
58
+ name: "stack_coverage",
59
+ description:
60
+ "Assess a set of sensors (by id or slug) for family and confidence-stack coverage, and recommend sensors for uncovered families.",
61
+ inputSchema: {
62
+ type: "object",
63
+ properties: {
64
+ ids: { type: "array", items: { type: "string" }, description: "Sensor ids or slugs." },
65
+ },
66
+ required: ["ids"],
67
+ },
68
+ },
69
+ ];
70
+
71
+ function sensorSummary(sensor) {
72
+ return {
73
+ id: sensor.id,
74
+ slug: sensor.slug,
75
+ title: sensor.title,
76
+ family: sensor.family,
77
+ url: siteUrl(sensor.url_path),
78
+ };
79
+ }
80
+
81
+ function callTool(name, args = {}) {
82
+ switch (name) {
83
+ case "list_families":
84
+ return listFamilies();
85
+ case "list_sensors":
86
+ return listSensors({ family: args.family }).map((s) => ({
87
+ ...sensorSummary(s),
88
+ oracle: s.frontmatter.oracle,
89
+ latency: s.frontmatter.latency,
90
+ type: s.frontmatter.type,
91
+ }));
92
+ case "get_sensor": {
93
+ const sensor = findSensor(args.query || "");
94
+ if (!sensor) return { error: `no sensor matches '${args.query}'` };
95
+ return { ...sensorSummary(sensor), frontmatter: sensor.frontmatter, body_text: sensor.body_text, related: getRelated(sensor) };
96
+ }
97
+ case "suggest_sensors":
98
+ return suggestSensors(args.question || "", { limit: args.limit });
99
+ case "stack_coverage":
100
+ return stackCoverage(args.ids || []);
101
+ default:
102
+ throw Object.assign(new Error(`unknown tool '${name}'`), { code: -32602 });
103
+ }
104
+ }
105
+
106
+ function resources() {
107
+ return [
108
+ { uri: "softwareobservatory://families", name: "Sensor families", mimeType: "application/json" },
109
+ ...loadData().sensors.map((s) => ({
110
+ uri: `softwareobservatory://sensor/${s.slug}`,
111
+ name: s.title,
112
+ description: `${s.id} (${s.family})`,
113
+ mimeType: "application/json",
114
+ })),
115
+ ];
116
+ }
117
+
118
+ function readResource(uri) {
119
+ const data = loadData();
120
+ if (uri === "softwareobservatory://families") {
121
+ return JSON.stringify(data.families, null, 2);
122
+ }
123
+ const match = uri.match(/^softwareobservatory:\/\/sensor\/(.+)$/);
124
+ if (match) {
125
+ const sensor = findSensor(match[1]);
126
+ if (sensor) return JSON.stringify({ ...sensor, url: siteUrl(sensor.url_path) }, null, 2);
127
+ }
128
+ throw Object.assign(new Error(`unknown resource '${uri}'`), { code: -32602 });
129
+ }
130
+
131
+ function respond(id, result, error) {
132
+ const message = { jsonrpc: "2.0", id };
133
+ if (error) message.error = { code: error.code || -32603, message: error.message };
134
+ else message.result = result;
135
+ process.stdout.write(JSON.stringify(message) + "\n");
136
+ }
137
+
138
+ function handleMessage(message) {
139
+ const { id, method, params = {} } = message;
140
+ try {
141
+ switch (method) {
142
+ case "initialize":
143
+ respond(id, { protocolVersion: PROTOCOL_VERSION, capabilities: { tools: {}, resources: {} }, serverInfo: SERVER_INFO });
144
+ break;
145
+ case "ping":
146
+ respond(id, {});
147
+ break;
148
+ case "notifications/initialized":
149
+ break;
150
+ case "tools/list":
151
+ respond(id, { tools: TOOLS });
152
+ break;
153
+ case "tools/call": {
154
+ const result = callTool(params.name, params.arguments);
155
+ respond(id, { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] });
156
+ break;
157
+ }
158
+ case "resources/list":
159
+ respond(id, { resources: resources() });
160
+ break;
161
+ case "resources/read": {
162
+ const text = readResource(params.uri);
163
+ respond(id, { contents: [{ uri: params.uri, mimeType: "application/json", text }] });
164
+ break;
165
+ }
166
+ default:
167
+ if (id !== undefined && id !== null) {
168
+ respond(id, null, { code: -32601, message: `method not found: ${method}` });
169
+ }
170
+ }
171
+ } catch (error) {
172
+ respond(id ?? null, null, { code: error.code || -32603, message: error.message });
173
+ }
174
+ }
175
+
176
+ export function startMcpServer() {
177
+ const rl = readline.createInterface({ input: process.stdin });
178
+ rl.on("line", (line) => {
179
+ const trimmed = line.trim();
180
+ if (!trimmed) return;
181
+ let message;
182
+ try {
183
+ message = JSON.parse(trimmed);
184
+ } catch {
185
+ respond(null, null, { code: -32700, message: "parse error" });
186
+ return;
187
+ }
188
+ handleMessage(message);
189
+ });
190
+ }
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "softwareobservatory",
3
+ "version": "0.1.0",
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
+ "keywords": [
6
+ "observability",
7
+ "testing",
8
+ "correctness",
9
+ "sensors",
10
+ "software-quality",
11
+ "agents",
12
+ "mcp"
13
+ ],
14
+ "homepage": "https://softwareobservatory.com",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/justinabrahms/software-observatory.git",
18
+ "directory": "cli"
19
+ },
20
+ "license": "CC-BY-NC-SA-4.0",
21
+ "type": "module",
22
+ "scripts": {
23
+ "test": "node test/smoke.mjs"
24
+ },
25
+ "bin": {
26
+ "softwareobservatory": "bin/softwareobservatory.mjs",
27
+ "sensors": "bin/softwareobservatory.mjs"
28
+ },
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
32
+ "files": [
33
+ "bin/",
34
+ "lib/",
35
+ "data/",
36
+ "README.md"
37
+ ],
38
+ "engines": {
39
+ "node": ">=18"
40
+ }
41
+ }