x-readiness-mcp 0.3.0 → 0.5.1

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.
@@ -0,0 +1,394 @@
1
+ // mcp/tools/rule-checkers.js
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { glob } from "glob";
5
+
6
+ /**
7
+ * Recursively find all source files in a repository
8
+ */
9
+ async function findSourceFiles(repoPath, extensions = ["js", "ts", "json", "yaml", "yml"]) {
10
+ const patterns = extensions.map(ext => `**/*.${ext}`);
11
+ const allFiles = [];
12
+
13
+ for (const pattern of patterns) {
14
+ const files = await glob(pattern, {
15
+ cwd: repoPath,
16
+ ignore: ["**/node_modules/**", "**/dist/**", "**/build/**", "**/.git/**", "**/mcp/**"],
17
+ absolute: true
18
+ });
19
+ allFiles.push(...files);
20
+ }
21
+
22
+ return allFiles;
23
+ }
24
+
25
+ /**
26
+ * Read file content with line tracking
27
+ */
28
+ async function readFileLines(filePath) {
29
+ const content = await fs.readFile(filePath, "utf8");
30
+ const lines = content.split("\n");
31
+ return { content, lines, filePath };
32
+ }
33
+
34
+ /**
35
+ * Base checker function - to be extended for specific rules
36
+ */
37
+ async function checkRule(repoPath, rule) {
38
+ const ruleId = rule.rule_id.toLowerCase();
39
+
40
+ // Route to specific checker based on rule category
41
+ const category = rule.category.toLowerCase();
42
+
43
+ if (category.includes("pagination")) {
44
+ return await checkPaginationRule(repoPath, rule);
45
+ } else if (category.includes("error")) {
46
+ return await checkErrorHandlingRule(repoPath, rule);
47
+ } else if (category.includes("naming")) {
48
+ return await checkNamingRule(repoPath, rule);
49
+ } else if (category.includes("security")) {
50
+ return await checkSecurityRule(repoPath, rule);
51
+ } else if (category.includes("versioning")) {
52
+ return await checkVersioningRule(repoPath, rule);
53
+ } else if (category.includes("media")) {
54
+ return await checkMediaTypesRule(repoPath, rule);
55
+ } else if (category.includes("operations")) {
56
+ return await checkOperationsRule(repoPath, rule);
57
+ } else if (category.includes("filtering")) {
58
+ return await checkFilteringRule(repoPath, rule);
59
+ } else if (category.includes("sorting")) {
60
+ return await checkSortingRule(repoPath, rule);
61
+ } else if (category.includes("sparse")) {
62
+ return await checkSparseFieldsetsRule(repoPath, rule);
63
+ }
64
+
65
+ // Default: not yet implemented
66
+ return {
67
+ status: "SKIPPED",
68
+ reason: "Checker not implemented for this rule category",
69
+ violations: []
70
+ };
71
+ }
72
+
73
+ /**
74
+ * Check pagination rules
75
+ */
76
+ async function checkPaginationRule(repoPath, rule) {
77
+ const violations = [];
78
+ const files = await findSourceFiles(repoPath, ["js", "ts"]);
79
+
80
+ for (const file of files) {
81
+ const { lines } = await readFileLines(file);
82
+
83
+ for (let i = 0; i < lines.length; i++) {
84
+ const line = lines[i];
85
+ const lineNum = i + 1;
86
+
87
+ // Check for pagination-related patterns
88
+ if (rule.rule_id === "600") {
89
+ // Check if pagination links are provided
90
+ if (line.includes("pagination") || line.includes("/page")) {
91
+ // Look for links object
92
+ const contextLines = lines.slice(Math.max(0, i - 5), Math.min(lines.length, i + 10)).join("\n");
93
+ if (!contextLines.includes("links") && !contextLines.includes("next") && !contextLines.includes("prev")) {
94
+ violations.push({
95
+ file_path: path.relative(repoPath, file),
96
+ line_number: lineNum,
97
+ line_content: line.trim(),
98
+ message: "Pagination endpoint should include links for navigation (next, prev, etc.)"
99
+ });
100
+ }
101
+ }
102
+ }
103
+
104
+ // Check for cursor/offset/index pagination patterns
105
+ if (rule.rule_id.startsWith("601")) {
106
+ if (line.includes("page") && (line.includes("?") || line.includes("query"))) {
107
+ const contextLines = lines.slice(Math.max(0, i - 3), Math.min(lines.length, i + 5)).join("\n");
108
+
109
+ if (rule.rule_id.includes("601.1") && !contextLines.match(/cursor|token/i)) {
110
+ violations.push({
111
+ file_path: path.relative(repoPath, file),
112
+ line_number: lineNum,
113
+ line_content: line.trim(),
114
+ message: "Consider using cursor-based pagination strategy"
115
+ });
116
+ }
117
+
118
+ if (rule.rule_id.includes("601.2") && !contextLines.match(/offset|limit/i)) {
119
+ violations.push({
120
+ file_path: path.relative(repoPath, file),
121
+ line_number: lineNum,
122
+ line_content: line.trim(),
123
+ message: "Offset-based pagination should include offset and limit parameters"
124
+ });
125
+ }
126
+ }
127
+ }
128
+ }
129
+ }
130
+
131
+ return violations.length > 0
132
+ ? { status: "FAIL", violations }
133
+ : { status: "PASS", violations: [] };
134
+ }
135
+
136
+ /**
137
+ * Check error handling rules
138
+ */
139
+ async function checkErrorHandlingRule(repoPath, rule) {
140
+ const violations = [];
141
+ const files = await findSourceFiles(repoPath, ["js", "ts"]);
142
+
143
+ for (const file of files) {
144
+ const { lines } = await readFileLines(file);
145
+
146
+ for (let i = 0; i < lines.length; i++) {
147
+ const line = lines[i];
148
+ const lineNum = i + 1;
149
+
150
+ // Rule 300: Check for proper HTTP status codes
151
+ if (rule.rule_id === "300") {
152
+ if (line.match(/\.status\((\d+)\)|\.sendStatus\((\d+)\)|statusCode\s*=\s*(\d+)/)) {
153
+ const statusMatch = line.match(/(\d{3})/);
154
+ if (statusMatch) {
155
+ const status = parseInt(statusMatch[1]);
156
+ // Check if status code is valid HTTP status
157
+ if (status < 100 || status > 599) {
158
+ violations.push({
159
+ file_path: path.relative(repoPath, file),
160
+ line_number: lineNum,
161
+ line_content: line.trim(),
162
+ message: `Invalid HTTP status code ${status}. Use official HTTP status codes (100-599)`
163
+ });
164
+ }
165
+ }
166
+ }
167
+ }
168
+
169
+ // Rule 303: Check for stack traces in error responses
170
+ if (rule.rule_id === "303") {
171
+ if (line.match(/stack|stackTrace/i) && (line.includes("response") || line.includes("send") || line.includes("json"))) {
172
+ violations.push({
173
+ file_path: path.relative(repoPath, file),
174
+ line_number: lineNum,
175
+ line_content: line.trim(),
176
+ message: "API should not expose stack traces in error responses (security risk)"
177
+ });
178
+ }
179
+ }
180
+
181
+ // Rule 304: Check for errors object in error responses
182
+ if (rule.rule_id === "304") {
183
+ if (line.match(/catch|error/i) && line.match(/\.json\(|\.send\(/)) {
184
+ const contextLines = lines.slice(Math.max(0, i - 2), Math.min(lines.length, i + 3)).join("\n");
185
+ if (!contextLines.includes("errors")) {
186
+ violations.push({
187
+ file_path: path.relative(repoPath, file),
188
+ line_number: lineNum,
189
+ line_content: line.trim(),
190
+ message: "Error response should include an 'errors' object array"
191
+ });
192
+ }
193
+ }
194
+ }
195
+ }
196
+ }
197
+
198
+ return violations.length > 0
199
+ ? { status: "FAIL", violations }
200
+ : { status: "PASS", violations: [] };
201
+ }
202
+
203
+ /**
204
+ * Check naming convention rules
205
+ */
206
+ async function checkNamingRule(repoPath, rule) {
207
+ const violations = [];
208
+ const files = await findSourceFiles(repoPath, ["js", "ts", "json"]);
209
+
210
+ for (const file of files) {
211
+ const { content, lines } = await readFileLines(file);
212
+
213
+ // For JSON files, check property names
214
+ if (file.endsWith(".json")) {
215
+ try {
216
+ const json = JSON.parse(content);
217
+ checkObjectNaming(json, file, violations, repoPath, rule);
218
+ } catch (e) {
219
+ // Skip invalid JSON
220
+ }
221
+ } else {
222
+ // For JS/TS files, check object property definitions
223
+ for (let i = 0; i < lines.length; i++) {
224
+ const line = lines[i];
225
+ const lineNum = i + 1;
226
+
227
+ // Check for object property definitions
228
+ const propMatch = line.match(/['"]([a-zA-Z_][a-zA-Z0-9_]*)['"]:/);
229
+ if (propMatch) {
230
+ const propName = propMatch[1];
231
+
232
+ // Rule naming_01: Must start with lowercase
233
+ if (rule.rule_id === "naming_01" && /^[A-Z]/.test(propName) && propName !== propName.toUpperCase()) {
234
+ violations.push({
235
+ file_path: path.relative(repoPath, file),
236
+ line_number: lineNum,
237
+ line_content: line.trim(),
238
+ message: `Property "${propName}" should start with a lowercase letter (camelCase)`
239
+ });
240
+ }
241
+
242
+ // Rule naming_02: Must use camelCase
243
+ if (rule.rule_id === "naming_02" && propName.includes("_") && !propName.startsWith("_")) {
244
+ violations.push({
245
+ file_path: path.relative(repoPath, file),
246
+ line_number: lineNum,
247
+ line_content: line.trim(),
248
+ message: `Property "${propName}" should use camelCase instead of snake_case`
249
+ });
250
+ }
251
+
252
+ // Rule naming_04: Must use ASCII characters only
253
+ if (rule.rule_id === "naming_04" && /[^\x00-\x7F]/.test(propName)) {
254
+ violations.push({
255
+ file_path: path.relative(repoPath, file),
256
+ line_number: lineNum,
257
+ line_content: line.trim(),
258
+ message: `Property "${propName}" contains non-ASCII characters`
259
+ });
260
+ }
261
+ }
262
+ }
263
+ }
264
+ }
265
+
266
+ return violations.length > 0
267
+ ? { status: "FAIL", violations }
268
+ : { status: "PASS", violations: [] };
269
+ }
270
+
271
+ /**
272
+ * Helper to check object naming recursively
273
+ */
274
+ function checkObjectNaming(obj, filePath, violations, repoPath, rule, path = "") {
275
+ if (typeof obj !== "object" || obj === null) return;
276
+
277
+ for (const key in obj) {
278
+ const fullPath = path ? `${path}.${key}` : key;
279
+
280
+ if (rule.rule_id === "naming_01" && /^[A-Z]/.test(key) && key !== key.toUpperCase()) {
281
+ violations.push({
282
+ file_path: path.relative(repoPath, filePath),
283
+ line_number: 0,
284
+ line_content: `"${key}": ...`,
285
+ message: `JSON property "${fullPath}" should start with lowercase (camelCase)`
286
+ });
287
+ }
288
+
289
+ if (rule.rule_id === "naming_02" && key.includes("_") && !key.startsWith("_")) {
290
+ violations.push({
291
+ file_path: path.relative(repoPath, filePath),
292
+ line_number: 0,
293
+ line_content: `"${key}": ...`,
294
+ message: `JSON property "${fullPath}" should use camelCase instead of snake_case`
295
+ });
296
+ }
297
+
298
+ if (Array.isArray(obj[key])) {
299
+ obj[key].forEach((item, idx) => checkObjectNaming(item, filePath, violations, repoPath, rule, `${fullPath}[${idx}]`));
300
+ } else if (typeof obj[key] === "object") {
301
+ checkObjectNaming(obj[key], filePath, violations, repoPath, rule, fullPath);
302
+ }
303
+ }
304
+ }
305
+
306
+ /**
307
+ * Check security rules
308
+ */
309
+ async function checkSecurityRule(repoPath, rule) {
310
+ const violations = [];
311
+ const files = await findSourceFiles(repoPath, ["js", "ts"]);
312
+
313
+ for (const file of files) {
314
+ const { lines } = await readFileLines(file);
315
+
316
+ for (let i = 0; i < lines.length; i++) {
317
+ const line = lines[i];
318
+ const lineNum = i + 1;
319
+
320
+ // Rule sec_01: OAuth 2.0 Bearer Token
321
+ if (rule.rule_id === "sec_01" || rule.rule_id === "sec_09") {
322
+ if (line.match(/app\.(get|post|put|delete|patch)/i) || line.match(/router\.(get|post|put|delete|patch)/i)) {
323
+ const contextLines = lines.slice(Math.max(0, i - 5), Math.min(lines.length, i + 10)).join("\n");
324
+ if (!contextLines.match(/authorization|bearer|token|auth/i)) {
325
+ violations.push({
326
+ file_path: path.relative(repoPath, file),
327
+ line_number: lineNum,
328
+ line_content: line.trim(),
329
+ message: "API endpoint should be secured with OAuth 2.0 Bearer Token authentication"
330
+ });
331
+ }
332
+ }
333
+ }
334
+
335
+ // Rule sec_02: TLS/HTTPS
336
+ if (rule.rule_id === "sec_02") {
337
+ if (line.match(/http:\/\//) && !line.includes("localhost") && !line.includes("127.0.0.1")) {
338
+ violations.push({
339
+ file_path: path.relative(repoPath, file),
340
+ line_number: lineNum,
341
+ line_content: line.trim(),
342
+ message: "Use HTTPS instead of HTTP for API protection"
343
+ });
344
+ }
345
+ }
346
+ }
347
+ }
348
+
349
+ return violations.length > 0
350
+ ? { status: "FAIL", violations }
351
+ : { status: "PASS", violations: [] };
352
+ }
353
+
354
+ /**
355
+ * Stub checkers for other categories
356
+ */
357
+ async function checkVersioningRule(repoPath, rule) {
358
+ return { status: "SKIPPED", reason: "Versioning checker not yet implemented", violations: [] };
359
+ }
360
+
361
+ async function checkMediaTypesRule(repoPath, rule) {
362
+ return { status: "SKIPPED", reason: "Media types checker not yet implemented", violations: [] };
363
+ }
364
+
365
+ async function checkOperationsRule(repoPath, rule) {
366
+ return { status: "SKIPPED", reason: "Operations checker not yet implemented", violations: [] };
367
+ }
368
+
369
+ async function checkFilteringRule(repoPath, rule) {
370
+ return { status: "SKIPPED", reason: "Filtering checker not yet implemented", violations: [] };
371
+ }
372
+
373
+ async function checkSortingRule(repoPath, rule) {
374
+ return { status: "SKIPPED", reason: "Sorting checker not yet implemented", violations: [] };
375
+ }
376
+
377
+ async function checkSparseFieldsetsRule(repoPath, rule) {
378
+ return { status: "SKIPPED", reason: "Sparse fieldsets checker not yet implemented", violations: [] };
379
+ }
380
+
381
+ /**
382
+ * Main export - evaluate a single rule
383
+ */
384
+ export async function evaluateRule(repoPath, rule) {
385
+ try {
386
+ return await checkRule(repoPath, rule);
387
+ } catch (error) {
388
+ return {
389
+ status: "ERROR",
390
+ reason: error.message,
391
+ violations: []
392
+ };
393
+ }
394
+ }
@@ -0,0 +1,204 @@
1
+ // mcp/tools/template-parser.js
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ const TEMPLATE_PATH = path.resolve(process.cwd(), "mcp", "templates", "master_template.md");
6
+
7
+ /**
8
+ * Parse the master template to extract scopes, categories, and rules
9
+ * @returns {Promise<Object>} Parsed checklist with scopes and rules
10
+ */
11
+ export async function parseTemplate() {
12
+ const content = await fs.readFile(TEMPLATE_PATH, "utf8");
13
+ const lines = content.split("\n");
14
+
15
+ const result = {
16
+ template_version: "2.5.0",
17
+ last_updated: "2026-01-28",
18
+ scopes: [],
19
+ categories: [],
20
+ reference_urls: {
21
+ common: "https://developer.siemens.com/guidelines/api-guidelines/common/index.html",
22
+ rest: "https://developer.siemens.com/guidelines/api-guidelines/rest/index.html"
23
+ }
24
+ };
25
+
26
+ let currentSection = null;
27
+ let currentScope = null;
28
+ let currentCategory = null;
29
+
30
+ for (let i = 0; i < lines.length; i++) {
31
+ const line = lines[i].trim();
32
+
33
+ // Detect sections
34
+ if (line === "## Scopes") {
35
+ currentSection = "scopes";
36
+ continue;
37
+ }
38
+ if (line === "## Rule Categories") {
39
+ currentSection = "categories";
40
+ continue;
41
+ }
42
+
43
+ // Parse scopes
44
+ if (currentSection === "scopes" && line.startsWith("### ")) {
45
+ const scopeName = line.substring(4).trim();
46
+ currentScope = {
47
+ name: scopeName,
48
+ description: "",
49
+ includes: []
50
+ };
51
+ result.scopes.push(currentScope);
52
+ continue;
53
+ }
54
+
55
+ if (currentScope && line.startsWith("**Description:**")) {
56
+ currentScope.description = line.substring(16).trim();
57
+ continue;
58
+ }
59
+
60
+ if (currentScope && line.startsWith("- ")) {
61
+ const include = line.substring(2).trim();
62
+ if (include) currentScope.includes.push(include);
63
+ continue;
64
+ }
65
+
66
+ // Parse categories
67
+ if (currentSection === "categories" && line.match(/^### \d+\./)) {
68
+ const match = line.match(/^### \d+\.\s+(.+)/);
69
+ if (match) {
70
+ const categoryName = match[1].trim();
71
+ currentCategory = {
72
+ name: categoryName,
73
+ priority: "",
74
+ guideline_url: "",
75
+ rules: []
76
+ };
77
+ result.categories.push(currentCategory);
78
+ }
79
+ continue;
80
+ }
81
+
82
+ if (currentCategory && line.startsWith("**Priority:**")) {
83
+ currentCategory.priority = line.substring(13).trim();
84
+ continue;
85
+ }
86
+
87
+ if (currentCategory && line.startsWith("**Guideline:**")) {
88
+ // Extract URLs from markdown links
89
+ const urlMatches = line.match(/\[([^\]]+)\]\(([^)]+)\)/g);
90
+ if (urlMatches && urlMatches.length > 0) {
91
+ const firstUrl = urlMatches[0].match(/\(([^)]+)\)/);
92
+ if (firstUrl) {
93
+ currentCategory.guideline_url = firstUrl[1];
94
+ }
95
+ }
96
+ continue;
97
+ }
98
+
99
+ if (currentCategory && line.startsWith("- ")) {
100
+ const ruleMatch = line.match(/^- ([^:]+):\s*(.+)/);
101
+ if (ruleMatch) {
102
+ const ruleId = ruleMatch[1].trim();
103
+ const ruleDescription = ruleMatch[2].trim();
104
+ currentCategory.rules.push({
105
+ rule_id: ruleId,
106
+ rule_name: ruleDescription,
107
+ category: currentCategory.name,
108
+ priority: currentCategory.priority,
109
+ guideline_url: currentCategory.guideline_url
110
+ });
111
+ }
112
+ continue;
113
+ }
114
+
115
+ // Reset scope when we hit a separator
116
+ if (line === "---") {
117
+ currentScope = null;
118
+ }
119
+ }
120
+
121
+ return result;
122
+ }
123
+
124
+ /**
125
+ * Generate a checklist based on scope and developer prompt
126
+ * @param {string} scope - The scope to use (e.g., 'x_api_readiness', 'pagination_ready')
127
+ * @param {string} developerPrompt - Developer intent to filter rules
128
+ * @returns {Promise<Object>} Filtered checklist
129
+ */
130
+ export async function generateChecklist(scope, developerPrompt = "") {
131
+ const template = await parseTemplate();
132
+
133
+ // Find the requested scope
134
+ const scopeConfig = template.scopes.find(s => s.name === scope);
135
+ if (!scopeConfig) {
136
+ throw new Error(`Unknown scope: ${scope}. Available scopes: ${template.scopes.map(s => s.name).join(", ")}`);
137
+ }
138
+
139
+ // Filter categories based on scope includes
140
+ let selectedCategories = [];
141
+
142
+ if (scope === "x_api_readiness") {
143
+ // Include all categories for full X-API readiness
144
+ selectedCategories = template.categories;
145
+ } else {
146
+ // Filter by scope's includes
147
+ selectedCategories = template.categories.filter(cat => {
148
+ return scopeConfig.includes.some(inc =>
149
+ cat.name.toLowerCase().includes(inc.toLowerCase()) ||
150
+ inc.toLowerCase().includes(cat.name.toLowerCase())
151
+ );
152
+ });
153
+ }
154
+
155
+ // Further filter by developer prompt (keyword matching)
156
+ const prompt = developerPrompt.toLowerCase();
157
+ if (prompt && scope !== "x_api_readiness") {
158
+ const keywords = extractKeywords(prompt);
159
+ if (keywords.length > 0) {
160
+ selectedCategories = selectedCategories.filter(cat => {
161
+ const catName = cat.name.toLowerCase();
162
+ return keywords.some(kw => catName.includes(kw));
163
+ });
164
+ }
165
+ }
166
+
167
+ return {
168
+ template_version: template.template_version,
169
+ last_updated: template.last_updated,
170
+ scope: scope,
171
+ scope_description: scopeConfig.description,
172
+ developer_prompt: developerPrompt,
173
+ reference_urls: template.reference_urls,
174
+ categories: selectedCategories,
175
+ total_rules: selectedCategories.reduce((sum, cat) => sum + cat.rules.length, 0)
176
+ };
177
+ }
178
+
179
+ /**
180
+ * Extract keywords from developer prompt for filtering
181
+ */
182
+ function extractKeywords(prompt) {
183
+ const keywordMap = {
184
+ "pagination": ["pagination", "paging", "page"],
185
+ "error": ["error", "errors", "error handling"],
186
+ "security": ["security", "auth", "oauth", "jwt", "token"],
187
+ "naming": ["naming", "camelcase", "kebab-case"],
188
+ "versioning": ["version", "versioning"],
189
+ "media": ["media", "content type", "json"],
190
+ "sorting": ["sort", "sorting"],
191
+ "filtering": ["filter", "filtering"],
192
+ "bulk": ["bulk", "batch"],
193
+ "sparse": ["sparse", "fields"],
194
+ "operations": ["operations", "crud"]
195
+ };
196
+
197
+ const found = [];
198
+ for (const [category, keywords] of Object.entries(keywordMap)) {
199
+ if (keywords.some(kw => prompt.includes(kw))) {
200
+ found.push(category);
201
+ }
202
+ }
203
+ return found;
204
+ }