@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.
Files changed (47) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +62 -0
  3. package/corpus/web/react/components/accordion.basic.md +157 -0
  4. package/corpus/web/react/components/button.basic.md +91 -0
  5. package/corpus/web/react/components/button.toggle.md +109 -0
  6. package/corpus/web/react/components/carousel.dots.md +376 -0
  7. package/corpus/web/react/components/carousel.thumbnails.md +395 -0
  8. package/corpus/web/react/components/collection-row.basic.md +179 -0
  9. package/corpus/web/react/components/combobox.autocomplete.md +293 -0
  10. package/corpus/web/react/components/dialog.modal.md +202 -0
  11. package/corpus/web/react/components/dialog.nonmodal.md +184 -0
  12. package/corpus/web/react/components/disclosure.basic.md +97 -0
  13. package/corpus/web/react/components/grid.channel-guide.md +453 -0
  14. package/corpus/web/react/components/link.basic.md +105 -0
  15. package/corpus/web/react/components/listbox.basic.md +263 -0
  16. package/corpus/web/react/components/menu.basic.md +294 -0
  17. package/corpus/web/react/components/menu.menubar.md +296 -0
  18. package/corpus/web/react/components/navigation-menu.basic.md +349 -0
  19. package/corpus/web/react/components/navigation-menu.dropdown.md +220 -0
  20. package/corpus/web/react/components/select.basic.md +318 -0
  21. package/corpus/web/react/components/select.native.md +108 -0
  22. package/corpus/web/react/components/switch.basic.md +151 -0
  23. package/corpus/web/react/components/toast.basic.md +112 -0
  24. package/corpus/web/react/components/tooltip.basic.md +139 -0
  25. package/corpus/web/react/global/global_rules.md +292 -0
  26. package/corpus/web/react/patterns.json +946 -0
  27. package/dist/config.js +35 -0
  28. package/dist/contracts/v1/types.js +6 -0
  29. package/dist/http.js +103 -0
  30. package/dist/index.js +8 -0
  31. package/dist/mcp/createServer.js +122 -0
  32. package/dist/mcp/response.js +7 -0
  33. package/dist/mcp/server.js +15 -0
  34. package/dist/repo/cache.js +27 -0
  35. package/dist/repo/globalRules.js +304 -0
  36. package/dist/repo/index.js +178 -0
  37. package/dist/repo/paths.js +25 -0
  38. package/dist/repo/sections.js +166 -0
  39. package/dist/smoke/smoke-remote-mcp.js +43 -0
  40. package/dist/telemetry.js +15 -0
  41. package/dist/telemetryWrap.js +48 -0
  42. package/dist/tools/getGlobalRules.js +33 -0
  43. package/dist/tools/getPattern.js +54 -0
  44. package/dist/tools/listPatterns.js +33 -0
  45. package/dist/utils/fs.js +18 -0
  46. package/dist/utils/hash.js +4 -0
  47. 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
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * QUESTIONS
3
+ *
4
+
5
+ */
6
+ export {};
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,8 @@
1
+ #!/usr/bin/env node
2
+ import "dotenv/config";
3
+ import { startMcpServer } from "./mcp/server.js";
4
+ startMcpServer().catch((err) => {
5
+ console.error("Startup failed ❌");
6
+ console.error(err);
7
+ process.exit(1);
8
+ });
@@ -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,7 @@
1
+ export function jsonResult(payload, options) {
2
+ return {
3
+ content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
4
+ structuredContent: payload,
5
+ ...(options?.isError !== undefined ? { isError: options.isError } : {}),
6
+ };
7
+ }
@@ -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
+ }