gasp-standard-mcp 1.0.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 GASP Standard
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,61 @@
1
+ # gasp-standard-mcp
2
+
3
+ MCP server for the [GASP Standard](https://gaspwiki.com) — the Generally Accepted SaaS Performance standard for metric definitions, formulas, benchmarks, and relationships.
4
+
5
+ Works with Claude Code, Claude Desktop, or any [MCP](https://modelcontextprotocol.io/)-compatible client.
6
+
7
+ ## Install
8
+
9
+ ### Claude Code (one command)
10
+
11
+ ```bash
12
+ claude mcp add gasp-standard -- npx -y gasp-standard-mcp
13
+ ```
14
+
15
+ ### Manual config
16
+
17
+ Add to your project `.mcp.json` or `~/.claude/settings.json`:
18
+
19
+ ```json
20
+ {
21
+ "mcpServers": {
22
+ "gasp-standard": {
23
+ "command": "npx",
24
+ "args": ["-y", "gasp-standard-mcp"]
25
+ }
26
+ }
27
+ }
28
+ ```
29
+
30
+ Restart your MCP client after adding the config.
31
+
32
+ ## Tools
33
+
34
+ | Tool | Description |
35
+ |------|-------------|
36
+ | `lookup_metric` | Full metric details: definition, formula, benchmarks, common mistakes, sources |
37
+ | `list_metrics` | List all metrics, optionally filtered by department |
38
+ | `get_formula` | Get formula(s) for a metric |
39
+ | `get_relationships` | Upstream and downstream metric relationships |
40
+ | `search` | Full-text search across all metrics and definitions |
41
+
42
+ ## Data
43
+
44
+ - 298 metrics across 13 departments
45
+ - 79 glossary definitions
46
+ - 21 relationship nodes, 25 edges
47
+ - Sources: the canonical [GASP Standard](https://gaspwiki.com) files
48
+
49
+ ## Examples
50
+
51
+ Once installed, ask your AI assistant:
52
+
53
+ - "What is NRR?"
54
+ - "How do I calculate CAC Payback?"
55
+ - "What drives ARR?"
56
+ - "List all marketing metrics"
57
+ - "Search for churn"
58
+
59
+ ## License
60
+
61
+ MIT
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Export all parsed GASP Standard data as a single JSON file.
4
+ * Output: website/public/api/standard.json
5
+ *
6
+ * Usage: npm run build && npm run export-data
7
+ */
8
+ export {};
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Export all parsed GASP Standard data as a single JSON file.
4
+ * Output: website/public/api/standard.json
5
+ *
6
+ * Usage: npm run build && npm run export-data
7
+ */
8
+ import { loadStandard } from "./loader.js";
9
+ import { writeFileSync, mkdirSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ const data = loadStandard();
12
+ const json = JSON.stringify(data);
13
+ const version = {
14
+ metricCount: data.metrics.length,
15
+ definitionCount: data.definitions.length,
16
+ nodeCount: data.nodes.length,
17
+ edgeCount: data.edges.length,
18
+ buildDate: new Date().toISOString().slice(0, 10),
19
+ };
20
+ const versionJson = JSON.stringify(version);
21
+ // 1. Write to website/public/api/ → serves as https://gaspwiki.com/api/standard.json
22
+ const webOutDir = join(new URL(".", import.meta.url).pathname, "..", "..", "website", "public", "api");
23
+ mkdirSync(webOutDir, { recursive: true });
24
+ writeFileSync(join(webOutDir, "standard.json"), json);
25
+ writeFileSync(join(webOutDir, "standard-version.json"), versionJson);
26
+ // 2. Write to mcp-server/build/ → bundled with npm package as offline fallback
27
+ const buildDir = new URL(".", import.meta.url).pathname;
28
+ writeFileSync(join(buildDir, "standard-data.json"), json);
29
+ writeFileSync(join(buildDir, "standard-version.json"), versionJson);
30
+ console.log(`Exported ${data.metrics.length} metrics, ${data.definitions.length} definitions, ${data.nodes.length} nodes, ${data.edges.length} edges (${version.buildDate})`);
31
+ console.log(` → website/public/api/standard.json + standard-version.json`);
32
+ console.log(` → mcp-server/build/standard-data.json + standard-version.json`);
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/build/index.js ADDED
@@ -0,0 +1,250 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { z } from "zod";
5
+ import { loadStandard, loadBundled, loadRemote, findMetric, getRelationships, search, } from "./loader.js";
6
+ // Load data: local repo files → bundled JSON → remote fetch from gaspwiki.com
7
+ let data;
8
+ try {
9
+ data = loadStandard();
10
+ }
11
+ catch {
12
+ try {
13
+ data = loadBundled();
14
+ }
15
+ catch {
16
+ try {
17
+ data = await loadRemote();
18
+ }
19
+ catch (err) {
20
+ console.error("Failed to load GASP Standard data:", err);
21
+ process.exit(1);
22
+ }
23
+ }
24
+ }
25
+ // Background version check — non-blocking, stderr only
26
+ const VERSION_URL = "https://gaspwiki.com/api/standard-version.json";
27
+ (async () => {
28
+ try {
29
+ const res = await fetch(VERSION_URL);
30
+ if (!res.ok)
31
+ return;
32
+ const remote = await res.json();
33
+ const local = { metricCount: data.metrics.length, definitionCount: data.definitions.length };
34
+ if (remote.metricCount !== local.metricCount || remote.definitionCount !== local.definitionCount) {
35
+ console.error(`[gasp-standard] Update available: your data has ${local.metricCount} metrics / ${local.definitionCount} definitions, ` +
36
+ `latest has ${remote.metricCount} / ${remote.definitionCount} (${remote.buildDate}). ` +
37
+ `Run: npx gasp-standard-mcp@latest`);
38
+ }
39
+ }
40
+ catch {
41
+ // Offline or gaspwiki.com unreachable — silently ignore
42
+ }
43
+ })();
44
+ const server = new McpServer({
45
+ name: "gasp-standard",
46
+ version: "1.0.0",
47
+ });
48
+ // --- Tool 1: lookup_metric ---
49
+ server.tool("lookup_metric", "Look up a GASP Standard metric by name or abbreviation. Returns definition, formula, benchmarks, time period, and department.", { name: z.string().describe("Metric name or abbreviation (e.g. 'MRR', 'Net Revenue Retention', 'CAC Payback')") }, async ({ name }) => {
50
+ const metric = findMetric(data, name);
51
+ if (!metric) {
52
+ return {
53
+ content: [
54
+ {
55
+ type: "text",
56
+ text: `No metric found matching "${name}". Try searching with the 'search' tool for partial matches.`,
57
+ },
58
+ ],
59
+ };
60
+ }
61
+ // Build response with branding swap
62
+ const parts = [
63
+ `# ${metric.name}`,
64
+ `**Department:** ${metric.department}`,
65
+ `**Category:** ${metric.category}`,
66
+ ];
67
+ if (metric.definition) {
68
+ parts.push(`\n**Definition:** ${metric.definition}`);
69
+ }
70
+ if (metric.formulas.length > 0) {
71
+ parts.push("\n**Formula(s):**");
72
+ for (const f of metric.formulas) {
73
+ parts.push("```\n" + f + "\n```");
74
+ }
75
+ }
76
+ if (metric.benchmarks) {
77
+ parts.push(`\n**Benchmarks:**\n${metric.benchmarks}`);
78
+ }
79
+ if (metric.insight) {
80
+ parts.push(`\n**What it tells you:** ${metric.insight}`);
81
+ }
82
+ if (metric.commonMistakes && metric.commonMistakes.length > 0) {
83
+ parts.push("\n**Common mistakes:**\n" +
84
+ metric.commonMistakes.map((m) => `- ${m}`).join("\n"));
85
+ }
86
+ if (metric.sources.length > 0) {
87
+ parts.push("\n**Sources:**\n" + metric.sources.map((s) => `- ${s}`).join("\n"));
88
+ }
89
+ if (metric.aliases.length > 0) {
90
+ parts.push(`\n**Aliases:** ${metric.aliases.join(", ")}`);
91
+ }
92
+ // Swap EOM → GASP branding
93
+ const text = parts.join("\n").replace(/\bEOM Standard\b/g, "GASP Standard");
94
+ return { content: [{ type: "text", text }] };
95
+ });
96
+ // --- Tool 2: list_metrics ---
97
+ server.tool("list_metrics", "List all GASP Standard metrics, optionally filtered by department. Returns metric names grouped by category.", {
98
+ department: z
99
+ .string()
100
+ .optional()
101
+ .describe("Filter by department name (e.g. 'sales', 'marketing', 'core'). Case-insensitive. Omit to list all."),
102
+ }, async ({ department }) => {
103
+ let filtered = data.metrics;
104
+ if (department) {
105
+ const q = department.toLowerCase();
106
+ filtered = data.metrics.filter((m) => m.department.toLowerCase().includes(q));
107
+ }
108
+ if (filtered.length === 0) {
109
+ const departments = [
110
+ ...new Set(data.metrics.map((m) => m.department)),
111
+ ].sort();
112
+ return {
113
+ content: [
114
+ {
115
+ type: "text",
116
+ text: `No metrics found for department "${department}".\n\nAvailable departments: ${departments.join(", ")}`,
117
+ },
118
+ ],
119
+ };
120
+ }
121
+ // Group by department then category
122
+ const grouped = new Map();
123
+ for (const m of filtered) {
124
+ if (!grouped.has(m.department)) {
125
+ grouped.set(m.department, new Map());
126
+ }
127
+ const dept = grouped.get(m.department);
128
+ if (!dept.has(m.category)) {
129
+ dept.set(m.category, []);
130
+ }
131
+ dept.get(m.category).push(m.name);
132
+ }
133
+ const parts = [`# GASP Standard Metrics (${filtered.length} total)\n`];
134
+ for (const [dept, categories] of grouped) {
135
+ parts.push(`## ${dept}`);
136
+ for (const [cat, names] of categories) {
137
+ if (cat)
138
+ parts.push(`### ${cat}`);
139
+ for (const n of names) {
140
+ parts.push(`- ${n}`);
141
+ }
142
+ }
143
+ parts.push("");
144
+ }
145
+ return { content: [{ type: "text", text: parts.join("\n") }] };
146
+ });
147
+ // --- Tool 3: get_formula ---
148
+ server.tool("get_formula", "Get the formula(s) for a GASP Standard metric. Returns all formula variants if multiple exist.", { metric: z.string().describe("Metric name or abbreviation (e.g. 'NRR', 'LTV', 'CAC Payback')") }, async ({ metric: name }) => {
149
+ const metric = findMetric(data, name);
150
+ if (!metric) {
151
+ return {
152
+ content: [
153
+ {
154
+ type: "text",
155
+ text: `No metric found matching "${name}".`,
156
+ },
157
+ ],
158
+ };
159
+ }
160
+ if (metric.formulas.length === 0) {
161
+ return {
162
+ content: [
163
+ {
164
+ type: "text",
165
+ text: `${metric.name} does not have a formula defined (it may be a qualitative metric or reference).`,
166
+ },
167
+ ],
168
+ };
169
+ }
170
+ const parts = [`# ${metric.name} — Formula\n`];
171
+ for (let i = 0; i < metric.formulas.length; i++) {
172
+ if (metric.formulas.length > 1) {
173
+ parts.push(`**Variant ${i + 1}:**`);
174
+ }
175
+ parts.push("```\n" + metric.formulas[i] + "\n```\n");
176
+ }
177
+ const text = parts.join("\n").replace(/\bEOM Standard\b/g, "GASP Standard");
178
+ return { content: [{ type: "text", text }] };
179
+ });
180
+ // --- Tool 4: get_relationships ---
181
+ server.tool("get_relationships", "Get the upstream (what drives it) and downstream (what it drives) relationships for a metric from the GASP relationship graph.", { metric: z.string().describe("Metric name or ID (e.g. 'arr', 'NRR', 'pipeline', 'churn_rate')") }, async ({ metric: name }) => {
182
+ const { node, upstream, downstream } = getRelationships(data, name);
183
+ if (!node) {
184
+ const available = data.nodes.map((n) => `${n.label} (${n.id})`).join(", ");
185
+ return {
186
+ content: [
187
+ {
188
+ type: "text",
189
+ text: `No relationship node found matching "${name}".\n\nAvailable nodes: ${available}`,
190
+ },
191
+ ],
192
+ };
193
+ }
194
+ const parts = [
195
+ `# ${node.label} — Relationships\n`,
196
+ `**Description:** ${node.description}`,
197
+ `**Category:** ${node.category}`,
198
+ `**Level:** ${node.level}\n`,
199
+ ];
200
+ if (upstream.length > 0) {
201
+ parts.push("## Upstream (what drives this metric)");
202
+ for (const r of upstream) {
203
+ parts.push(`- **${r.node.label}** (${r.type}) — ${r.node.description}`);
204
+ }
205
+ parts.push("");
206
+ }
207
+ else {
208
+ parts.push("## Upstream\nNo upstream drivers (this is a top-level metric).\n");
209
+ }
210
+ if (downstream.length > 0) {
211
+ parts.push("## Downstream (what this metric drives)");
212
+ for (const r of downstream) {
213
+ parts.push(`- **${r.node.label}** (${r.type}) — ${r.node.description}`);
214
+ }
215
+ }
216
+ else {
217
+ parts.push("## Downstream\nNo downstream metrics (this is a leaf metric).");
218
+ }
219
+ return { content: [{ type: "text", text: parts.join("\n") }] };
220
+ });
221
+ // --- Tool 5: search ---
222
+ server.tool("search", "Search across all GASP Standard metrics and definitions. Returns top 10 matches ranked by relevance.", { query: z.string().describe("Search query (e.g. 'churn', 'retention', 'pipeline coverage')") }, async ({ query: q }) => {
223
+ const results = search(data, q, 10);
224
+ if (results.length === 0) {
225
+ return {
226
+ content: [
227
+ {
228
+ type: "text",
229
+ text: `No results found for "${q}".`,
230
+ },
231
+ ],
232
+ };
233
+ }
234
+ const parts = [`# Search results for "${q}" (${results.length} matches)\n`];
235
+ for (const r of results) {
236
+ const dept = r.department ? ` [${r.department}]` : "";
237
+ parts.push(`### ${r.name}${dept}`);
238
+ parts.push(`*${r.type}*: ${r.snippet}\n`);
239
+ }
240
+ return { content: [{ type: "text", text: parts.join("\n") }] };
241
+ });
242
+ // Start the server
243
+ async function main() {
244
+ const transport = new StdioServerTransport();
245
+ await server.connect(transport);
246
+ }
247
+ main().catch((err) => {
248
+ console.error("Fatal error:", err);
249
+ process.exit(1);
250
+ });
@@ -0,0 +1,31 @@
1
+ import { Metric, RelationshipNode, StandardData } from "./types.js";
2
+ /** Load pre-parsed data bundled with the npm package (offline fallback) */
3
+ export declare function loadBundled(): StandardData;
4
+ /** Fetch pre-parsed standard data from gaspwiki.com (for global/npx installs) */
5
+ export declare function loadRemote(): Promise<StandardData>;
6
+ /** Load all standard data from local files at startup */
7
+ export declare function loadStandard(): StandardData;
8
+ /**
9
+ * Search metrics + definitions for a query string.
10
+ * Returns top N matches ranked by relevance.
11
+ */
12
+ export declare function search(data: StandardData, query: string, limit?: number): Array<{
13
+ type: "metric" | "definition";
14
+ name: string;
15
+ snippet: string;
16
+ department?: string;
17
+ }>;
18
+ /** Find a metric by name, abbreviation, or slug */
19
+ export declare function findMetric(data: StandardData, name: string): Metric | undefined;
20
+ /** Get relationships for a metric: upstream (what drives it) and downstream (what it drives) */
21
+ export declare function getRelationships(data: StandardData, metricId: string): {
22
+ node: RelationshipNode | undefined;
23
+ upstream: Array<{
24
+ node: RelationshipNode;
25
+ type: string;
26
+ }>;
27
+ downstream: Array<{
28
+ node: RelationshipNode;
29
+ type: string;
30
+ }>;
31
+ };
@@ -0,0 +1,163 @@
1
+ import { readFileSync, readdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { parseMetricsFile, parseDefinitions } from "./parser.js";
4
+ /** Resolve the repo root (two levels up from build/index.js) */
5
+ function repoRoot() {
6
+ // mcp-server/build/index.js -> mcp-server -> repo root
7
+ const thisDir = new URL(".", import.meta.url).pathname;
8
+ return join(thisDir, "..", "..");
9
+ }
10
+ const DATA_URL = "https://gaspwiki.com/api/standard.json";
11
+ /** Load pre-parsed data bundled with the npm package (offline fallback) */
12
+ export function loadBundled() {
13
+ const bundledPath = join(new URL(".", import.meta.url).pathname, "standard-data.json");
14
+ const raw = readFileSync(bundledPath, "utf-8");
15
+ return JSON.parse(raw);
16
+ }
17
+ /** Fetch pre-parsed standard data from gaspwiki.com (for global/npx installs) */
18
+ export async function loadRemote() {
19
+ const res = await fetch(DATA_URL);
20
+ if (!res.ok)
21
+ throw new Error(`Failed to fetch ${DATA_URL}: ${res.status}`);
22
+ return res.json();
23
+ }
24
+ /** Load all standard data from local files at startup */
25
+ export function loadStandard() {
26
+ const root = repoRoot();
27
+ const standardDir = join(root, "standard");
28
+ const metricsDir = join(standardDir, "metrics");
29
+ // Load all metric files
30
+ const metricFiles = readdirSync(metricsDir).filter((f) => f.endsWith(".md"));
31
+ const metrics = [];
32
+ for (const file of metricFiles) {
33
+ const content = readFileSync(join(metricsDir, file), "utf-8");
34
+ const parsed = parseMetricsFile(content, file);
35
+ metrics.push(...parsed);
36
+ }
37
+ // Load definitions
38
+ const defsContent = readFileSync(join(standardDir, "definitions.md"), "utf-8");
39
+ const definitions = parseDefinitions(defsContent);
40
+ // Load relationships
41
+ const relPath = join(root, "website", "src", "data", "relationships.json");
42
+ const relData = JSON.parse(readFileSync(relPath, "utf-8"));
43
+ const nodes = relData.nodes;
44
+ const edges = relData.edges;
45
+ return { metrics, definitions, nodes, edges };
46
+ }
47
+ /**
48
+ * Search metrics + definitions for a query string.
49
+ * Returns top N matches ranked by relevance.
50
+ */
51
+ export function search(data, query, limit = 10) {
52
+ const q = query.toLowerCase();
53
+ const results = [];
54
+ for (const m of data.metrics) {
55
+ const score = scoreMetric(m, q);
56
+ if (score > 0) {
57
+ results.push({
58
+ type: "metric",
59
+ name: m.name,
60
+ snippet: m.definition || m.insight || "",
61
+ department: m.department,
62
+ score,
63
+ });
64
+ }
65
+ }
66
+ for (const d of data.definitions) {
67
+ const score = scoreDefinition(d, q);
68
+ if (score > 0) {
69
+ results.push({
70
+ type: "definition",
71
+ name: d.term,
72
+ snippet: d.definition,
73
+ score,
74
+ });
75
+ }
76
+ }
77
+ results.sort((a, b) => b.score - a.score);
78
+ return results.slice(0, limit).map(({ score: _, ...rest }) => rest);
79
+ }
80
+ /** Score a metric against a query */
81
+ function scoreMetric(m, q) {
82
+ let score = 0;
83
+ // Exact name match
84
+ if (m.name.toLowerCase() === q)
85
+ score += 100;
86
+ // Name contains query
87
+ else if (m.name.toLowerCase().includes(q))
88
+ score += 50;
89
+ // Alias match
90
+ for (const alias of m.aliases) {
91
+ if (alias.toLowerCase() === q)
92
+ score += 90;
93
+ else if (alias.toLowerCase().includes(q))
94
+ score += 40;
95
+ }
96
+ // Definition contains query
97
+ if (m.definition.toLowerCase().includes(q))
98
+ score += 20;
99
+ // Formula contains query
100
+ for (const f of m.formulas) {
101
+ if (f.toLowerCase().includes(q))
102
+ score += 15;
103
+ }
104
+ // Category/department match
105
+ if (m.department.toLowerCase().includes(q))
106
+ score += 10;
107
+ if (m.category.toLowerCase().includes(q))
108
+ score += 10;
109
+ // Raw markdown contains query (catch-all)
110
+ if (score === 0 && m.rawMarkdown.toLowerCase().includes(q))
111
+ score += 5;
112
+ return score;
113
+ }
114
+ /** Score a definition against a query */
115
+ function scoreDefinition(d, q) {
116
+ let score = 0;
117
+ if (d.term.toLowerCase() === q)
118
+ score += 80;
119
+ else if (d.term.toLowerCase().includes(q))
120
+ score += 40;
121
+ if (d.definition.toLowerCase().includes(q))
122
+ score += 15;
123
+ if (d.section.toLowerCase().includes(q))
124
+ score += 5;
125
+ return score;
126
+ }
127
+ /** Find a metric by name, abbreviation, or slug */
128
+ export function findMetric(data, name) {
129
+ const q = name.toLowerCase().trim();
130
+ return (data.metrics.find((m) => m.name.toLowerCase() === q) ||
131
+ data.metrics.find((m) => m.aliases.some((a) => a.toLowerCase() === q)) ||
132
+ data.metrics.find((m) => m.slug === q) ||
133
+ data.metrics.find((m) => m.name.toLowerCase().includes(q)) ||
134
+ data.metrics.find((m) => m.aliases.some((a) => a.toLowerCase().includes(q))));
135
+ }
136
+ /** Get relationships for a metric: upstream (what drives it) and downstream (what it drives) */
137
+ export function getRelationships(data, metricId) {
138
+ const q = metricId.toLowerCase().trim();
139
+ // Find the node by id or label
140
+ const node = data.nodes.find((n) => n.id === q) ||
141
+ data.nodes.find((n) => n.label.toLowerCase() === q) ||
142
+ data.nodes.find((n) => n.id.includes(q) || n.label.toLowerCase().includes(q));
143
+ if (!node) {
144
+ return { node: undefined, upstream: [], downstream: [] };
145
+ }
146
+ // Upstream = edges where this node is the target
147
+ const upstream = data.edges
148
+ .filter((e) => e.target === node.id)
149
+ .map((e) => ({
150
+ node: data.nodes.find((n) => n.id === e.source),
151
+ type: e.type,
152
+ }))
153
+ .filter((r) => r.node);
154
+ // Downstream = edges where this node is the source
155
+ const downstream = data.edges
156
+ .filter((e) => e.source === node.id)
157
+ .map((e) => ({
158
+ node: data.nodes.find((n) => n.id === e.target),
159
+ type: e.type,
160
+ }))
161
+ .filter((r) => r.node);
162
+ return { node, upstream, downstream };
163
+ }
@@ -0,0 +1,11 @@
1
+ import { Metric, Definition } from "./types.js";
2
+ /**
3
+ * Parse a single metric markdown file into an array of Metric objects.
4
+ * Splits on ### headings and extracts fields.
5
+ */
6
+ export declare function parseMetricsFile(content: string, filename: string): Metric[];
7
+ /**
8
+ * Parse definitions.md into Definition objects.
9
+ * Each ### heading is a term.
10
+ */
11
+ export declare function parseDefinitions(content: string): Definition[];