project-graph-mcp 1.3.0 → 1.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.
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Test Annotations Parser
3
- * Extracts @test/@expect JSDoc annotations for browser testing
2
+ * Test Checklists — .ctx.md based
3
+ * Reads/writes test checklists from ## Tests sections in .ctx.md files
4
4
  */
5
5
 
6
6
  import { readFileSync, readdirSync, statSync, writeFileSync } from 'fs';
@@ -8,143 +8,151 @@ import { join, basename, relative, resolve } from 'path';
8
8
 
9
9
  /**
10
10
  * @typedef {Object} TestStep
11
- * @property {string} id - Unique ID (e.g., "togglePin.1")
12
- * @property {string} type - Action type (click, key, drag, etc.)
13
- * @property {string} description - What to do
14
- * @property {boolean} completed - Whether test passed
15
- * @property {string} [failReason] - Why it failed (if failed)
11
+ * @property {string} id - Unique ID (e.g., "togglePin.0")
12
+ * @property {string} action - What to do
13
+ * @property {string} [expected] - Expected result (after →)
14
+ * @property {string} status - 'pending' | 'passed' | 'failed'
15
+ * @property {string} [failReason] - Why it failed
16
16
  */
17
17
 
18
18
  /**
19
19
  * @typedef {Object} Feature
20
- * @property {string} name - Method name
21
- * @property {string} description - What the method does
20
+ * @property {string} name - Function/method name
22
21
  * @property {TestStep[]} tests - Test steps
23
- * @property {Array<{type: string, description: string}>} expects - Expected outcomes
24
- * @property {string} file - Source file
25
- * @property {number} line - Line number
22
+ * @property {string} file - Source .ctx.md file path
26
23
  */
27
24
 
28
- // In-memory state for test progress
29
- const testState = new Map();
25
+ /**
26
+ * Find all .ctx.md files in .context/ directory
27
+ * @param {string} dir - Context directory path
28
+ * @returns {string[]}
29
+ */
30
+ function findCtxMdFiles(dir) {
31
+ const files = [];
32
+ try {
33
+ for (const entry of readdirSync(dir)) {
34
+ const fullPath = join(dir, entry);
35
+ const stat = statSync(fullPath);
36
+ if (stat.isDirectory() && !entry.startsWith('.')) {
37
+ files.push(...findCtxMdFiles(fullPath));
38
+ } else if (entry.endsWith('.ctx.md')) {
39
+ files.push(fullPath);
40
+ }
41
+ }
42
+ } catch (e) {
43
+ // Directory not found
44
+ }
45
+ return files;
46
+ }
30
47
 
31
48
  /**
32
- * Parse @test/@expect annotations from a file
33
- * @param {string} content
34
- * @param {string} filePath
49
+ * Parse ## Tests section from a .ctx.md file
50
+ * @param {string} content - File content
51
+ * @param {string} filePath - Path to .ctx.md file
35
52
  * @returns {Feature[]}
36
53
  */
