@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
@@ -0,0 +1,178 @@
1
+ import fg from "fast-glob";
2
+ import matter from "gray-matter";
3
+ import path from "node:path";
4
+ import { getRepoPaths } from "./paths.js";
5
+ import { readTextFile, fileExists, toPosixPath } from "../utils/fs.js";
6
+ function normalizeBullet(s, maxLen) {
7
+ if (typeof s !== "string")
8
+ return null;
9
+ const cleaned = s.replace(/\s+/g, " ").trim();
10
+ if (!cleaned)
11
+ return null;
12
+ return cleaned.length > maxLen ? cleaned.slice(0, maxLen).trimEnd() : cleaned;
13
+ }
14
+ function normalizeBulletList(value, maxItems, maxLen) {
15
+ if (!Array.isArray(value))
16
+ return [];
17
+ const out = [];
18
+ for (const item of value) {
19
+ const b = normalizeBullet(item, maxLen);
20
+ if (b)
21
+ out.push(b);
22
+ if (out.length >= maxItems)
23
+ break;
24
+ }
25
+ return out;
26
+ }
27
+ function parseSelectionExcerpt(value) {
28
+ if (!value || typeof value !== "object")
29
+ return undefined;
30
+ const v = value;
31
+ const use_when = normalizeBulletList(v.use_when, 3, 140);
32
+ const do_not_use_when = normalizeBulletList(v.do_not_use_when, 3, 140);
33
+ if (use_when.length === 0 && do_not_use_when.length === 0)
34
+ return undefined;
35
+ return { use_when, do_not_use_when };
36
+ }
37
+ function uniqSorted(values) {
38
+ const cleaned = values.map((s) => String(s).trim()).filter(Boolean);
39
+ return Array.from(new Set(cleaned)).sort((a, b) => a.localeCompare(b));
40
+ }
41
+ /**
42
+ * patterns.json supported shapes:
43
+ * - { patterns: [...] }
44
+ * - { items: [...] }
45
+ * - [...] (array)
46
+ */
47
+ function buildCatalogSelectionMap(catalogText) {
48
+ const map = new Map();
49
+ let parsed;
50
+ try {
51
+ parsed = JSON.parse(catalogText);
52
+ }
53
+ catch {
54
+ throw new Error("patterns.json is not valid JSON.");
55
+ }
56
+ const parsedObj = parsed;
57
+ const arr = Array.isArray(parsed)
58
+ ? parsed
59
+ : Array.isArray(parsedObj?.patterns)
60
+ ? parsedObj.patterns
61
+ : Array.isArray(parsedObj?.items)
62
+ ? parsedObj.items
63
+ : null;
64
+ if (!arr)
65
+ return map;
66
+ for (const entry of arr) {
67
+ if (!entry || typeof entry !== "object")
68
+ continue;
69
+ const entryObj = entry;
70
+ const id = typeof entryObj.id === "string" ? entryObj.id.trim() : "";
71
+ if (!id)
72
+ continue;
73
+ const excerpt = parseSelectionExcerpt(entryObj.selection_excerpt);
74
+ if (excerpt)
75
+ map.set(id, excerpt);
76
+ }
77
+ return map;
78
+ }
79
+ /**
80
+ * Reads + indexes the content repo once.
81
+ * After this, list_patterns is very fast.
82
+ */
83
+ export async function buildPatternIndex(patternRepoPath, stack, cacheTtlSeconds) {
84
+ const { baselinePath, catalogPath, componentsGlob } = getRepoPaths(patternRepoPath, stack);
85
+ if (!(await fileExists(baselinePath))) {
86
+ throw new Error(`Missing baseline file: ${baselinePath}`);
87
+ }
88
+ if (!(await fileExists(catalogPath))) {
89
+ throw new Error(`Missing catalog file: ${catalogPath}`);
90
+ }
91
+ // Deterministic ordering: sort file paths before reading/hashing/parsing
92
+ const componentPaths = (await fg(componentsGlob, { onlyFiles: true, unique: true }))
93
+ .map(toPosixPath)
94
+ .sort((a, b) => a.localeCompare(b));
95
+ const baselineText = await readTextFile(baselinePath);
96
+ const catalogText = await readTextFile(catalogPath);
97
+ const selectionById = buildCatalogSelectionMap(catalogText);
98
+ // Read each component once; reuse both for hashing and parsing
99
+ const fileTextByPath = new Map();
100
+ for (const p of componentPaths) {
101
+ // NOTE: componentPaths are POSIX normalized; ensure readTextFile can handle them on Windows.
102
+ // If not, store original paths separately. (If you're not targeting Windows right now, you're fine.)
103
+ fileTextByPath.set(p, await readTextFile(p));
104
+ }
105
+ // The corpus's own semantic version (from patterns.json), surfaced to clients
106
+ // so they see e.g. "0.5.2" rather than an opaque content hash.
107
+ let catalog_revision = "unknown";
108
+ try {
109
+ const parsedCatalog = JSON.parse(catalogText);
110
+ if (typeof parsedCatalog.catalog_revision === "string") {
111
+ catalog_revision = parsedCatalog.catalog_revision;
112
+ }
113
+ }
114
+ catch {
115
+ // leave as "unknown" if the catalog isn't valid JSON
116
+ }
117
+ const byId = new Map();
118
+ const idToPath = new Map();
119
+ const all = [];
120
+ for (const filePath of componentPaths) {
121
+ const raw = fileTextByPath.get(filePath);
122
+ if (raw == null)
123
+ throw new Error(`Internal error: missing cached text for ${filePath}`);
124
+ const parsed = matter(raw);
125
+ const data = parsed.data;
126
+ const id = String(data.id ?? "").trim();
127
+ if (!id) {
128
+ throw new Error(`Pattern missing 'id' in frontmatter: ${filePath}`);
129
+ }
130
+ const declaredStack = String(data.stack ?? "").trim();
131
+ const status = String(data.status ?? "").trim();
132
+ const summary = String(data.summary ?? "").trim();
133
+ const tags = Array.isArray(data.tags) ? data.tags.map(String) : [];
134
+ const aliases = Array.isArray(data.aliases) ? data.aliases.map(String) : [];
135
+ if (declaredStack && declaredStack !== stack) {
136
+ throw new Error(`Pattern ${id} declares stack=${declaredStack} but is located under stack=${stack}`);
137
+ }
138
+ if (!summary) {
139
+ throw new Error(`Pattern missing 'summary' in frontmatter: ${filePath}`);
140
+ }
141
+ const allowed = ["alpha", "beta", "stable", "deprecated"];
142
+ if (!allowed.includes(status)) {
143
+ throw new Error(`Invalid status '${status}' in ${filePath}. Allowed: ${allowed.join(", ")}`);
144
+ }
145
+ const selection_excerpt = selectionById.get(id);
146
+ const pattern = {
147
+ id,
148
+ stack,
149
+ status,
150
+ summary,
151
+ tags: uniqSorted(tags),
152
+ aliases: uniqSorted(aliases),
153
+ ...(selection_excerpt ? { selection_excerpt } : {}),
154
+ };
155
+ if (byId.has(id)) {
156
+ throw new Error(`Duplicate pattern id '${id}' found. Second copy: ${filePath}`);
157
+ }
158
+ byId.set(id, pattern);
159
+ idToPath.set(id, filePath);
160
+ all.push(pattern);
161
+ }
162
+ // Already stable by filePath sort, but keep this as a last-line guarantee
163
+ all.sort((a, b) => a.id.localeCompare(b.id));
164
+ return {
165
+ stack,
166
+ cache: { catalog_revision, cache_ttl_seconds: cacheTtlSeconds },
167
+ byId,
168
+ idToPath,
169
+ all,
170
+ };
171
+ }
172
+ /**
173
+ * Helper to make debug info safe and consistent for output.
174
+ */
175
+ export function makeRelativePath(patternRepoPath, filePath) {
176
+ const rel = path.relative(patternRepoPath, filePath);
177
+ return toPosixPath(rel);
178
+ }
@@ -0,0 +1,25 @@
1
+ import path from "node:path";
2
+ /**
3
+ * Maps a stack name to actual file locations in the content repo.
4
+ */
5
+ export function getRepoPaths(patternRepoPath, stack) {
6
+ // Turns "web/react" into ["web", "react"], etc.
7
+ const parts = stack.split("/");
8
+ // Defensive check (helpful for beginners)
9
+ if (parts.length !== 2) {
10
+ throw new Error(`Invalid stack format: ${stack}. Expected "group/name" like "web/react".`);
11
+ }
12
+ // Corpus structure (bundled at <package>/corpus, or an override root):
13
+ // <root>/web/react/global/global_rules.md
14
+ // <root>/web/react/components/*.md
15
+ // <root>/web/react/patterns.json
16
+ // <root>/android/compose/... (when populated)
17
+ const root = path.join(patternRepoPath, ...parts);
18
+ return {
19
+ baselinePath: path.join(root, "global", "global_rules.md"),
20
+ catalogPath: path.join(root, "patterns.json"),
21
+ // IMPORTANT: make it recursive so nested folders work
22
+ // and include mdx/markdown if you ever use them.
23
+ componentsGlob: path.join(root, "components", "**", "*.{md,mdx,markdown}"),
24
+ };
25
+ }
@@ -0,0 +1,166 @@
1
+ // src/repo/sections.ts
2
+ /**
3
+ * Goal:
4
+ * Turn a markdown body into deterministic sections for AI consumption.
5
+ *
6
+ * This is intentionally "good enough" for v1:
7
+ * - It relies on headings (## Heading)
8
+ * - It extracts bullet lists into string arrays
9
+ * - It preserves Golden Pattern as markdown (often contains code blocks)
10
+ */
11
+ // Headings we recognize, mapped to keys in ParsedSections.
12
+ // Add more later without changing the rest of the parser.
13
+ const HEADING_TO_KEY = {
14
+ "use when": "use_when",
15
+ "do not use when": "do_not_use_when",
16
+ "must haves": "must_haves",
17
+ "customizable": "customizable",
18
+ "don'ts": "donts",
19
+ "don’ts": "donts",
20
+ "donts": "donts",
21
+ "golden pattern": "golden_pattern",
22
+ "acceptance checks": "acceptance_checks",
23
+ };
24
+ /**
25
+ * Extract sections from markdown (content without frontmatter).
26
+ */
27
+ export function extractSections(markdownBody) {
28
+ const normalized = markdownBody
29
+ .replace(/\r\n/g, "\n")
30
+ // normalize non-breaking spaces to regular spaces
31
+ .replace(/\u00A0/g, " ")
32
+ // normalize curly apostrophes to ASCII apostrophe
33
+ .replace(/[’‘]/g, "'");
34
+ // We’ll build up sections as raw markdown chunks first.
35
+ const rawByKey = {};
36
+ // Default output
37
+ const out = {
38
+ use_when: [],
39
+ do_not_use_when: [],
40
+ must_haves: [],
41
+ customizable: [],
42
+ donts: [],
43
+ golden_pattern: null,
44
+ acceptance_checks: [],
45
+ };
46
+ // Split by headings like: ## Something
47
+ // We keep the heading text so we know where each chunk belongs.
48
+ const parts = splitByH2(normalized);
49
+ for (const part of parts) {
50
+ const heading = (part.heading ?? "")
51
+ .replace(/\u00A0/g, " ")
52
+ .replace(/[’‘]/g, "'")
53
+ .trim()
54
+ .toLowerCase()
55
+ .replace(/[:.]+$/, ""); // tolerate "Golden Pattern:" style
56
+ const key = HEADING_TO_KEY[heading];
57
+ if (!key)
58
+ continue;
59
+ rawByKey[key] = (part.body ?? "").trim();
60
+ }
61
+ // Convert list-like sections to arrays
62
+ out.use_when = toBulletArray(rawByKey.use_when);
63
+ out.do_not_use_when = toBulletArray(rawByKey.do_not_use_when);
64
+ out.must_haves = toBulletArray(rawByKey.must_haves);
65
+ out.customizable = toBulletArray(rawByKey.customizable);
66
+ out.donts = toBulletArray(rawByKey.donts);
67
+ out.acceptance_checks = toBulletArray(rawByKey.acceptance_checks);
68
+ // Golden Pattern is special: keep markdown as-is (often code fences)
69
+ const golden = (rawByKey.golden_pattern ?? "").trim();
70
+ out.golden_pattern = golden.length > 0 ? golden : null;
71
+ return out;
72
+ }
73
+ /**
74
+ * Splits markdown by "##" headings.
75
+ * Returns an array like:
76
+ * - { heading: "Use when", body: "..." }
77
+ * - { heading: "Must haves", body: "..." }
78
+ */
79
+ function splitByH2(markdown) {
80
+ const lines = markdown.split("\n");
81
+ const result = [];
82
+ let currentHeading = null;
83
+ let currentBody = [];
84
+ function pushCurrent() {
85
+ // Don’t push empty preamble
86
+ if (currentHeading === null && currentBody.join("\n").trim() === "")
87
+ return;
88
+ result.push({ heading: currentHeading, body: currentBody.join("\n") });
89
+ }
90
+ for (const line of lines) {
91
+ const match = line.match(/^##[ \t\u00A0]+(.*)$/);
92
+ if (match) {
93
+ // New section begins
94
+ pushCurrent();
95
+ currentHeading = match[1].trim();
96
+ currentBody = [];
97
+ }
98
+ else {
99
+ currentBody.push(line);
100
+ }
101
+ }
102
+ pushCurrent();
103
+ return result;
104
+ }
105
+ /**
106
+ * Converts a markdown chunk into a list of bullet strings.
107
+ * Supports:
108
+ * - "- item"
109
+ * - "* item"
110
+ * - "1. item" (simple numbered lists)
111
+ *
112
+ * If the section isn't a list, we return [] (v1 simplicity).
113
+ */
114
+ function toBulletArray(sectionMarkdown) {
115
+ if (!sectionMarkdown)
116
+ return [];
117
+ const lines = sectionMarkdown.replace(/\r\n/g, "\n").split("\n");
118
+ const items = [];
119
+ let current = null;
120
+ let nested = [];
121
+ function flushCurrent() {
122
+ if (!current)
123
+ return;
124
+ if (nested.length > 0) {
125
+ items.push(`${current}\n${nested.map((n) => ` - ${n}`).join("\n")}`);
126
+ }
127
+ else {
128
+ items.push(current);
129
+ }
130
+ current = null;
131
+ nested = [];
132
+ }
133
+ for (const rawLine of lines) {
134
+ const line = rawLine.replace(/\t/g, " ");
135
+ const trimmed = line.trim();
136
+ if (!trimmed)
137
+ continue;
138
+ // Markdown subheadings inside a section (e.g. the `### Roles & structure`
139
+ // concern groups within Must Haves) are structure, not bullets. Flush any
140
+ // active bullet and skip the heading so it isn't concatenated into text.
141
+ if (/^#{1,6}\s/.test(trimmed)) {
142
+ flushCurrent();
143
+ continue;
144
+ }
145
+ // One-level nested bullets must be handled before top-level matching.
146
+ const nestedMatch = line.match(/^\s{2,}[-*]\s+(.*)$/);
147
+ if (nestedMatch && current) {
148
+ nested.push(nestedMatch[1].trim());
149
+ continue;
150
+ }
151
+ // Top-level bullets start at column 0.
152
+ const topDash = line.match(/^[-*]\s+(.*)$/);
153
+ const topNum = line.match(/^\d+\.\s+(.*)$/);
154
+ if (topDash || topNum) {
155
+ flushCurrent();
156
+ current = (topDash?.[1] ?? topNum?.[1] ?? "").trim();
157
+ continue;
158
+ }
159
+ // Wrapped text continues the active top-level bullet.
160
+ if (current) {
161
+ current = `${current} ${trimmed}`.trim();
162
+ }
163
+ }
164
+ flushCurrent();
165
+ return items;
166
+ }
@@ -0,0 +1,43 @@
1
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
3
+ const url = process.argv[2];
4
+ if (!url)
5
+ throw new Error("Usage: node dist/smoke-remote-mcp.js https://host/mcp");
6
+ const client = new Client({ name: "smoke-test", version: "1.0.0" });
7
+ const transport = new StreamableHTTPClientTransport(new URL(url));
8
+ await client.connect(transport);
9
+ function assertHasStructuredContent(result, toolName) {
10
+ if (!result ||
11
+ typeof result !== "object" ||
12
+ !("structuredContent" in result) ||
13
+ !result.structuredContent ||
14
+ typeof result.structuredContent !== "object") {
15
+ throw new Error(`${toolName} missing structuredContent in tool response.`);
16
+ }
17
+ }
18
+ // Just verifying the protocol handshake and tool listing works
19
+ const tools = await client.listTools();
20
+ console.log("Connected. Tool count:", tools.tools?.length ?? 0);
21
+ console.log("Tool names:", (tools.tools ?? []).map((t) => t.name));
22
+ const listPatternsResult = await client.callTool({
23
+ name: "list_patterns",
24
+ arguments: { stack: "web/react" },
25
+ });
26
+ assertHasStructuredContent(listPatternsResult, "list_patterns");
27
+ const listPayload = listPatternsResult.structuredContent;
28
+ const firstPatternId = listPayload.patterns?.[0]?.id;
29
+ if (!firstPatternId) {
30
+ throw new Error("list_patterns returned no patterns; cannot run get_pattern smoke check.");
31
+ }
32
+ const getGlobalRulesResult = await client.callTool({
33
+ name: "get_global_rules",
34
+ arguments: { stack: "web/react" },
35
+ });
36
+ assertHasStructuredContent(getGlobalRulesResult, "get_global_rules");
37
+ const getPatternResult = await client.callTool({
38
+ name: "get_pattern",
39
+ arguments: { stack: "web/react", id: firstPatternId },
40
+ });
41
+ assertHasStructuredContent(getPatternResult, "get_pattern");
42
+ console.log(`structuredContent check passed (list_patterns, get_global_rules, get_pattern). sample_pattern_id=${firstPatternId}`);
43
+ process.exit(0);
@@ -0,0 +1,15 @@
1
+ import crypto from "node:crypto";
2
+ export function newTraceId() {
3
+ return crypto.randomUUID();
4
+ }
5
+ export function nowIso() {
6
+ return new Date().toISOString();
7
+ }
8
+ export function estimateTokensFromChars(chars) {
9
+ // Crude but consistent estimate.
10
+ return Math.ceil(chars / 4);
11
+ }
12
+ export function logTelemetry(evt) {
13
+ // Keep MCP stdio output clean by writing telemetry to stderr.
14
+ console.error(JSON.stringify(evt));
15
+ }
@@ -0,0 +1,48 @@
1
+ import { estimateTokensFromChars, logTelemetry, newTraceId, nowIso } from "./telemetry.js";
2
+ export async function withTelemetry(params) {
3
+ const traceId = newTraceId();
4
+ const startMs = Date.now();
5
+ try {
6
+ const result = await params.handler();
7
+ const durationMs = Date.now() - startMs;
8
+ const argsJson = JSON.stringify(params.args ?? {});
9
+ const responseJson = JSON.stringify(result ?? {});
10
+ logTelemetry({
11
+ ts: nowIso(),
12
+ trace_id: traceId,
13
+ tool: params.tool,
14
+ stack: params.stack,
15
+ ok: true,
16
+ duration_ms: durationMs,
17
+ sizes: {
18
+ args_bytes: Buffer.byteLength(argsJson, "utf8"),
19
+ response_bytes: Buffer.byteLength(responseJson, "utf8"),
20
+ response_chars: responseJson.length,
21
+ est_tokens_chars_div4: estimateTokensFromChars(responseJson.length),
22
+ },
23
+ result: params.summarizeResult?.(result),
24
+ });
25
+ return result;
26
+ }
27
+ catch (error) {
28
+ const durationMs = Date.now() - startMs;
29
+ const argsJson = JSON.stringify(params.args ?? {});
30
+ const message = error instanceof Error ? error.message : String(error);
31
+ logTelemetry({
32
+ ts: nowIso(),
33
+ trace_id: traceId,
34
+ tool: params.tool,
35
+ stack: params.stack,
36
+ ok: false,
37
+ duration_ms: durationMs,
38
+ sizes: {
39
+ args_bytes: Buffer.byteLength(argsJson, "utf8"),
40
+ response_bytes: 0,
41
+ response_chars: 0,
42
+ est_tokens_chars_div4: 0,
43
+ },
44
+ error: { message },
45
+ });
46
+ throw error;
47
+ }
48
+ }
@@ -0,0 +1,33 @@
1
+ import { readTextFile } from "../utils/fs.js";
2
+ import { getRepoPaths } from "../repo/paths.js";
3
+ import { parseGlobalRulesMarkdown } from "../repo/globalRules.js";
4
+ function normalizeScope(scope) {
5
+ if (!scope)
6
+ return null;
7
+ return Array.isArray(scope) ? scope : [scope];
8
+ }
9
+ export async function getGlobalRules(index, patternRepoPath, args) {
10
+ const { stack } = args;
11
+ if (stack !== index.stack) {
12
+ throw new Error(`Stack mismatch. Index=${index.stack}, requested=${stack}`);
13
+ }
14
+ const scopeFilter = normalizeScope(args.scope);
15
+ const { baselinePath } = getRepoPaths(patternRepoPath, stack);
16
+ const fileText = await readTextFile(baselinePath);
17
+ const parsed = parseGlobalRulesMarkdown(fileText, stack);
18
+ const cache_ttl_seconds = parsed.meta.cache_ttl_seconds ?? index.cache.cache_ttl_seconds;
19
+ const items = scopeFilter
20
+ ? parsed.rules.filter((r) => r.scope.some((s) => scopeFilter.includes(s)))
21
+ : parsed.rules;
22
+ return {
23
+ contract_version: "1.0",
24
+ stack,
25
+ catalog_revision: index.cache.catalog_revision,
26
+ cache_ttl_seconds,
27
+ meta: parsed.meta,
28
+ rules: {
29
+ scope_filter: scopeFilter, // additive
30
+ items,
31
+ },
32
+ };
33
+ }
@@ -0,0 +1,54 @@
1
+ // src/tools/getPattern.ts
2
+ import matter from "gray-matter";
3
+ import { readTextFile } from "../utils/fs.js";
4
+ import { extractSections } from "../repo/sections.js";
5
+ import { makeRelativePath } from "../repo/index.js";
6
+ export async function getPattern(index, patternRepoPath, args) {
7
+ const { stack, id } = args;
8
+ if (stack !== index.stack) {
9
+ throw new Error(`Stack mismatch. Index=${index.stack}, requested=${stack}`);
10
+ }
11
+ const filePath = index.idToPath.get(id);
12
+ if (!filePath) {
13
+ throw new Error(`PATTERN_NOT_FOUND: No pattern with id '${id}' for stack '${stack}'`);
14
+ }
15
+ const raw = await readTextFile(filePath);
16
+ const parsed = matter(raw);
17
+ const data = parsed.data;
18
+ // Frontmatter fields (strict enough for v1)
19
+ const patternId = String(data.id ?? "").trim();
20
+ const status = String(data.status ?? "").trim();
21
+ const summary = String(data.summary ?? "").trim();
22
+ const tags = Array.isArray(data.tags) ? data.tags.map(String) : [];
23
+ const aliases = Array.isArray(data.aliases) ? data.aliases.map(String) : [];
24
+ if (!patternId)
25
+ throw new Error(`Pattern missing 'id' in frontmatter: ${filePath}`);
26
+ if (patternId !== id) {
27
+ throw new Error(`Pattern id mismatch. Requested '${id}', file declares '${patternId}' (${filePath})`);
28
+ }
29
+ // Optional but strongly recommended: validate declared stack matches folder stack
30
+ const declaredStack = String(data.stack ?? "").trim();
31
+ if (declaredStack && declaredStack !== stack) {
32
+ throw new Error(`Pattern '${id}' declares stack='${declaredStack}' but is served under stack='${stack}'. File: ${filePath}`);
33
+ }
34
+ if (!summary)
35
+ throw new Error(`Pattern missing 'summary' in frontmatter: ${filePath}`);
36
+ const sections = extractSections(parsed.content);
37
+ const detail = {
38
+ id,
39
+ stack,
40
+ status,
41
+ summary,
42
+ tags,
43
+ aliases,
44
+ sections,
45
+ source: {
46
+ relative_path: makeRelativePath(patternRepoPath, filePath),
47
+ },
48
+ };
49
+ return {
50
+ contract_version: "1.0",
51
+ ...index.cache,
52
+ pattern: detail,
53
+ };
54
+ }
@@ -0,0 +1,33 @@
1
+ export function listPatterns(index, args) {
2
+ const { stack, tags, query } = args;
3
+ if (stack !== index.stack) {
4
+ throw new Error(`Stack mismatch. Index=${index.stack}, requested=${stack}`);
5
+ }
6
+ let results = index.all;
7
+ // Filter by tags (OR logic): match if pattern has ANY of the requested tags.
8
+ if (tags && tags.length > 0) {
9
+ const wanted = new Set(tags.map((t) => t.toLowerCase()));
10
+ results = results.filter((p) => p.tags.some((t) => wanted.has(t.toLowerCase())));
11
+ }
12
+ // Simple query search across id, summary, aliases
13
+ if (query && query.trim()) {
14
+ const q = query.toLowerCase();
15
+ results = results.filter((p) => {
16
+ if (p.id.toLowerCase().includes(q))
17
+ return true;
18
+ if (p.summary.toLowerCase().includes(q))
19
+ return true;
20
+ if (p.aliases.some((a) => a.toLowerCase().includes(q)))
21
+ return true;
22
+ return false;
23
+ });
24
+ }
25
+ results = [...results].sort((a, b) => a.id.localeCompare(b.id));
26
+ return {
27
+ contract_version: "1.0",
28
+ stack,
29
+ ...index.cache,
30
+ count: results.length,
31
+ patterns: results,
32
+ };
33
+ }
@@ -0,0 +1,18 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ export async function fileExists(filePath) {
4
+ try {
5
+ await fs.access(filePath);
6
+ return true;
7
+ }
8
+ catch {
9
+ return false;
10
+ }
11
+ }
12
+ export async function readTextFile(filePath) {
13
+ return fs.readFile(filePath, "utf8");
14
+ }
15
+ export function toPosixPath(p) {
16
+ // Normalizes Windows backslashes to forward slashes for consistent output.
17
+ return p.split(path.sep).join("/");
18
+ }
@@ -0,0 +1,4 @@
1
+ import crypto from "node:crypto";
2
+ export function sha256(text) {
3
+ return crypto.createHash("sha256").update(text).digest("hex");
4
+ }