spec-up-t-healthcheck 1.0.0 → 1.1.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,463 @@
1
+ /**
2
+ * @fileoverview Markdown table validation health check module
3
+ *
4
+ * This module validates markdown tables in specification files, checking for:
5
+ * - Proper table structure (header, separator, body rows)
6
+ * - Consistent column counts across all rows
7
+ * - Valid separator line syntax
8
+ * - Problematic characters that can break table parsing
9
+ * - Mismatched quotes or backticks in table cells
10
+ *
11
+ * This validator catches errors that can cause issues with markdown-it-attrs
12
+ * and other markdown table parsing plugins.
13
+ *
14
+ * @author spec-up-t-healthcheck
15
+ */
16
+
17
+ import { createHealthCheckResult, createErrorResult } from '../health-check-utils.js';
18
+
19
+ /**
20
+ * The identifier for this health check, used in reports and registries.
21
+ * @type {string}
22
+ */
23
+ export const CHECK_ID = 'markdown-tables';
24
+
25
+ /**
26
+ * Human-readable name for this health check.
27
+ * @type {string}
28
+ */
29
+ export const CHECK_NAME = 'Markdown Table Validation';
30
+
31
+ /**
32
+ * Description of what this health check validates.
33
+ * @type {string}
34
+ */
35
+ export const CHECK_DESCRIPTION = 'Validates markdown table structure and syntax';
36
+
37
+ /**
38
+ * Represents a table found in a markdown file.
39
+ * @typedef {Object} TableInfo
40
+ * @property {string} file - Path to the file containing the table
41
+ * @property {number} startLine - Line number where the table starts
42
+ * @property {number} endLine - Line number where the table ends
43
+ * @property {string[]} content - Array of table lines
44
+ * @property {number} headerColumns - Number of columns in header
45
+ * @property {number} separatorColumns - Number of columns in separator
46
+ * @property {boolean} hasSeparator - Whether table has a separator line
47
+ * @property {boolean} columnMismatch - Whether column counts don't match
48
+ * @property {Object[]} issues - Array of specific issues found
49
+ */
50
+
51
+ /**
52
+ * Extracts all tables from markdown content.
53
+ *
54
+ * This function parses markdown content line by line and identifies table structures.
55
+ * Tables are detected by lines containing pipe characters (|).
56
+ *
57
+ * @param {string} content - The markdown content to parse
58
+ * @param {string} filePath - Path to the file (for error reporting)
59
+ * @returns {TableInfo[]} Array of table information objects
60
+ */
61
+ function extractTables(content, filePath) {
62
+ const lines = content.split('\n');
63
+ const tables = [];
64
+ let inTable = false;
65
+ let tableStartLine = 0;
66
+ let tableContent = [];
67
+
68
+ for (let i = 0; i < lines.length; i++) {
69
+ const line = lines[i];
70
+ const trimmedLine = line.trim();
71
+
72
+ // Detect table start (line with pipes)
73
+ if (trimmedLine.match(/^\|.*\|/)) {
74
+ if (!inTable) {
75
+ inTable = true;
76
+ tableStartLine = i + 1; // 1-indexed for user display
77
+ tableContent = [];
78
+ }
79
+ tableContent.push({ lineNum: i + 1, content: line });
80
+ } else if (inTable && (trimmedLine === '' || !line.includes('|'))) {
81
+ // Table ended - analyze and store it
82
+ if (tableContent.length > 0) {
83
+ const tableInfo = analyzeTable(tableContent, tableStartLine, filePath);
84
+ tables.push(tableInfo);
85
+ }
86
+ inTable = false;
87
+ } else if (inTable && line.includes('|')) {
88
+ // Continuation of table (might be in code block or other context)
89
+ tableContent.push({ lineNum: i + 1, content: line });
90
+ }
91
+ }
92
+
93
+ // Handle table at end of file
94
+ if (inTable && tableContent.length > 0) {
95
+ const tableInfo = analyzeTable(tableContent, tableStartLine, filePath);
96
+ tables.push(tableInfo);
97
+ }
98
+
99
+ return tables;
100
+ }
101
+
102
+ /**
103
+ * Analyzes a single table for structural issues.
104
+ *
105
+ * This function performs comprehensive validation of table structure:
106
+ * - Counts columns in each row
107
+ * - Validates separator line format
108
+ * - Checks for problematic characters
109
+ * - Detects mismatched quotes and backticks
110
+ *
111
+ * @param {Array<{lineNum: number, content: string}>} tableLines - Array of table lines
112
+ * @param {number} startLine - Starting line number
113
+ * @param {string} filePath - Path to the file
114
+ * @returns {TableInfo} Analyzed table information
115
+ */
116
+ function analyzeTable(tableLines, startLine, filePath) {
117
+ const issues = [];
118
+
119
+ // Extract header and separator
120
+ const headerLine = tableLines[0];
121
+ const separatorLine = tableLines.length > 1 ? tableLines[1] : null;
122
+
123
+ // Count columns
124
+ const headerColumns = countColumns(headerLine.content);
125
+ const separatorColumns = separatorLine ? countColumns(separatorLine.content) : 0;
126
+ const hasSeparator = separatorLine && isSeparatorLine(separatorLine.content);
127
+ const columnMismatch = hasSeparator && (headerColumns !== separatorColumns);
128
+
129
+ // Check each row for issues
130
+ tableLines.forEach((line, idx) => {
131
+ const rowIssues = validateTableRow(line.content, line.lineNum, idx === 1);
132
+ issues.push(...rowIssues);
133
+ });
134
+
135
+ // Validate separator line specifically
136
+ if (separatorLine && !hasSeparator) {
137
+ issues.push({
138
+ type: 'missing-separator',
139
+ line: separatorLine.lineNum,
140
+ message: 'Table separator line is missing or malformed (should contain only |, -, and :)',
141
+ content: separatorLine.content
142
+ });
143
+ }
144
+
145
+ // Check for column count mismatches
146
+ if (columnMismatch) {
147
+ issues.push({
148
+ type: 'column-mismatch',
149
+ line: startLine,
150
+ message: `Column count mismatch: header has ${headerColumns} columns, separator has ${separatorColumns}`,
151
+ content: `Header: ${headerLine.content}\nSeparator: ${separatorLine.content}`
152
+ });
153
+ }
154
+
155
+ // Validate body row column counts
156
+ if (hasSeparator && tableLines.length > 2) {
157
+ const expectedColumns = headerColumns;
158
+ for (let i = 2; i < tableLines.length; i++) {
159
+ const rowColumns = countColumns(tableLines[i].content);
160
+ if (rowColumns !== expectedColumns) {
161
+ issues.push({
162
+ type: 'row-column-mismatch',
163
+ line: tableLines[i].lineNum,
164
+ message: `Row has ${rowColumns} columns, expected ${expectedColumns}`,
165
+ content: tableLines[i].content
166
+ });
167
+ }
168
+ }
169
+ }
170
+
171
+ return {
172
+ file: filePath,
173
+ startLine,
174
+ endLine: tableLines[tableLines.length - 1].lineNum,
175
+ content: tableLines.map(l => l.content),
176
+ headerColumns,
177
+ separatorColumns,
178
+ hasSeparator,
179
+ columnMismatch,
180
+ issues
181
+ };
182
+ }
183
+
184
+ /**
185
+ * Counts the number of columns in a table row.
186
+ *
187
+ * Columns are delimited by pipe characters (|). The count excludes
188
+ * leading and trailing pipes.
189
+ *
190
+ * @param {string} line - The table row line
191
+ * @returns {number} Number of columns
192
+ */
193
+ function countColumns(line) {
194
+ // Remove leading/trailing whitespace and pipes
195
+ const trimmed = line.trim();
196
+ if (!trimmed) return 0;
197
+
198
+ // Count pipe separators (excluding leading/trailing)
199
+ const withoutEnds = trimmed.replace(/^\|/, '').replace(/\|$/, '');
200
+ const pipes = (withoutEnds.match(/\|/g) || []).length;
201
+
202
+ return pipes + 1;
203
+ }
204
+
205
+ /**
206
+ * Checks if a line is a valid table separator.
207
+ *
208
+ * A valid separator line contains only pipes, hyphens, colons, and whitespace.
209
+ * Format: |:---:|:---:| or |---|---| etc.
210
+ *
211
+ * @param {string} line - The line to check
212
+ * @returns {boolean} True if the line is a valid separator
213
+ */
214
+ function isSeparatorLine(line) {
215
+ const trimmed = line.trim();
216
+ // Valid separator contains only: |, -, :, and whitespace
217
+ return /^[\s|:\-]+$/.test(trimmed) && trimmed.includes('-');
218
+ }
219
+
220
+ /**
221
+ * Validates a single table row for common issues.
222
+ *
223
+ * This function checks for:
224
+ * - Mismatched quotes (opening quote without closing)
225
+ * - Mismatched backticks
226
+ * - Problematic character combinations
227
+ * - Extra quotes inside code spans
228
+ *
229
+ * @param {string} line - The table row line
230
+ * @param {number} lineNum - Line number in file
231
+ * @param {boolean} isSeparator - Whether this is the separator row
232
+ * @returns {Array<Object>} Array of issues found
233
+ */
234
+ function validateTableRow(line, lineNum, isSeparator) {
235
+ const issues = [];
236
+
237
+ // Skip validation for separator lines
238
+ if (isSeparator) {
239
+ return issues;
240
+ }
241
+
242
+ // Split line into cells
243
+ const cells = line.split('|').slice(1, -1); // Remove first/last empty elements
244
+
245
+ cells.forEach((cell, cellIdx) => {
246
+ const trimmedCell = cell.trim();
247
+
248
+ // Check for problematic quote patterns in code spans
249
+ // Pattern: `'text or `text'
250
+ const backtickQuotePattern = /`['"]|['"]`/g;
251
+ const backtickWithQuote = trimmedCell.match(backtickQuotePattern);
252
+ if (backtickWithQuote) {
253
+ issues.push({
254
+ type: 'quote-in-code',
255
+ line: lineNum,
256
+ cell: cellIdx + 1,
257
+ message: `Cell ${cellIdx + 1} contains potentially problematic quote/backtick combination: ${backtickWithQuote.join(', ')}`,
258
+ content: cell,
259
+ severity: 'warning'
260
+ });
261
+ }
262
+
263
+ // Check for mismatched backticks in cell
264
+ const backticks = (trimmedCell.match(/`/g) || []).length;
265
+ if (backticks % 2 !== 0) {
266
+ issues.push({
267
+ type: 'mismatched-backticks',
268
+ line: lineNum,
269
+ cell: cellIdx + 1,
270
+ message: `Cell ${cellIdx + 1} has mismatched backticks`,
271
+ content: cell,
272
+ severity: 'error'
273
+ });
274
+ }
275
+
276
+ // Check for quote patterns that might indicate typos
277
+ // Pattern: opening quote at start of backtick span
278
+ const codeSpanStartQuote = /`'[^'`]*'?`/g;
279
+ const matches = trimmedCell.match(codeSpanStartQuote);
280
+ if (matches) {
281
+ // Check if any match has quotes both at start AND end inside backticks
282
+ matches.forEach(match => {
283
+ if (match.match(/`'.*'`/)) {
284
+ // This is likely intentional (quoted string in code)
285
+ return;
286
+ }
287
+ // Only opening quote inside backticks - likely error
288
+ if (match.match(/`'[^']*`$/)) {
289
+ issues.push({
290
+ type: 'likely-typo',
291
+ line: lineNum,
292
+ cell: cellIdx + 1,
293
+ message: `Cell ${cellIdx + 1} has likely typo: opening quote inside backticks without matching closing quote`,
294
+ content: cell,
295
+ example: match,
296
+ severity: 'error'
297
+ });
298
+ }
299
+ });
300
+ }
301
+
302
+ // Check for attributes syntax {...} which requires careful table structure
303
+ if (trimmedCell.includes('{') && trimmedCell.includes('}')) {
304
+ const hasAttributes = /\{[.#][^\}]+\}/.test(trimmedCell);
305
+ if (hasAttributes) {
306
+ issues.push({
307
+ type: 'has-attributes',
308
+ line: lineNum,
309
+ cell: cellIdx + 1,
310
+ message: `Cell ${cellIdx + 1} contains attribute syntax {.class} or {#id} - ensure table structure is correct`,
311
+ content: cell,
312
+ severity: 'info'
313
+ });
314
+ }
315
+ }
316
+ });
317
+
318
+ return issues;
319
+ }
320
+
321
+ /**
322
+ * Checks markdown tables in specification files.
323
+ *
324
+ * This health check validates all markdown tables found in specification files,
325
+ * ensuring proper structure and catching common errors that can cause parsing issues.
326
+ *
327
+ * The check performs the following validations:
328
+ * - Table header and separator presence
329
+ * - Consistent column counts across rows
330
+ * - Valid separator line syntax
331
+ * - Detection of problematic characters
332
+ * - Validation of quotes and backticks in cells
333
+ *
334
+ * @param {import('../providers.js').Provider} provider - The provider instance for file operations
335
+ * @returns {Promise<import('../health-check-utils.js').HealthCheckResult>} The health check result
336
+ *
337
+ * @example
338
+ * ```javascript
339
+ * const provider = createLocalProvider('/path/to/repo');
340
+ * const result = await checkMarkdownTables(provider);
341
+ * console.log(result.status); // 'pass', 'warning', or 'error'
342
+ * ```
343
+ */
344
+ export async function checkMarkdownTables(provider) {
345
+ try {
346
+ // Discover specification files
347
+ const specFiles = await discoverSpecificationFiles(provider);
348
+
349
+ if (specFiles.length === 0) {
350
+ return createHealthCheckResult(
351
+ CHECK_ID,
352
+ 'warn',
353
+ 'No specification files found to validate tables',
354
+ {
355
+ filesChecked: 0,
356
+ tablesFound: 0,
357
+ tablesWithIssues: 0
358
+ }
359
+ );
360
+ }
361
+
362
+ let totalTables = 0;
363
+ let tablesWithIssues = 0;
364
+ let totalIssues = 0;
365
+ const fileResults = [];
366
+
367
+ // Check each file for tables
368
+ for (const filePath of specFiles) {
369
+ try {
370
+ const content = await provider.readFile(filePath);
371
+ const tables = extractTables(content, filePath);
372
+
373
+ totalTables += tables.length;
374
+
375
+ // Analyze tables for issues
376
+ const tablesWithProblems = tables.filter(t => t.issues.length > 0);
377
+ tablesWithIssues += tablesWithProblems.length;
378
+ totalIssues += tablesWithProblems.reduce((sum, t) => sum + t.issues.length, 0);
379
+
380
+ if (tablesWithProblems.length > 0) {
381
+ fileResults.push({
382
+ file: filePath,
383
+ tablesCount: tables.length,
384
+ tablesWithIssues: tablesWithProblems.length,
385
+ tables: tablesWithProblems
386
+ });
387
+ }
388
+ } catch (error) {
389
+ console.warn(`Could not read file ${filePath}:`, error.message);
390
+ }
391
+ }
392
+
393
+ // Determine status
394
+ let status = 'pass';
395
+ let message = `All ${totalTables} tables are valid`;
396
+
397
+ if (tablesWithIssues > 0) {
398
+ // Check if any issues are errors vs warnings
399
+ const hasErrors = fileResults.some(fr =>
400
+ fr.tables.some(t =>
401
+ t.issues.some(i => i.severity === 'error' || i.type === 'column-mismatch' || i.type === 'missing-separator')
402
+ )
403
+ );
404
+
405
+ status = hasErrors ? 'fail' : 'warn';
406
+ message = `Found ${totalIssues} issue(s) in ${tablesWithIssues} of ${totalTables} tables`;
407
+ }
408
+
409
+ return createHealthCheckResult(
410
+ CHECK_ID,
411
+ status,
412
+ message,
413
+ {
414
+ filesChecked: specFiles.length,
415
+ tablesFound: totalTables,
416
+ tablesWithIssues,
417
+ totalIssues,
418
+ details: fileResults
419
+ }
420
+ );
421
+
422
+ } catch (error) {
423
+ return createErrorResult(
424
+ CHECK_ID,
425
+ `Failed to validate markdown tables: ${error.message}`,
426
+ { error: error.message, stack: error.stack }
427
+ );
428
+ }
429
+ }
430
+
431
+ /**
432
+ * Discovers specification files to check for tables.
433
+ *
434
+ * This function searches for markdown files in common spec directories
435
+ * and the repository root.
436
+ *
437
+ * @param {import('../providers.js').Provider} provider - The provider instance
438
+ * @returns {Promise<string[]>} Array of file paths to check
439
+ * @private
440
+ */
441
+ async function discoverSpecificationFiles(provider) {
442
+ const files = [];
443
+ const searchPaths = ['spec/', 'specs/', 'docs/', ''];
444
+
445
+ for (const searchPath of searchPaths) {
446
+ try {
447
+ const entries = await provider.listFiles(searchPath);
448
+
449
+ // Filter for markdown files only (not subdirectories)
450
+ const mdFiles = entries.filter(entry =>
451
+ entry.isFile &&
452
+ (entry.name.endsWith('.md') || entry.name.endsWith('.markdown'))
453
+ );
454
+
455
+ // Use the full path from the entry
456
+ files.push(...mdFiles.map(entry => entry.path));
457
+ } catch (error) {
458
+ // Directory doesn't exist or can't be read, continue
459
+ }
460
+ }
461
+
462
+ return [...new Set(files)]; // Remove duplicates
463
+ }
@@ -24,7 +24,7 @@ export const CHECK_ID = 'package-json';
24
24
  * Human-readable name for this health check.
25
25
  * @type {string}
26
26
  */
27
- export const CHECK_NAME = 'Package.json Validation';
27
+ export const CHECK_NAME = 'package.json';
28
28
 
29
29
  /**
30
30
  * Description of what this health check validates.