37
54
  export function parseAnnotations(content, filePath) {
38
- const results = [];
39
- const blockRegex = /\/\*\*([^]*?)\*\//g;
40
-
41
- let match;
42
- while ((match = blockRegex.exec(content)) !== null) {
43
- const block = match[1];
44
-
45
- // Check if block has @test or @expect
46
- if (!block.includes('@test') && !block.includes('@expect')) continue;
47
-
48
- // Find method name after the block
49
- // Supports: methodName( | propName: ( | propName: async (
50
- const afterBlock = content.slice(match.index + match[0].length);
51
- const methodMatch = afterBlock.match(
52
- /^\s*(?:async\s+)?(\w+)\s*\(/ // class method: methodName(
53
- ) || afterBlock.match(
54
- /^\s*(\w+)\s*:\s*(?:async\s*)?\(/ // arrow in object: propName: (
55
- ) || afterBlock.match(
56
- /^\s*(\w+)\s*:\s*(?:async\s+)?\(/ // arrow in object: propName: async (
57
- );
58
- if (!methodMatch) continue;
59
-
60
- const methodName = methodMatch[1];
61
-
62
- // Extract description (first line)
63
- const descMatch = block.match(/^\s*\*\s*([^@\n][^\n]*)/m);
64
- const description = descMatch ? descMatch[1].trim() : methodName;
65
-
66
- // Extract @test annotations with unique IDs
67
- const tests = [];
68
- const testRegex = /@test\s+(\w+):\s*(.+)/g;
69
- let testMatch;
70
- let testIndex = 0;
71
- while ((testMatch = testRegex.exec(block)) !== null) {
72
- tests.push({
73
- id: `${methodName}.${testIndex++}`,
74
- type: testMatch[1],
75
- description: testMatch[2].trim(),
76
- completed: false,
77
- failReason: null,
78
- });
79
- }
55
+ const lines = content.split('\n');
56
+ const features = [];
80
57
 
81
- // Extract @expect annotations
82
- const expects = [];
83
- const expectRegex = /@expect\s+(\w+):\s*(.+)/g;
84
- let expectMatch;
85
- while ((expectMatch = expectRegex.exec(block)) !== null) {
86
- expects.push({
87
- type: expectMatch[1],
88
- description: expectMatch[2].trim(),
89
- });
58
+ // Find ## Tests section
59
+ let inTests = false;
60
+ let currentTests = [];
61
+
62
+ for (const line of lines) {
63
+ // Detect section headers
64
+ if (line.startsWith('## ')) {
65
+ if (inTests && currentTests.length) {
66
+ // End of Tests section — flush
67
+ features.push(...groupByName(currentTests, filePath));
68
+ currentTests = [];
69
+ }
70
+ inTests = line.startsWith('## Tests');
71
+ continue;
90
72
  }
91
73
 
92
- if (tests.length || expects.length) {
93
- const lineNumber = content.slice(0, match.index).split('\n').length;
94
-
95
- results.push({
96
- name: methodName,
97
- description,
98
- tests,
99
- expects,
100
- file: filePath,
101
- line: lineNumber,
102
- });
74
+ if (!inTests) continue;
75
+
76
+ // Parse checklist lines: - [ ] name: action → expected
77
+ // States: [ ] = pending, [x] = passed, [!] = failed
78
+ const match = line.match(/^- \[([ x!])\] (\w+):\s*(.+)$/);
79
+ if (!match) continue;
80
+
81
+ const [, state, name, rest] = match;
82
+ const parts = rest.split('→').map(s => s.trim());
83
+ const action = parts[0];
84
+ const expected = parts[1] || null;
85
+
86
+ // Extract fail reason from: (FAILED: reason)
87
+ let failReason = null;
88
+ let status = 'pending';
89
+ if (state === 'x') status = 'passed';
90
+ if (state === '!') {
91
+ status = 'failed';
92
+ const failMatch = action.match(/\(FAILED:\s*(.+)\)$/);
93
+ if (failMatch) failReason = failMatch[1].trim();
103
94
  }
95
+
96
+ currentTests.push({ name, action, expected, status, failReason });
104
97
  }
105
98
 
106
- return results;
99
+ // Flush remaining
100
+ if (inTests && currentTests.length) {
101
+ features.push(...groupByName(currentTests, filePath));
102
+ }
103
+
104
+ return features;
107
105
  }
108
106
 
109
107
  /**
110
- * Find all JS files in directory
111
- * @param {string} dir
112
- * @returns {string[]}
108
+ * Group test steps by function name into Feature objects
109
+ * @param {Array} tests - Raw test entries
110
+ * @param {string} filePath - Source file
111
+ * @returns {Feature[]}
113
112
  */
114
- function findJSFiles(dir) {
115
- const files = [];
116
-
117
- try {
118
- for (const entry of readdirSync(dir)) {
119
- const fullPath = join(dir, entry);
120
- const stat = statSync(fullPath);
121
-
122
- if (stat.isDirectory() && !entry.startsWith('.') && entry !== 'node_modules') {
123
- files.push(...findJSFiles(fullPath));
124
- } else if (entry.endsWith('.js') && !entry.endsWith('.css.js') && !entry.endsWith('.tpl.js')) {
125
- files.push(fullPath);
126
- }
113
+ function groupByName(tests, filePath) {
114
+ const map = {};
115
+ let indexMap = {};
116
+
117
+ for (const t of tests) {
118
+ if (!map[t.name]) {
119
+ map[t.name] = [];
120
+ indexMap[t.name] = 0;
127
121
  }
128
- } catch (e) {
129
- // Directory not found
122
+ map[t.name].push({
123
+ id: `${t.name}.${indexMap[t.name]++}`,
124
+ action: t.action,
125
+ expected: t.expected,
126
+ status: t.status,
127
+ failReason: t.failReason,
128
+ });
130
129
  }
131
130
 
132
- return files;
131
+ return Object.entries(map).map(([name, tests]) => ({
132
+ name,
133
+ tests,
134
+ file: filePath,
135
+ }));
133
136
  }
134
137
 
135
138
  /**
136
- * Get all features from a directory
137
- * @param {string} dir
139
+ * Get all features from a project directory
140
+ * @param {string} dir - Project root
138
141
  * @returns {Feature[]}
139
142
  */
140
143
  export function getAllFeatures(dir) {
141
- const files = findJSFiles(dir);
144
+ const contextDir = join(resolve(dir), '.context');
145
+ const files = findCtxMdFiles(contextDir);
142
146
  const features = [];
143
147
 
144
148
  for (const file of files) {
145
- const content = readFileSync(file, 'utf-8');
146
- const parsed = parseAnnotations(content, file);
147
- features.push(...parsed);
149
+ try {
150
+ const content = readFileSync(file, 'utf-8');
151
+ const parsed = parseAnnotations(content, file);
152
+ features.push(...parsed);
153
+ } catch (e) {
154
+ // Skip unreadable files
155
+ }
148
156
  }
149
157
 
150
158
  return features;
@@ -152,7 +160,7 @@ export function getAllFeatures(dir) {
152
160
 
153
161
  /**
154
162
  * Get pending (uncompleted) tests
155
- * @param {string} dir
163
+ * @param {string} dir - Project root
156
164
  * @returns {TestStep[]}
157
165
  */
158
166
  export function getPendingTests(dir) {
@@ -162,8 +170,7 @@ export function getPendingTests(dir) {
162
170
 
163
171
  for (const feature of features) {
164
172
  for (const test of feature.tests) {
165
- const state = testState.get(test.id);
166
- if (!state || !state.completed) {
173
+ if (test.status === 'pending') {
167
174
  pending.push({
168
175
  ...test,
169
176
  feature: feature.name,
@@ -177,27 +184,81 @@ export function getPendingTests(dir) {
177
184
  }
178
185
 
179
186
  /**
180
- * Mark a test as passed
181
- * @param {string} testId
187
+ * Mark a test as passed — writes directly to .ctx.md file
188
+ * @param {string} testId - e.g. "togglePin.0"
189
+ * @returns {{success: boolean, testId: string}}
182
190
  */
183
191
  export function markTestPassed(testId) {
184
- testState.set(testId, { completed: true, passed: true });
185
- return { success: true, testId };
192
+ const name = testId.split('.')[0];
193
+ return updateTestState(name, testId, 'x');
186
194
  }
187
195
 
188
196
  /**
189
- * Mark a test as failed
190
- * @param {string} testId
191
- * @param {string} reason
197
+ * Mark a test as failed — writes directly to .ctx.md file
198
+ * @param {string} testId - e.g. "togglePin.0"
199
+ * @param {string} reason - Why it failed
200
+ * @returns {{success: boolean, testId: string, reason: string}}
192
201
  */
193
202
  export function markTestFailed(testId, reason) {
194
- testState.set(testId, { completed: true, passed: false, reason });
195
- return { success: true, testId, reason };
203
+ const name = testId.split('.')[0];
204
+ return updateTestState(name, testId, '!', reason);
196
205
  }
197
206
 
198
207
  /**
199
- * Get test summary
200
- * @param {string} dir
208
+ * Update test state in .ctx.md files
209
+ * @param {string} name - Function name
210
+ * @param {string} testId - Full test ID
211
+ * @param {string} newState - 'x' | '!' | ' '
212
+ * @param {string} [reason] - Failure reason
213
+ * @returns {{success: boolean, testId: string, reason?: string}}
214
+ */
215
+ function updateTestState(name, testId, newState, reason) {
216
+ // Need to find which .ctx.md file contains this test
217
+ // Walk all .ctx.md files in .context/
218
+ const cwd = process.cwd();
219
+ const contextDir = join(cwd, '.context');
220
+ const files = findCtxMdFiles(contextDir);
221
+ const testIndex = parseInt(testId.split('.')[1], 10);
222
+
223
+ for (const file of files) {
224
+ try {
225
+ const content = readFileSync(file, 'utf-8');
226
+ const lines = content.split('\n');
227
+ let inTests = false;
228
+ let nameIndex = 0;
229
+
230
+ for (let i = 0; i < lines.length; i++) {
231
+ if (lines[i].startsWith('## ')) {
232
+ inTests = lines[i].startsWith('## Tests');
233
+ continue;
234
+ }
235
+ if (!inTests) continue;
236
+
237
+ const match = lines[i].match(/^- \[([ x!])\] (\w+):\s*(.+)$/);
238
+ if (!match) continue;
239
+ if (match[2] !== name) continue;
240
+
241
+ if (nameIndex === testIndex) {
242
+ // Found the line — update it
243
+ const desc = match[3].replace(/\s*\(FAILED:.*\)$/, '');
244
+ const suffix = reason ? ` (FAILED: ${reason})` : '';
245
+ lines[i] = `- [${newState}] ${name}: ${desc}${suffix}`;
246
+ writeFileSync(file, lines.join('\n'), 'utf-8');
247
+ return { success: true, testId, ...(reason ? { reason } : {}) };
248
+ }
249
+ nameIndex++;
250
+ }
251
+ } catch (e) {
252
+ // Skip
253
+ }
254
+ }
255
+
256
+ return { success: false, testId, error: 'Test not found' };
257
+ }
258
+
259
+ /**
260
+ * Get test summary across all .ctx.md files
261
+ * @param {string} dir - Project root
201
262
  * @returns {Object}
202
263
  */
203
264
  export function getTestSummary(dir) {
@@ -212,18 +273,13 @@ export function getTestSummary(dir) {
212
273
  for (const feature of features) {
213
274
  for (const test of feature.tests) {
214
275
  total++;
215
- const state = testState.get(test.id);
216
-
217
- if (!state || !state.completed) {
218
- pending++;
219
- } else if (state.passed) {
276
+ if (test.status === 'passed') {
220
277
  passed++;
221
- } else {
278
+ } else if (test.status === 'failed') {
222
279
  failed++;
223
- failures.push({
224
- id: test.id,
225
- reason: state.reason,
226
- });
280
+ failures.push({ id: test.id, reason: test.failReason });
281
+ } else {
282
+ pending++;
227
283
  }
228
284
  }
229
285
  }
@@ -239,63 +295,29 @@ export function getTestSummary(dir) {
239
295
  }
240
296
 
241
297
  /**
242
- * Reset test state
298
+ * Reset all test states — changes [x] and [!] back to [ ] in all .ctx.md files
299
+ * @returns {{success: boolean}}
243
300
  */
244
301
  export function resetTestState() {
245
- testState.clear();
246
- return { success: true };
247
- }
302
+ const cwd = process.cwd();
303
+ const contextDir = join(cwd, '.context');
304
+ const files = findCtxMdFiles(contextDir);
248
305
 
249
- /**
250
- * Generate markdown checklist
251
- * @param {Feature[]} features
252
- * @returns {string}
253
- */
254
- export function generateMarkdown(features) {
255
- const lines = [
256
- '# Browser Test Checklist',
257
- '',
258
- `> Auto-generated from JSDoc @test/@expect annotations`,
259
- `> Generated: ${new Date().toISOString().split('T')[0]}`,
260
- '',
261
- ];
262
-
263
- // Group by file
264
- const byFile = {};
265
- for (const feature of features) {
266
- const key = feature.file;
267
- if (!byFile[key]) byFile[key] = [];
268
- byFile[key].push(feature);
269
- }
270
-
271
- for (const [file, fileFeatures] of Object.entries(byFile)) {
272
- lines.push(`## ${basename(file, '.js')}`);
273
- lines.push('');
274
-
275
- for (const feature of fileFeatures) {
276
- lines.push(`### ${feature.name}()`);
277
- lines.push(`${feature.description}`);
278
- lines.push('');
279
-
280
- if (feature.tests.length) {
281
- lines.push('**Steps:**');
282
- for (const test of feature.tests) {
283
- const state = testState.get(test.id);
284
- const check = state?.passed ? '[x]' : '[ ]';
285
- lines.push(`- ${check} \`${test.type}\`: ${test.description}`);
286
- }
287
- lines.push('');
288
- }
289
-
290
- if (feature.expects.length) {
291
- lines.push('**Expected:**');
292
- for (const expect of feature.expects) {
293
- lines.push(`- ✅ \`${expect.type}\`: ${expect.description}`);
294
- }
295
- lines.push('');
306
+ for (const file of files) {
307
+ try {
308
+ let content = readFileSync(file, 'utf-8');
309
+ // Replace [x] and [!] with [ ] in test lines, remove FAILED reasons
310
+ const updated = content.replace(
311
+ /^(- )\[([x!])\] (\w+:\s*.+?)(?:\s*\(FAILED:.*\))?$/gm,
312
+ '$1[ ] $3'
313
+ );
314
+ if (updated !== content) {
315
+ writeFileSync(file, updated, 'utf-8');
296
316
  }
317
+ } catch (e) {
318
+ // Skip
297
319
  }
298
320
  }
299
321
 
300
- return lines.join('\n');
322
+ return { success: true };
301
323
  }