@a11y-context/mcp-server 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/LICENSE +201 -0
- package/README.md +62 -0
- package/corpus/web/react/components/accordion.basic.md +157 -0
- package/corpus/web/react/components/button.basic.md +91 -0
- package/corpus/web/react/components/button.toggle.md +109 -0
- package/corpus/web/react/components/carousel.dots.md +376 -0
- package/corpus/web/react/components/carousel.thumbnails.md +395 -0
- package/corpus/web/react/components/collection-row.basic.md +179 -0
- package/corpus/web/react/components/combobox.autocomplete.md +293 -0
- package/corpus/web/react/components/dialog.modal.md +202 -0
- package/corpus/web/react/components/dialog.nonmodal.md +184 -0
- package/corpus/web/react/components/disclosure.basic.md +97 -0
- package/corpus/web/react/components/grid.channel-guide.md +453 -0
- package/corpus/web/react/components/link.basic.md +105 -0
- package/corpus/web/react/components/listbox.basic.md +263 -0
- package/corpus/web/react/components/menu.basic.md +294 -0
- package/corpus/web/react/components/menu.menubar.md +296 -0
- package/corpus/web/react/components/navigation-menu.basic.md +349 -0
- package/corpus/web/react/components/navigation-menu.dropdown.md +220 -0
- package/corpus/web/react/components/select.basic.md +318 -0
- package/corpus/web/react/components/select.native.md +108 -0
- package/corpus/web/react/components/switch.basic.md +151 -0
- package/corpus/web/react/components/toast.basic.md +112 -0
- package/corpus/web/react/components/tooltip.basic.md +139 -0
- package/corpus/web/react/global/global_rules.md +292 -0
- package/corpus/web/react/patterns.json +946 -0
- package/dist/config.js +35 -0
- package/dist/contracts/v1/types.js +6 -0
- package/dist/http.js +103 -0
- package/dist/index.js +8 -0
- package/dist/mcp/createServer.js +122 -0
- package/dist/mcp/response.js +7 -0
- package/dist/mcp/server.js +15 -0
- package/dist/repo/cache.js +27 -0
- package/dist/repo/globalRules.js +304 -0
- package/dist/repo/index.js +178 -0
- package/dist/repo/paths.js +25 -0
- package/dist/repo/sections.js +166 -0
- package/dist/smoke/smoke-remote-mcp.js +43 -0
- package/dist/telemetry.js +15 -0
- package/dist/telemetryWrap.js +48 -0
- package/dist/tools/getGlobalRules.js +33 -0
- package/dist/tools/getPattern.js +54 -0
- package/dist/tools/listPatterns.js +33 -0
- package/dist/utils/fs.js +18 -0
- package/dist/utils/hash.js +4 -0
- package/package.json +68 -0
package/dist/config.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
// Resolve the package root regardless of how the server is launched.
|
|
5
|
+
// ESM-safe (no `__dirname`, which is undefined under `type: module`).
|
|
6
|
+
// This file compiles to dist/config.js, so its dir is dist/ and the package
|
|
7
|
+
// root is one level up; in dev (ts-node on src/config.ts) it resolves the same.
|
|
8
|
+
const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
const PACKAGE_ROOT = path.resolve(MODULE_DIR, "..");
|
|
10
|
+
// Canonical server identity, read once from package.json so both transports
|
|
11
|
+
// (stdio and HTTP) advertise the same name and version in the MCP handshake.
|
|
12
|
+
const pkg = JSON.parse(readFileSync(path.join(PACKAGE_ROOT, "package.json"), "utf8"));
|
|
13
|
+
export const SERVER_NAME = pkg.name ?? "a11y-context-mcp";
|
|
14
|
+
export const SERVER_VERSION = pkg.version ?? "0.0.0";
|
|
15
|
+
/**
|
|
16
|
+
* Reads configuration from environment variables.
|
|
17
|
+
*/
|
|
18
|
+
export function getConfig() {
|
|
19
|
+
// The bundled corpus ships inside the package at <root>/corpus. Override with
|
|
20
|
+
// PATTERN_REPO_PATH (absolute, or relative to the package root) to point at a
|
|
21
|
+
// working corpus checkout during development.
|
|
22
|
+
const repoPathFromEnv = process.env.PATTERN_REPO_PATH ?? "corpus";
|
|
23
|
+
const patternRepoPath = path.isAbsolute(repoPathFromEnv)
|
|
24
|
+
? repoPathFromEnv
|
|
25
|
+
: path.resolve(PACKAGE_ROOT, repoPathFromEnv);
|
|
26
|
+
const cacheTtlSeconds = process.env.CACHE_TTL_SECONDS
|
|
27
|
+
? Number(process.env.CACHE_TTL_SECONDS)
|
|
28
|
+
: 60 * 60; // 1 hour default
|
|
29
|
+
// Startup debug is opt-in (goes to stderr, never stdout — safe for MCP).
|
|
30
|
+
if (process.env.A11Y_MCP_DEBUG) {
|
|
31
|
+
console.error("[config] patternRepoPath:", patternRepoPath);
|
|
32
|
+
console.error("[config] cacheTtlSeconds:", cacheTtlSeconds);
|
|
33
|
+
}
|
|
34
|
+
return { patternRepoPath, cacheTtlSeconds };
|
|
35
|
+
}
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// src/http.ts
|
|
2
|
+
import "dotenv/config";
|
|
3
|
+
import express from "express";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
6
|
+
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
7
|
+
import { createMcpServer as createSharedMcpServer } from "./mcp/createServer.js";
|
|
8
|
+
import { getConfig, SERVER_NAME, SERVER_VERSION } from "./config.js";
|
|
9
|
+
function createHttpMcpServer() {
|
|
10
|
+
// Use the same corpus resolution and identity as the stdio transport, so both
|
|
11
|
+
// read the bundled package corpus and advertise the same server name/version.
|
|
12
|
+
const config = getConfig();
|
|
13
|
+
return createSharedMcpServer({
|
|
14
|
+
name: SERVER_NAME,
|
|
15
|
+
version: SERVER_VERSION,
|
|
16
|
+
patternsRoot: config.patternRepoPath,
|
|
17
|
+
cacheTtlSeconds: config.cacheTtlSeconds,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
function originGuard() {
|
|
21
|
+
// MCP spec warns about Origin validation for HTTP transports. In practice,
|
|
22
|
+
// Cursor/Claude Code are not browsers and often send no Origin header.
|
|
23
|
+
// So: only enforce when Origin is present.
|
|
24
|
+
const allowed = (process.env.ALLOWED_ORIGINS ?? "")
|
|
25
|
+
.split(",")
|
|
26
|
+
.map((s) => s.trim())
|
|
27
|
+
.filter(Boolean);
|
|
28
|
+
return (req, res, next) => {
|
|
29
|
+
const origin = req.headers.origin;
|
|
30
|
+
if (!origin)
|
|
31
|
+
return next();
|
|
32
|
+
if (allowed.length === 0)
|
|
33
|
+
return res.status(403).send("Origin not allowed");
|
|
34
|
+
if (!allowed.includes(origin))
|
|
35
|
+
return res.status(403).send("Origin not allowed");
|
|
36
|
+
next();
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
async function main() {
|
|
40
|
+
const app = express();
|
|
41
|
+
app.use(express.json({ limit: "2mb" }));
|
|
42
|
+
app.use(originGuard());
|
|
43
|
+
// Health endpoint for Railway and humans
|
|
44
|
+
app.get("/health", (_req, res) => {
|
|
45
|
+
res.status(200).json({
|
|
46
|
+
ok: true,
|
|
47
|
+
contract_version: process.env.CONTRACT_VERSION ?? "v1",
|
|
48
|
+
patterns_dir: process.env.PATTERNS_DIR ?? "patterns",
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
// Map transports by session ID (official SDK approach).
|
|
52
|
+
const transports = {};
|
|
53
|
+
app.post("/mcp", async (req, res) => {
|
|
54
|
+
const sessionId = req.headers["mcp-session-id"];
|
|
55
|
+
let transport;
|
|
56
|
+
if (sessionId && transports[sessionId]) {
|
|
57
|
+
transport = transports[sessionId];
|
|
58
|
+
}
|
|
59
|
+
else if (!sessionId && isInitializeRequest(req.body)) {
|
|
60
|
+
transport = new StreamableHTTPServerTransport({
|
|
61
|
+
sessionIdGenerator: () => randomUUID(),
|
|
62
|
+
onsessioninitialized: (id) => {
|
|
63
|
+
transports[id] = transport;
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
transport.onclose = () => {
|
|
67
|
+
if (transport?.sessionId)
|
|
68
|
+
delete transports[transport.sessionId];
|
|
69
|
+
};
|
|
70
|
+
const server = createHttpMcpServer();
|
|
71
|
+
await server.connect(transport);
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
res.status(400).json({
|
|
75
|
+
jsonrpc: "2.0",
|
|
76
|
+
error: { code: -32000, message: "Bad Request: No valid session ID provided" },
|
|
77
|
+
id: null,
|
|
78
|
+
});
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
await transport.handleRequest(req, res, req.body);
|
|
82
|
+
});
|
|
83
|
+
const handleSessionRequest = async (req, res) => {
|
|
84
|
+
const sessionId = req.headers["mcp-session-id"];
|
|
85
|
+
if (!sessionId || !transports[sessionId]) {
|
|
86
|
+
res.status(400).send("Invalid or missing session ID");
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
await transports[sessionId].handleRequest(req, res);
|
|
90
|
+
};
|
|
91
|
+
// GET for server-to-client notifications via SSE (part of Streamable HTTP behavior)
|
|
92
|
+
app.get("/mcp", handleSessionRequest);
|
|
93
|
+
// DELETE to end a session
|
|
94
|
+
app.delete("/mcp", handleSessionRequest);
|
|
95
|
+
const port = Number(process.env.PORT) || 3000;
|
|
96
|
+
app.listen(port, "0.0.0.0", () => {
|
|
97
|
+
console.log(`MCP HTTP server listening on :${port} at /mcp`);
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
main().catch((e) => {
|
|
101
|
+
console.error(e);
|
|
102
|
+
process.exit(1);
|
|
103
|
+
});
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { createIndexCache } from "../repo/cache.js";
|
|
4
|
+
import { getGlobalRules } from "../tools/getGlobalRules.js";
|
|
5
|
+
import { getPattern } from "../tools/getPattern.js";
|
|
6
|
+
import { listPatterns } from "../tools/listPatterns.js";
|
|
7
|
+
import { withTelemetry } from "../telemetryWrap.js";
|
|
8
|
+
import { jsonResult } from "./response.js";
|
|
9
|
+
function registerTools(server, opts) {
|
|
10
|
+
const cache = createIndexCache({
|
|
11
|
+
patternRepoPath: opts.patternsRoot,
|
|
12
|
+
cacheTtlSeconds: opts.cacheTtlSeconds,
|
|
13
|
+
});
|
|
14
|
+
server.registerTool("list_patterns", {
|
|
15
|
+
description: "List the accessible UI component patterns available for a stack. Call this FIRST whenever you are about to build or modify any UI, to choose which patterns apply. Each entry has an `id`, `summary`, `tags`, `aliases`, and a `selection_excerpt` (its `use_when` / `do_not_use_when` bullets) — use the selection_excerpt to decide which patterns match the components in your task, then call get_pattern for each. Optionally narrow with `tags` or a free-text `query`.",
|
|
16
|
+
inputSchema: {
|
|
17
|
+
stack: z
|
|
18
|
+
.enum(["web/react", "android/compose"])
|
|
19
|
+
.default("web/react")
|
|
20
|
+
.describe("Target platform and framework. Currently only 'web/react' is populated; defaults to 'web/react'."),
|
|
21
|
+
tags: z.array(z.string()).optional(),
|
|
22
|
+
query: z.string().optional(),
|
|
23
|
+
},
|
|
24
|
+
}, async (args) => {
|
|
25
|
+
const stack = args.stack;
|
|
26
|
+
const toolArgs = {
|
|
27
|
+
stack,
|
|
28
|
+
tags: args.tags,
|
|
29
|
+
query: args.query,
|
|
30
|
+
};
|
|
31
|
+
const payload = await withTelemetry({
|
|
32
|
+
tool: "list_patterns",
|
|
33
|
+
stack,
|
|
34
|
+
args: toolArgs,
|
|
35
|
+
handler: async () => {
|
|
36
|
+
const index = await cache.getIndex(stack);
|
|
37
|
+
return listPatterns(index, {
|
|
38
|
+
stack,
|
|
39
|
+
tags: args.tags,
|
|
40
|
+
query: args.query,
|
|
41
|
+
});
|
|
42
|
+
},
|
|
43
|
+
summarizeResult: (result) => ({
|
|
44
|
+
count: result.count,
|
|
45
|
+
cache_ttl_seconds: result.cache_ttl_seconds,
|
|
46
|
+
catalog_revision: result.catalog_revision,
|
|
47
|
+
}),
|
|
48
|
+
});
|
|
49
|
+
return jsonResult(payload);
|
|
50
|
+
});
|
|
51
|
+
server.registerTool("get_pattern", {
|
|
52
|
+
description: "Get the full accessibility specification for one pattern by `id` (ids come from list_patterns). Returns the pattern's sections: `must_haves` (non-negotiable WCAG 2.2 AA requirements — implement all of them), `donts` (anti-patterns — never produce), `golden_pattern` (a reference implementation to model), `customizable` (allowed variations), and `acceptance_checks` (observable pass/fail behaviors). Call this for each pattern you selected before writing UI code.",
|
|
53
|
+
inputSchema: {
|
|
54
|
+
stack: z
|
|
55
|
+
.enum(["web/react", "android/compose"])
|
|
56
|
+
.default("web/react")
|
|
57
|
+
.describe("Target platform and framework. Currently only 'web/react' is populated; defaults to 'web/react'."),
|
|
58
|
+
id: z.string(),
|
|
59
|
+
},
|
|
60
|
+
}, async (args) => {
|
|
61
|
+
const stack = args.stack;
|
|
62
|
+
const toolArgs = {
|
|
63
|
+
stack,
|
|
64
|
+
id: String(args.id),
|
|
65
|
+
};
|
|
66
|
+
const payload = await withTelemetry({
|
|
67
|
+
tool: "get_pattern",
|
|
68
|
+
stack,
|
|
69
|
+
args: toolArgs,
|
|
70
|
+
handler: async () => {
|
|
71
|
+
const index = await cache.getIndex(stack);
|
|
72
|
+
return getPattern(index, opts.patternsRoot, {
|
|
73
|
+
stack,
|
|
74
|
+
id: String(args.id),
|
|
75
|
+
});
|
|
76
|
+
},
|
|
77
|
+
summarizeResult: (result) => ({
|
|
78
|
+
pattern_id: result.pattern.id,
|
|
79
|
+
cache_ttl_seconds: result.cache_ttl_seconds,
|
|
80
|
+
catalog_revision: result.catalog_revision,
|
|
81
|
+
}),
|
|
82
|
+
});
|
|
83
|
+
return jsonResult(payload);
|
|
84
|
+
});
|
|
85
|
+
server.registerTool("get_foundations", {
|
|
86
|
+
description: "Get the cross-cutting Foundations rules for a stack — accessibility requirements not tied to a single component: focus states, landmarks, headings, contrast, page structure, use of color. Retrieve these on every UI task, not just page-level work: each rule carries a `scope` (utility, style, component, layout, page) that determines whether it applies to the current change.",
|
|
87
|
+
inputSchema: {
|
|
88
|
+
stack: z
|
|
89
|
+
.enum(["web/react", "android/compose"])
|
|
90
|
+
.default("web/react")
|
|
91
|
+
.describe("Target platform and framework. Currently only 'web/react' is populated; defaults to 'web/react'."),
|
|
92
|
+
},
|
|
93
|
+
}, async (args) => {
|
|
94
|
+
const stack = args.stack;
|
|
95
|
+
const toolArgs = {
|
|
96
|
+
stack,
|
|
97
|
+
};
|
|
98
|
+
const payload = await withTelemetry({
|
|
99
|
+
tool: "get_foundations",
|
|
100
|
+
stack,
|
|
101
|
+
args: toolArgs,
|
|
102
|
+
handler: async () => {
|
|
103
|
+
const index = await cache.getIndex(stack);
|
|
104
|
+
return getGlobalRules(index, opts.patternsRoot, { stack });
|
|
105
|
+
},
|
|
106
|
+
summarizeResult: (result) => ({
|
|
107
|
+
rules_count: Array.isArray(result.rules.items) ? result.rules.items.length : 0,
|
|
108
|
+
cache_ttl_seconds: result.cache_ttl_seconds,
|
|
109
|
+
catalog_revision: result.catalog_revision,
|
|
110
|
+
}),
|
|
111
|
+
});
|
|
112
|
+
return jsonResult(payload);
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
export function createMcpServer(opts) {
|
|
116
|
+
const server = new McpServer({ name: opts.name, version: opts.version }, { capabilities: { tools: {} } });
|
|
117
|
+
registerTools(server, {
|
|
118
|
+
patternsRoot: opts.patternsRoot,
|
|
119
|
+
cacheTtlSeconds: opts.cacheTtlSeconds,
|
|
120
|
+
});
|
|
121
|
+
return server;
|
|
122
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
2
|
+
import { getConfig, SERVER_NAME, SERVER_VERSION } from "../config.js";
|
|
3
|
+
import { createMcpServer } from "./createServer.js";
|
|
4
|
+
export async function startMcpServer() {
|
|
5
|
+
const config = getConfig();
|
|
6
|
+
const server = createMcpServer({
|
|
7
|
+
name: SERVER_NAME,
|
|
8
|
+
version: SERVER_VERSION,
|
|
9
|
+
patternsRoot: config.patternRepoPath,
|
|
10
|
+
cacheTtlSeconds: config.cacheTtlSeconds,
|
|
11
|
+
});
|
|
12
|
+
const transport = new StdioServerTransport();
|
|
13
|
+
await server.connect(transport);
|
|
14
|
+
// Keep process alive (stdio transport)
|
|
15
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { buildPatternIndex } from "./index.js";
|
|
2
|
+
export function createIndexCache(params) {
|
|
3
|
+
const { patternRepoPath, cacheTtlSeconds } = params;
|
|
4
|
+
const store = new Map();
|
|
5
|
+
async function getIndex(stack) {
|
|
6
|
+
const now = Date.now();
|
|
7
|
+
const cached = store.get(stack);
|
|
8
|
+
// If we have it and it's fresh enough, reuse it.
|
|
9
|
+
if (cached) {
|
|
10
|
+
const ageSeconds = (now - cached.builtAtMs) / 1000;
|
|
11
|
+
if (ageSeconds < cacheTtlSeconds) {
|
|
12
|
+
return cached.index;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
// Otherwise rebuild
|
|
16
|
+
const index = await buildPatternIndex(patternRepoPath, stack, cacheTtlSeconds);
|
|
17
|
+
store.set(stack, { index, builtAtMs: now });
|
|
18
|
+
return index;
|
|
19
|
+
}
|
|
20
|
+
function clear(stack) {
|
|
21
|
+
if (stack)
|
|
22
|
+
store.delete(stack);
|
|
23
|
+
else
|
|
24
|
+
store.clear();
|
|
25
|
+
}
|
|
26
|
+
return { getIndex, clear };
|
|
27
|
+
}
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import matter from "gray-matter";
|
|
2
|
+
// Keep allowed values centralized and deterministic.
|
|
3
|
+
const ALLOWED_SCOPES = ["utility", "style", "component", "layout", "page"];
|
|
4
|
+
function normalizeScopeToken(raw) {
|
|
5
|
+
return raw.trim().toLowerCase();
|
|
6
|
+
}
|
|
7
|
+
function assertIsRuleScope(value, ctx) {
|
|
8
|
+
const v = normalizeScopeToken(value);
|
|
9
|
+
if (!ALLOWED_SCOPES.includes(v)) {
|
|
10
|
+
throw new Error(`${ctx}: invalid scope '${value}'. Allowed: ${ALLOWED_SCOPES.join(", ")}`);
|
|
11
|
+
}
|
|
12
|
+
return v;
|
|
13
|
+
}
|
|
14
|
+
function toRuleScopeArray(raw, ctx) {
|
|
15
|
+
const out = [];
|
|
16
|
+
for (const s of raw)
|
|
17
|
+
out.push(assertIsRuleScope(s, ctx));
|
|
18
|
+
// de-dupe + deterministic ordering
|
|
19
|
+
return Array.from(new Set(out)).sort((a, b) => a.localeCompare(b));
|
|
20
|
+
}
|
|
21
|
+
function parseStringArray(value) {
|
|
22
|
+
if (!Array.isArray(value))
|
|
23
|
+
return undefined;
|
|
24
|
+
const arr = value.map(String).map((s) => s.trim()).filter(Boolean);
|
|
25
|
+
return arr.length ? arr : undefined;
|
|
26
|
+
}
|
|
27
|
+
function parseApplyPolicy(value, ctx) {
|
|
28
|
+
if (!value || typeof value !== "object")
|
|
29
|
+
return undefined;
|
|
30
|
+
const v = value;
|
|
31
|
+
const instruction = typeof v.instruction === "string" ? v.instruction : undefined;
|
|
32
|
+
const rawScopes = parseStringArray(v.scopes_in_order);
|
|
33
|
+
const scopes_in_order = rawScopes
|
|
34
|
+
? toRuleScopeArray(rawScopes, `${ctx}: apply_policy.scopes_in_order`)
|
|
35
|
+
: undefined;
|
|
36
|
+
const policy = {};
|
|
37
|
+
if (instruction)
|
|
38
|
+
policy.instruction = instruction;
|
|
39
|
+
if (scopes_in_order)
|
|
40
|
+
policy.scopes_in_order = scopes_in_order;
|
|
41
|
+
return Object.keys(policy).length ? policy : undefined;
|
|
42
|
+
}
|
|
43
|
+
export function parseGlobalRulesMarkdown(fileText, expectedStack) {
|
|
44
|
+
const parsed = matter(fileText);
|
|
45
|
+
const data = parsed.data;
|
|
46
|
+
const body = parsed.content.replace(/\r\n/g, "\n").trim();
|
|
47
|
+
// ---- Frontmatter -> meta (light validation) ----
|
|
48
|
+
const meta = {
|
|
49
|
+
id: String(data.id ?? "").trim(),
|
|
50
|
+
stack: String(data.stack ?? "").trim() || expectedStack,
|
|
51
|
+
rule_set: typeof data.rule_set === "string" ? data.rule_set : undefined,
|
|
52
|
+
status: typeof data.status === "string" ? data.status : undefined,
|
|
53
|
+
summary: typeof data.summary === "string" ? data.summary : undefined,
|
|
54
|
+
cache_ttl_seconds: typeof data.cache_ttl_seconds === "number" ? data.cache_ttl_seconds : undefined,
|
|
55
|
+
apply_policy: parseApplyPolicy(data.apply_policy, "global rules frontmatter"),
|
|
56
|
+
};
|
|
57
|
+
if (!meta.id) {
|
|
58
|
+
throw new Error("Global rules file missing frontmatter 'id'.");
|
|
59
|
+
}
|
|
60
|
+
if (meta.stack !== expectedStack) {
|
|
61
|
+
throw new Error(`Global rules frontmatter stack='${meta.stack}' does not match requested stack='${expectedStack}'.`);
|
|
62
|
+
}
|
|
63
|
+
// Optional: validate status if present (keeps v1 strict-ish)
|
|
64
|
+
if (meta.status) {
|
|
65
|
+
const allowed = ["alpha", "beta", "stable", "deprecated"];
|
|
66
|
+
if (!allowed.includes(meta.status)) {
|
|
67
|
+
throw new Error(`Global rules frontmatter has invalid status='${meta.status}'. Allowed: ${allowed.join(", ")}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
// ---- Parse rules ----
|
|
71
|
+
const ruleBlocks = splitByH2Rule(body);
|
|
72
|
+
const rules = ruleBlocks.map((block) => parseOneRule(block));
|
|
73
|
+
// Deterministic ordering: by id
|
|
74
|
+
rules.sort((a, b) => a.id.localeCompare(b.id));
|
|
75
|
+
// Optional: enforce no duplicate rule ids
|
|
76
|
+
const seen = new Set();
|
|
77
|
+
for (const r of rules) {
|
|
78
|
+
if (seen.has(r.id)) {
|
|
79
|
+
throw new Error(`Duplicate global rule id '${r.id}' found.`);
|
|
80
|
+
}
|
|
81
|
+
seen.add(r.id);
|
|
82
|
+
}
|
|
83
|
+
return { meta, rules };
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Splits the markdown content into rule blocks based on "## Rule: ..."
|
|
87
|
+
* Slightly forgiving: allows extra spaces and different casing.
|
|
88
|
+
*/
|
|
89
|
+
function splitByH2Rule(markdown) {
|
|
90
|
+
const lines = markdown.split("\n");
|
|
91
|
+
const blocks = [];
|
|
92
|
+
let currentTitle = null;
|
|
93
|
+
let currentBody = [];
|
|
94
|
+
function push() {
|
|
95
|
+
if (!currentTitle)
|
|
96
|
+
return;
|
|
97
|
+
blocks.push({ title: currentTitle, body: currentBody.join("\n").trim() });
|
|
98
|
+
}
|
|
99
|
+
for (const line of lines) {
|
|
100
|
+
const match = line.match(/^##\s+Rule:\s*(.*)\s*$/i);
|
|
101
|
+
if (match) {
|
|
102
|
+
push();
|
|
103
|
+
currentTitle = match[1].trim();
|
|
104
|
+
currentBody = [];
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
if (currentTitle)
|
|
108
|
+
currentBody.push(line);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
push();
|
|
112
|
+
return blocks;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Parse a single rule block into a deterministic GlobalRule object.
|
|
116
|
+
*/
|
|
117
|
+
function parseOneRule(block) {
|
|
118
|
+
const { title, body } = block;
|
|
119
|
+
// 1) Extract the first fenced yaml block: ```yaml ... ```
|
|
120
|
+
const yamlFence = firstFencedBlock(body, "yaml");
|
|
121
|
+
if (!yamlFence) {
|
|
122
|
+
throw new Error(`Rule '${title}' is missing a \`\`\`yaml fenced block with 'id' and 'scope'.`);
|
|
123
|
+
}
|
|
124
|
+
const { id, scope } = parseRuleYaml(yamlFence.code, `Rule '${title}'`);
|
|
125
|
+
if (!id)
|
|
126
|
+
throw new Error(`Rule '${title}' yaml block is missing 'id'.`);
|
|
127
|
+
if (!scope.length)
|
|
128
|
+
throw new Error(`Rule '${title}' yaml block is missing 'scope'.`);
|
|
129
|
+
// 2) Extract sections by "### Heading"
|
|
130
|
+
const sections = splitByH3(body);
|
|
131
|
+
const must_haves = toBullets(sections["must haves"]);
|
|
132
|
+
const donts = toBullets(sections["don'ts"] ?? sections["donts"]);
|
|
133
|
+
const acceptance_checks = toBullets(sections["acceptance checks"]);
|
|
134
|
+
// 3) Snippets: collect *all* fenced blocks under "### Snippets"
|
|
135
|
+
const snippetsMarkdown = sections["snippets"] ?? "";
|
|
136
|
+
const snippets = allFencedBlocks(snippetsMarkdown)
|
|
137
|
+
.map((b) => ({ language: b.language, code: b.code }))
|
|
138
|
+
// Deterministic ordering: language then code
|
|
139
|
+
.sort((a, b) => (a.language + "\n" + a.code).localeCompare(b.language + "\n" + b.code));
|
|
140
|
+
return {
|
|
141
|
+
id,
|
|
142
|
+
title,
|
|
143
|
+
scope,
|
|
144
|
+
must_haves,
|
|
145
|
+
donts,
|
|
146
|
+
acceptance_checks,
|
|
147
|
+
snippets,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Splits a markdown chunk into a map of H3 sections:
|
|
152
|
+
* "### Must Haves" -> "...", "### Don'ts" -> "..."
|
|
153
|
+
*/
|
|
154
|
+
function splitByH3(markdown) {
|
|
155
|
+
const lines = markdown.split("\n");
|
|
156
|
+
const out = {};
|
|
157
|
+
let currentKey = null;
|
|
158
|
+
let currentBody = [];
|
|
159
|
+
function push() {
|
|
160
|
+
if (!currentKey)
|
|
161
|
+
return;
|
|
162
|
+
out[currentKey] = currentBody.join("\n").trim();
|
|
163
|
+
}
|
|
164
|
+
for (const line of lines) {
|
|
165
|
+
const match = line.match(/^###\s+(.*)\s*$/);
|
|
166
|
+
if (match) {
|
|
167
|
+
push();
|
|
168
|
+
currentKey = match[1].trim().toLowerCase();
|
|
169
|
+
currentBody = [];
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
if (currentKey)
|
|
173
|
+
currentBody.push(line);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
push();
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Convert a section's markdown to bullet array.
|
|
181
|
+
* Supports:
|
|
182
|
+
* - "- item"
|
|
183
|
+
* - "* item"
|
|
184
|
+
* - "1. item"
|
|
185
|
+
* Also supports basic one-level nesting.
|
|
186
|
+
*/
|
|
187
|
+
function toBullets(sectionMarkdown) {
|
|
188
|
+
if (!sectionMarkdown)
|
|
189
|
+
return [];
|
|
190
|
+
const lines = sectionMarkdown.replace(/\r\n/g, "\n").split("\n");
|
|
191
|
+
const items = [];
|
|
192
|
+
let current = null;
|
|
193
|
+
let nested = [];
|
|
194
|
+
function flushCurrent() {
|
|
195
|
+
if (!current)
|
|
196
|
+
return;
|
|
197
|
+
if (nested.length > 0) {
|
|
198
|
+
items.push(`${current}\n${nested.map((n) => ` - ${n}`).join("\n")}`);
|
|
199
|
+
}
|
|
200
|
+
else {
|
|
201
|
+
items.push(current);
|
|
202
|
+
}
|
|
203
|
+
current = null;
|
|
204
|
+
nested = [];
|
|
205
|
+
}
|
|
206
|
+
for (const rawLine of lines) {
|
|
207
|
+
const line = rawLine.replace(/\t/g, " ");
|
|
208
|
+
const trimmed = line.trim();
|
|
209
|
+
if (!trimmed)
|
|
210
|
+
continue;
|
|
211
|
+
// Nested bullet (2+ spaces then -/*)
|
|
212
|
+
const nestedMatch = line.match(/^\s{2,}[-*]\s+(.*)$/);
|
|
213
|
+
if (nestedMatch && current) {
|
|
214
|
+
nested.push(nestedMatch[1].trim());
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
// Top-level bullets at column 0
|
|
218
|
+
const topDash = line.match(/^[-*]\s+(.*)$/);
|
|
219
|
+
const topNum = line.match(/^\d+\.\s+(.*)$/);
|
|
220
|
+
if (topDash || topNum) {
|
|
221
|
+
flushCurrent();
|
|
222
|
+
current = (topDash?.[1] ?? topNum?.[1] ?? "").trim();
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
// Wrapped text continues current bullet
|
|
226
|
+
if (current) {
|
|
227
|
+
current = `${current} ${trimmed}`.trim();
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
flushCurrent();
|
|
231
|
+
return items;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Finds the first fenced block of a given language, e.g. ```yaml ... ```
|
|
235
|
+
* More forgiving: allows optional whitespace after language and tolerates missing trailing newline.
|
|
236
|
+
*/
|
|
237
|
+
function firstFencedBlock(markdown, language) {
|
|
238
|
+
const re = new RegExp("```" + language + "\\s*\\n([\\s\\S]*?)\\n?```", "i");
|
|
239
|
+
const m = markdown.match(re);
|
|
240
|
+
if (!m)
|
|
241
|
+
return null;
|
|
242
|
+
return { language: language.toLowerCase(), code: m[1].trim() };
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Collect all fenced code blocks inside a chunk (any language).
|
|
246
|
+
*/
|
|
247
|
+
function allFencedBlocks(markdown) {
|
|
248
|
+
const re = /```([a-zA-Z0-9_-]+)?\s*\n([\s\S]*?)\n?```/g;
|
|
249
|
+
const blocks = [];
|
|
250
|
+
let m;
|
|
251
|
+
while ((m = re.exec(markdown)) !== null) {
|
|
252
|
+
const language = (m[1] ?? "").trim().toLowerCase() || "text";
|
|
253
|
+
const code = (m[2] ?? "").trim();
|
|
254
|
+
blocks.push({ language, code });
|
|
255
|
+
}
|
|
256
|
+
return blocks;
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Minimal YAML parsing for the fields you use:
|
|
260
|
+
* - id: something
|
|
261
|
+
* - scope: [a, b]
|
|
262
|
+
* - OR scope:
|
|
263
|
+
* - a
|
|
264
|
+
* - b
|
|
265
|
+
*
|
|
266
|
+
* We keep MVP simple, but robust against common YAML patterns.
|
|
267
|
+
*/
|
|
268
|
+
function parseRuleYaml(yamlText, ctx) {
|
|
269
|
+
// id: value
|
|
270
|
+
const idMatch = yamlText.match(/^\s*id:\s*(.+)\s*$/m);
|
|
271
|
+
const id = idMatch ? idMatch[1].trim() : "";
|
|
272
|
+
// scope: [a, b]
|
|
273
|
+
const scopeInline = yamlText.match(/^\s*scope:\s*\[(.*)\]\s*$/m);
|
|
274
|
+
if (scopeInline) {
|
|
275
|
+
const raw = scopeInline[1]
|
|
276
|
+
.split(",")
|
|
277
|
+
.map((s) => s.trim())
|
|
278
|
+
.filter(Boolean);
|
|
279
|
+
return { id, scope: toRuleScopeArray(raw, `${ctx}: scope`) };
|
|
280
|
+
}
|
|
281
|
+
// scope:
|
|
282
|
+
// - a
|
|
283
|
+
// - b
|
|
284
|
+
const scopeBlock = yamlText.match(/^\s*scope:\s*$(?:\r?\n([\s\S]*))?/m);
|
|
285
|
+
if (scopeBlock) {
|
|
286
|
+
const after = yamlText.split(/\r?\n/);
|
|
287
|
+
const startIndex = after.findIndex((l) => /^\s*scope:\s*$/.test(l));
|
|
288
|
+
const raw = [];
|
|
289
|
+
if (startIndex >= 0) {
|
|
290
|
+
for (let i = startIndex + 1; i < after.length; i++) {
|
|
291
|
+
const line = after[i];
|
|
292
|
+
// stop when a new top-level key begins (e.g. "title:" etc.)
|
|
293
|
+
if (/^\S+\s*:/.test(line))
|
|
294
|
+
break;
|
|
295
|
+
const m = line.match(/^\s*-\s*(.+)\s*$/);
|
|
296
|
+
if (m)
|
|
297
|
+
raw.push(m[1].trim());
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return { id, scope: toRuleScopeArray(raw, `${ctx}: scope`) };
|
|
301
|
+
}
|
|
302
|
+
// No scope found
|
|
303
|
+
return { id, scope: [] };
|
|
304
|
+
}
|