x-readiness-mcp 0.3.0 → 0.5.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.
@@ -0,0 +1,88 @@
1
+ // mcp/tools/gitignore-helper.js
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ /**
6
+ * Ensures .xreadiness/ is ignored by git
7
+ * Tries .gitignore first, falls back to .git/info/exclude
8
+ */
9
+ export async function ensureXReadinessIgnored(repoPath) {
10
+ const gitignorePath = path.join(repoPath, ".gitignore");
11
+ const gitExcludePath = path.join(repoPath, ".git", "info", "exclude");
12
+ const ignoreEntry = ".xreadiness/";
13
+
14
+ // Try to add to .gitignore
15
+ try {
16
+ let gitignoreContent = "";
17
+ try {
18
+ gitignoreContent = await fs.readFile(gitignorePath, "utf8");
19
+ } catch {
20
+ // File doesn't exist, will create it
21
+ }
22
+
23
+ // Check if already present
24
+ if (gitignoreContent.includes(ignoreEntry)) {
25
+ return {
26
+ status: "ALREADY_IGNORED",
27
+ method: ".gitignore",
28
+ message: ".xreadiness/ is already in .gitignore"
29
+ };
30
+ }
31
+
32
+ // Append to .gitignore
33
+ const newContent = gitignoreContent.trim()
34
+ ? `${gitignoreContent.trim()}\n\n# X-Readiness tool artifacts\n${ignoreEntry}\n`
35
+ : `# X-Readiness tool artifacts\n${ignoreEntry}\n`;
36
+
37
+ await fs.writeFile(gitignorePath, newContent, "utf8");
38
+
39
+ return {
40
+ status: "ADDED_TO_GITIGNORE",
41
+ method: ".gitignore",
42
+ path: gitignorePath,
43
+ message: `✅ Added ${ignoreEntry} to .gitignore`
44
+ };
45
+ } catch (gitignoreErr) {
46
+ // Fallback to .git/info/exclude
47
+ try {
48
+ let excludeContent = "";
49
+ try {
50
+ excludeContent = await fs.readFile(gitExcludePath, "utf8");
51
+ } catch {
52
+ // Create directory if needed
53
+ await fs.mkdir(path.dirname(gitExcludePath), { recursive: true });
54
+ }
55
+
56
+ // Check if already present
57
+ if (excludeContent.includes(ignoreEntry)) {
58
+ return {
59
+ status: "ALREADY_IGNORED",
60
+ method: ".git/info/exclude",
61
+ message: ".xreadiness/ is already in .git/info/exclude"
62
+ };
63
+ }
64
+
65
+ // Append to exclude
66
+ const newContent = excludeContent.trim()
67
+ ? `${excludeContent.trim()}\n\n# X-Readiness tool artifacts\n${ignoreEntry}\n`
68
+ : `# X-Readiness tool artifacts\n${ignoreEntry}\n`;
69
+
70
+ await fs.writeFile(gitExcludePath, newContent, "utf8");
71
+
72
+ return {
73
+ status: "ADDED_TO_EXCLUDE",
74
+ method: ".git/info/exclude",
75
+ path: gitExcludePath,
76
+ message: `✅ Added ${ignoreEntry} to .git/info/exclude (local ignore)`
77
+ };
78
+ } catch (excludeErr) {
79
+ return {
80
+ status: "FAILED",
81
+ error: `Could not add to .gitignore or .git/info/exclude`,
82
+ gitignore_error: gitignoreErr.message,
83
+ exclude_error: excludeErr.message,
84
+ message: "⚠️ Failed to auto-ignore .xreadiness/. Please add it manually to .gitignore"
85
+ };
86
+ }
87
+ }
88
+ }
package/tools/planning.js CHANGED
@@ -1,343 +1,130 @@
1
- // tools/planning.js
2
- // X-API Readiness Planning Tool (Deterministic / Offline)
3
- //
4
- // Contract:
5
- // - Input: { scope, format }
6
- // - Output: canonical JSON with `features[]` (stable IDs), optionally `markdown`.
7
- //
8
- // IMPORTANT: No network calls or HTML scraping. Everything is generated from
9
- // `templates/master_template.json` (sources/scopes/categories.expected_rules).
10
-
11
- import fs from "fs";
12
- import path from "path";
13
- import { fileURLToPath } from "url";
14
-
15
- const __filename = fileURLToPath(import.meta.url);
16
- const __dirname = path.dirname(__filename);
17
-
18
- const MASTER_TEMPLATE_PATH = path.join(__dirname, "..", "templates", "master_template.json");
19
- const MASTER_TEMPLATE_MD_PATH = path.join(__dirname, "..", "templates", "master_template.md");
20
-
21
- export async function generateChecklist(scope, format = "json") {
22
- // Try to load JSON first, fallback to parsing MD if JSON doesn't exist
23
- let template;
24
- try {
25
- template = loadMasterTemplate();
26
- } catch (err) {
27
- // If JSON doesn't exist, we'll use the MD version (for now just throw)
28
- throw new Error(`Master template not found. Please ensure master_template.json exists: ${err.message}`);
29
- }
30
- const scopeKey = resolveScopeKey(scope, template);
31
- const selectedSources = selectSourcesByScope(scopeKey, template);
32
- const selectedCategories = selectCategoriesForSources(selectedSources, template);
33
-
34
- const checklist = buildChecklistFromTemplate(scopeKey, selectedCategories, selectedSources, template);
35
-
36
- if (format === "markdown") {
37
- return {
38
- ...checklist,
39
- markdown: formatAsMarkdown(checklist)
40
- };
41
- }
42
-
43
- if (format === "both") {
44
- return {
45
- ...checklist,
46
- markdown: formatAsMarkdown(checklist)
47
- };
48
- }
49
-
50
- return checklist;
51
- }
52
-
53
- export const planningTool = generateChecklist;
54
-
55
- function loadMasterTemplate() {
56
- const raw = fs.readFileSync(MASTER_TEMPLATE_PATH, "utf8");
57
- const template = JSON.parse(raw);
58
-
59
- if (!template || typeof template !== "object") {
60
- throw new Error("Invalid master template: not an object");
61
- }
62
- if (!Array.isArray(template.sources)) {
63
- throw new Error("Invalid master template: missing 'sources[]'");
64
- }
65
- if (!template.scopes || typeof template.scopes !== "object") {
66
- throw new Error("Invalid master template: missing 'scopes'");
67
- }
68
- if (!template.categories || typeof template.categories !== "object") {
69
- throw new Error("Invalid master template: missing 'categories'");
70
- }
71
- return template;
72
- }
73
-
74
- function resolveScopeKey(scope, template) {
75
- const scopeInput = (scope || "").toString().trim();
76
- if (!scopeInput) return "x_api_readiness";
77
-
78
- const directKey = scopeInput.toLowerCase();
79
- if (template.scopes[directKey]) return directKey;
80
-
81
- const normalized = directKey.replace(/[_-]/g, "");
82
- for (const key of Object.keys(template.scopes)) {
83
- if (key.replace(/[_-]/g, "") === normalized) return key;
84
- }
85
-
86
- return "x_api_readiness";
87
- }
88
-
89
- function selectSourcesByScope(scopeKey, template) {
90
- const scopeConfig = template.scopes[scopeKey] || template.scopes.x_api_readiness;
91
- const includePatterns = Array.isArray(scopeConfig?.include_ids) ? scopeConfig.include_ids : ["common-*", "rest-*"];
92
- const excludePatterns = Array.isArray(scopeConfig?.exclude_ids) ? scopeConfig.exclude_ids : [];
93
-
94
- return template.sources.filter((source) => {
95
- if (!source || !source.id) return false;
96
-
97
- const isIncluded = includePatterns.some((pattern) => matchIdPattern(source.id, pattern));
98
- if (!isIncluded) return false;
99
-
100
- const isExcluded = excludePatterns.some((pattern) => matchIdPattern(source.id, pattern));
101
- if (isExcluded) return false;
102
-
103
- return true;
104
- });
105
- }
106
-
107
- function matchIdPattern(id, pattern) {
108
- if (!pattern) return false;
109
- if (pattern === "*") return true;
110
- if (pattern.endsWith("*")) return id.startsWith(pattern.slice(0, -1));
111
- if (pattern.startsWith("*")) return id.endsWith(pattern.slice(1));
112
- return id === pattern;
113
- }
114
-
115
- function selectCategoriesForSources(selectedSources, template) {
116
- const categories = template.categories;
117
-
118
- // Build a reverse lookup: normalized guideline URL -> [{ categoryKey, category }]
119
- const byUrl = new Map();
120
- for (const [categoryKey, category] of Object.entries(categories)) {
121
- const url = normalizeUrl(category?.guideline_url);
122
- if (!url) continue;
123
- const existing = byUrl.get(url) || [];
124
- existing.push({ categoryKey, category });
125
- byUrl.set(url, existing);
126
- }
127
-
128
- const selected = new Map();
129
- for (const source of selectedSources) {
130
- const url = normalizeUrl(source?.url);
131
- if (!url) continue;
132
- const matches = byUrl.get(url);
133
- if (!matches || matches.length === 0) continue;
134
-
135
- for (const match of matches) {
136
- selected.set(match.categoryKey, match.category);
137
- }
138
- }
139
-
140
- return selected;
141
- }
142
-
143
- function normalizeUrl(url) {
144
- if (!url || typeof url !== "string") return null;
145
- try {
146
- const parsed = new URL(url);
147
- parsed.hash = "";
148
- // Some URLs might differ only by trailing slash
149
- const normalized = parsed.toString().replace(/\/$/, "");
150
- return normalized;
151
- } catch {
152
- return url.replace(/#.*$/, "").replace(/\/$/, "");
153
- }
154
- }
155
-
156
- function buildChecklistFromTemplate(scopeKey, selectedCategoriesMap, selectedSources, template) {
157
- const generatedAt = new Date().toISOString();
158
- const checklistId = `xreadiness:${template.version || "0"}:${scopeKey}`;
159
-
160
- const selectedCategories = Array.from(selectedCategoriesMap.entries())
161
- .map(([categoryKey, category]) => ({ categoryKey, category }))
162
- .sort((a, b) => a.categoryKey.localeCompare(b.categoryKey));
163
-
164
- const features = selectedCategories
165
- .map(({ categoryKey, category }) => buildFeature(categoryKey, category))
166
- .filter((f) => f.rules.length > 0);
167
-
168
- // Derived flattened rules for execution tool compatibility
169
- const rules = [];
170
- for (const feature of features) {
171
- for (const rule of feature.rules) {
172
- rules.push({
173
- ruleId: rule.rule_id,
174
- ruleName: rule.rule_name,
175
- category: feature.category_key,
176
- categoryKey: feature.category_key,
177
- normative: rule.normative,
178
- severity: rule.severity,
179
- referenceUrl: rule.reference_url
180
- });
1
+ // mcp/tools/planning.js
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import crypto from "node:crypto";
5
+ import { generateChecklist } from "./template-parser.js";
6
+ import { ensureXReadinessIgnored } from "./gitignore-helper.js";
7
+
8
+ const PLAN_DIR_NAME = [".xreadiness", "plan"];
9
+
10
+ function planDir(repoPath) {
11
+ return path.resolve(repoPath, ...PLAN_DIR_NAME);
12
+ }
13
+
14
+ async function ensureDir(p) {
15
+ await fs.mkdir(p, { recursive: true });
16
+ }
17
+
18
+ /**
19
+ * Generate plan markdown with high-level checklist
20
+ */
21
+ function toPlanMarkdown({ planId, repoPath, scope, developerPrompt, checklist }) {
22
+ const totalRules = checklist.total_rules;
23
+
24
+ let md = `# X-Readiness Plan\n\n`;
25
+ md += `**Generated:** ${new Date().toISOString()}\n\n`;
26
+ md += `- **Plan ID:** ${planId}\n`;
27
+ md += `- **Repository Path:** ${repoPath}\n`;
28
+ md += `- **Scope:** ${scope}\n`;
29
+ md += `- **Scope Description:** ${checklist.scope_description}\n`;
30
+ md += `- **Developer Prompt:** ${developerPrompt}\n`;
31
+ md += `- **Total Rules Selected:** ${totalRules}\n`;
32
+ md += `- **Template Version:** ${checklist.template_version}\n\n`;
33
+
34
+ md += `---\n\n`;
35
+ md += `## Reference URLs\n\n`;
36
+ md += `- Common Guidelines: ${checklist.reference_urls.common}\n`;
37
+ md += `- REST Guidelines: ${checklist.reference_urls.rest}\n\n`;
38
+
39
+ md += `---\n\n`;
40
+ md += `## High-Level Checklist\n\n`;
41
+ md += `This plan will check the following categories and rules:\n\n`;
42
+
43
+ for (const category of checklist.categories) {
44
+ md += `### ${category.name} (Priority: ${category.priority})\n\n`;
45
+ md += `**Guideline:** [${category.name}](${category.guideline_url})\n\n`;
46
+ md += `**Rules to check:**\n\n`;
47
+
48
+ for (const rule of category.rules) {
49
+ md += `- [ ] **${rule.rule_id}** — ${rule.rule_name}\n`;
181
50
  }
51
+ md += `\n`;
182
52
  }
183
53
 
184
- const summary = computeSummary(features);
185
-
186
- return {
187
- checklist_id: checklistId,
188
- scope: scopeKey,
189
- generated_at: generatedAt,
190
- template_version: template.version || null,
191
- template_last_updated: template.last_updated || null,
192
-
193
- // Canonical contract
194
- features,
195
-
196
- // Derived compatibility field for current execution tool
197
- rules,
198
-
199
- // Supporting metadata
200
- sources_included: selectedSources.map((s) => ({
201
- id: s.id,
202
- area: s.area,
203
- name: s.name,
204
- url: s.url,
205
- priority: s.priority || null
206
- })),
207
- summary
54
+ md += `---\n\n`;
55
+ md += `## Next Steps\n\n`;
56
+ md += `1. **Review this plan** to ensure it covers the requirements you need\n`;
57
+ md += `2. **⚠️ IMPORTANT:** Do NOT proceed with execution automatically\n`;
58
+ md += `3. **When ready to execute**, explicitly reply: **"Yes, run execution"**\n`;
59
+ md += `4. **Execution will require your approval code** before scanning your source code\n`;
60
+ md += `5. **To execute**, call the \`execute\` tool with:\n`;
61
+ md += ` - \`repoPath\`: ${repoPath}\n`;
62
+ md += ` - \`planPath\`: (path to this plan's JSON file)\n\n`;
63
+
64
+ return md;
65
+ }
66
+
67
+ /**
68
+ * Main planning tool - generates plan and asks for approval to execute
69
+ */
70
+ export async function planTool({ repoPath, scope, developerPrompt, outputFormat = "markdown" }) {
71
+ // Ensure .xreadiness/ is in .gitignore
72
+ const gitignoreResult = await ensureXReadinessIgnored(repoPath);
73
+
74
+ // Generate checklist from template based on scope and developer prompt
75
+ const checklist = await generateChecklist(scope, developerPrompt);
76
+
77
+ // Create unique plan ID
78
+ const planId = `plan_${new Date().toISOString().replace(/[:.]/g, "-")}_${crypto.randomUUID().slice(0, 8)}`;
79
+ const dir = planDir(repoPath);
80
+ await ensureDir(dir);
81
+
82
+ const planJsonPath = path.join(dir, `${planId}.json`);
83
+ const planMdPath = path.join(dir, `${planId}.md`);
84
+
85
+ // Build plan object
86
+ const plan = {
87
+ plan_id: planId,
88
+ scope,
89
+ scope_description: checklist.scope_description,
90
+ developer_prompt: developerPrompt,
91
+ created_at: new Date().toISOString(),
92
+ template_version: checklist.template_version,
93
+ reference_urls: checklist.reference_urls,
94
+ categories: checklist.categories,
95
+ total_rules: checklist.total_rules
208
96
  };
209
- }
210
-
211
- function buildFeature(categoryKey, category) {
212
- const featureId = category?.id || categoryKey.toLowerCase();
213
- const featureName = category?.name || categoryKey;
214
- const guidelineUrl = category?.guideline_url || null;
215
- const priority = category?.priority || "medium";
216
97
 
217
- const expectedRules = category?.expected_rules && typeof category.expected_rules === "object"
218
- ? category.expected_rules
219
- : {};
220
-
221
- const rules = Object.entries(expectedRules)
222
- .map(([ruleNumber, ruleName]) => {
223
- const normative = deriveNormative(ruleName);
224
- return {
225
- rule_id: `${categoryKey}-${ruleNumber}`,
226
- rule_number: ruleNumber,
227
- rule_name: ruleName,
228
- reference_url: guidelineUrl,
229
- severity: priorityToSeverity(priority),
230
- normative,
231
- adoption_condition: null,
232
- evidence_hints: buildEvidenceHints(category)
233
- };
234
- })
235
- .sort((a, b) => compareRuleNumbers(a.rule_number, b.rule_number));
236
-
237
- return {
238
- feature_id: featureId,
239
- feature_name: featureName,
240
- feature_description: category?.description || null,
241
- guideline_url: guidelineUrl,
242
- priority,
243
-
244
- // Helps execution tool routing (PAGINATION, SECURITY, ...)
245
- category_key: categoryKey,
246
-
247
- rules
248
- };
249
- }
250
-
251
- function buildEvidenceHints(category) {
252
- const hints = [];
253
- if (Array.isArray(category?.applies_to)) hints.push(...category.applies_to);
254
- if (Array.isArray(category?.keywords)) hints.push(...category.keywords);
255
- return Array.from(new Set(hints.filter(Boolean).map((h) => h.toString())));
256
- }
257
-
258
- function priorityToSeverity(priority) {
259
- const p = (priority || "medium").toString().toLowerCase();
260
- if (p === "critical") return "critical";
261
- if (p === "high") return "high";
262
- if (p === "low") return "low";
263
- return "medium";
264
- }
98
+ // Write plan to JSON
99
+ await fs.writeFile(planJsonPath, JSON.stringify(plan, null, 2), "utf8");
265
100
 
266
- function deriveNormative(text) {
267
- const value = (text || "").toString();
268
- if (/\bMUST NOT\b/i.test(value)) return "MUST NOT";
269
- if (/\bSHOULD NOT\b/i.test(value)) return "SHOULD NOT";
270
- if (/\bMUST\b/i.test(value)) return "MUST";
271
- if (/\bSHOULD\b/i.test(value)) return "SHOULD";
272
- if (/\bMAY\b/i.test(value)) return "MAY";
273
- return null;
274
- }
275
-
276
- function compareRuleNumbers(a, b) {
277
- const aParts = (a || "").toString().split(".").map((x) => Number(x));
278
- const bParts = (b || "").toString().split(".").map((x) => Number(x));
279
-
280
- const max = Math.max(aParts.length, bParts.length);
281
- for (let i = 0; i < max; i++) {
282
- const av = Number.isFinite(aParts[i]) ? aParts[i] : 0;
283
- const bv = Number.isFinite(bParts[i]) ? bParts[i] : 0;
284
- if (av !== bv) return av - bv;
101
+ // Write plan to Markdown
102
+ if (outputFormat === "markdown" || outputFormat === "both") {
103
+ const md = toPlanMarkdown({ planId, repoPath, scope, developerPrompt, checklist });
104
+ await fs.writeFile(planMdPath, md, "utf8");
285
105
  }
286
- return 0;
287
- }
288
-
289
- function computeSummary(features) {
290
- const summary = {
291
- total_features: features.length,
292
- total_rules: 0,
293
- by_severity: { critical: 0, high: 0, medium: 0, low: 0 },
294
- by_normative: { MUST: 0, SHOULD: 0, MAY: 0, "MUST NOT": 0, "SHOULD NOT": 0, UNKNOWN: 0 }
295
- };
296
-
297
- for (const feature of features) {
298
- for (const rule of feature.rules) {
299
- summary.total_rules++;
300
- const sev = rule.severity || "medium";
301
- if (summary.by_severity[sev] === undefined) summary.by_severity[sev] = 0;
302
- summary.by_severity[sev]++;
303
-
304
- const norm = rule.normative || "UNKNOWN";
305
- if (summary.by_normative[norm] === undefined) summary.by_normative[norm] = 0;
306
- summary.by_normative[norm]++;
307
- }
308
- }
309
-
310
- return summary;
311
- }
312
-
313
- function formatAsMarkdown(checklist) {
314
- const lines = [];
315
- lines.push(`# X-API Readiness Checklist (${checklist.scope})`);
316
- lines.push("");
317
- lines.push(`Generated: ${checklist.generated_at}`);
318
- if (checklist.template_version) lines.push(`Template: ${checklist.template_version}`);
319
- lines.push("");
320
-
321
- for (const feature of checklist.features) {
322
- lines.push(`## ${feature.feature_name}`);
323
- if (feature.guideline_url) lines.push(`Source: ${feature.guideline_url}`);
324
- lines.push("");
325
106
 
326
- for (const rule of feature.rules) {
327
- const norm = rule.normative ? ` (${rule.normative})` : "";
328
- lines.push(`- [ ] ${rule.rule_id}${norm}: ${rule.rule_name}`);
107
+ return {
108
+ status: "PLAN_GENERATED",
109
+ plan_id: planId,
110
+ plan_json_path: planJsonPath,
111
+ plan_md_path: planMdPath,
112
+ gitignore_status: gitignoreResult,
113
+ summary: {
114
+ scope,
115
+ scope_description: checklist.scope_description,
116
+ total_rules: checklist.total_rules,
117
+ categories_count: checklist.categories.length
118
+ },
119
+ message: `✅ Plan generated successfully!\n\n📄 Plan saved to:\n- JSON: ${planJsonPath}\n- Markdown: ${planMdPath}\n\n${gitignoreResult.message}\n\n📋 This plan includes ${checklist.total_rules} rules across ${checklist.categories.length} categories.\n\n⚠️ NEXT STEPS:\n1. Review the plan carefully\n2. When ready, explicitly reply: "Yes, run execution"\n3. Do NOT proceed with execution automatically\n4. Execution will require an approval code before scanning your source code`,
120
+ instruction: "⚠️ Reply 'Yes, run execution' to proceed. Do NOT run execution without explicit user approval.",
121
+ next_step: {
122
+ tool: "execute",
123
+ parameters: {
124
+ repoPath: repoPath,
125
+ planPath: planJsonPath
126
+ },
127
+ note: "⚠️ Execution requires approval code. Wait for user confirmation before calling this tool."
329
128
  }
330
-
331
- lines.push("");
332
- }
333
-
334
- return lines.join("\n");
335
- }
336
-
337
- // Backward compatibility for local test runner
338
- export function formatChecklistAsTable(checklist) {
339
- return formatAsMarkdown(checklist);
129
+ };
340
130
  }
341
-
342
- // Default export (used by `mcp/test-planner-v2.js`)
343
- export default generateChecklist;