spec-up-t-healthcheck 1.0.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,484 @@
1
+ /**
2
+ * @fileoverview External Specs URL Validator
3
+ *
4
+ * This module validates external specification references in specs.json,
5
+ * specifically checking the structure and accessibility of URLs.
6
+ * It verifies both gh_page and url fields exist, have correct formats,
7
+ * and return HTTP 200 responses.
8
+ *
9
+ * @author spec-up-t-healthcheck
10
+ */
11
+
12
+ import axios from 'axios';
13
+ import { createHealthCheckResult, createErrorResult } from '../health-check-utils.js';
14
+
15
+ /**
16
+ * The identifier for this health check
17
+ * @type {string}
18
+ */
19
+ export const CHECK_ID = 'external-specs-urls';
20
+
21
+ /**
22
+ * Human-readable name for this health check
23
+ * @type {string}
24
+ */
25
+ export const CHECK_NAME = 'External Specs URL Validation';
26
+
27
+ /**
28
+ * Description of what this health check validates
29
+ * @type {string}
30
+ */
31
+ export const CHECK_DESCRIPTION = 'Validates external specification URLs exist, have correct structure, and are accessible';
32
+
33
+ /**
34
+ * Timeout for HTTP requests in milliseconds
35
+ * @type {number}
36
+ */
37
+ const HTTP_TIMEOUT = 10000;
38
+
39
+ /**
40
+ * Maximum number of redirects to follow
41
+ * @type {number}
42
+ */
43
+ const MAX_REDIRECTS = 5;
44
+
45
+ /**
46
+ * Validates that a string is a properly formatted URL
47
+ *
48
+ * @param {string} urlString - The URL string to validate
49
+ * @returns {{isValid: boolean, message?: string}} Validation result
50
+ */
51
+ function validateUrlStructure(urlString) {
52
+ if (!urlString || typeof urlString !== 'string') {
53
+ return { isValid: false, message: 'URL is missing or not a string' };
54
+ }
55
+
56
+ if (urlString.trim() === '') {
57
+ return { isValid: false, message: 'URL is empty' };
58
+ }
59
+
60
+ try {
61
+ const url = new URL(urlString);
62
+
63
+ // Check protocol
64
+ if (!['http:', 'https:'].includes(url.protocol)) {
65
+ return {
66
+ isValid: false,
67
+ message: `URL must use http or https protocol, found: ${url.protocol}`
68
+ };
69
+ }
70
+
71
+ // Check for hostname
72
+ if (!url.hostname) {
73
+ return { isValid: false, message: 'URL must have a hostname' };
74
+ }
75
+
76
+ return { isValid: true };
77
+ } catch (error) {
78
+ return {
79
+ isValid: false,
80
+ message: `Invalid URL format: ${error.message}`
81
+ };
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Validates that a URL matches GitHub Pages URL pattern
87
+ *
88
+ * @param {string} urlString - The URL string to validate
89
+ * @returns {{isValid: boolean, message?: string}} Validation result
90
+ */
91
+ function validateGitHubPagesStructure(urlString) {
92
+ const structureCheck = validateUrlStructure(urlString);
93
+ if (!structureCheck.isValid) {
94
+ return structureCheck;
95
+ }
96
+
97
+ try {
98
+ const url = new URL(urlString);
99
+ const hostname = url.hostname.toLowerCase();
100
+
101
+ // Check if it's a GitHub Pages URL
102
+ if (hostname.endsWith('.github.io')) {
103
+ return { isValid: true };
104
+ }
105
+
106
+ // Custom domain could also be used for GitHub Pages
107
+ // We'll accept any valid URL but note it's not standard GitHub Pages
108
+ return {
109
+ isValid: true,
110
+ message: `Valid URL (${urlString}) but not a standard GitHub Pages domain (.github.io)`
111
+ };
112
+ } catch (error) {
113
+ return {
114
+ isValid: false,
115
+ message: `Error validating GitHub Pages URL: ${error.message}`
116
+ };
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Validates that a URL matches GitHub repository URL pattern
122
+ *
123
+ * @param {string} urlString - The URL string to validate
124
+ * @returns {{isValid: boolean, message?: string}} Validation result
125
+ */
126
+ function validateGitHubRepoStructure(urlString) {
127
+ const structureCheck = validateUrlStructure(urlString);
128
+ if (!structureCheck.isValid) {
129
+ return structureCheck;
130
+ }
131
+
132
+ try {
133
+ const url = new URL(urlString);
134
+ const hostname = url.hostname.toLowerCase();
135
+
136
+ // Check if it's a GitHub URL
137
+ if (hostname === 'github.com' || hostname === 'www.github.com') {
138
+ // Check basic path structure (should have at least /owner/repo)
139
+ const pathParts = url.pathname.split('/').filter(part => part.length > 0);
140
+
141
+ if (pathParts.length < 2) {
142
+ return {
143
+ isValid: false,
144
+ message: 'GitHub URL should have format: https://github.com/{owner}/{repo}'
145
+ };
146
+ }
147
+
148
+ return { isValid: true };
149
+ }
150
+
151
+ return {
152
+ isValid: false,
153
+ message: 'URL is not a GitHub repository URL (should be github.com)'
154
+ };
155
+ } catch (error) {
156
+ return {
157
+ isValid: false,
158
+ message: `Error validating GitHub repository URL: ${error.message}`
159
+ };
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Checks if a URL is accessible and returns HTTP 200
165
+ *
166
+ * @param {string} url - The URL to check
167
+ * @param {string} fieldName - Name of the field being checked (for error messages)
168
+ * @returns {Promise<{isAccessible: boolean, statusCode?: number, message?: string}>}
169
+ */
170
+ async function checkUrlAccessibility(url, fieldName) {
171
+ // First, try HEAD request (more efficient)
172
+ const headResult = await attemptHeadRequest(url, fieldName);
173
+ if (headResult !== null) {
174
+ return headResult;
175
+ }
176
+
177
+ // If HEAD fails, try GET request (some servers don't support HEAD)
178
+ const getResult = await attemptGetRequest(url, fieldName);
179
+ return getResult;
180
+ }
181
+
182
+ /**
183
+ * Attempts to check URL accessibility using HEAD request
184
+ *
185
+ * @param {string} url - The URL to check
186
+ * @param {string} fieldName - Name of the field being checked
187
+ * @returns {Promise<Object|null>} Result object or null if HEAD is not supported
188
+ */
189
+ async function attemptHeadRequest(url, fieldName) {
190
+ try {
191
+ const response = await axios.head(url, {
192
+ timeout: HTTP_TIMEOUT,
193
+ maxRedirects: MAX_REDIRECTS,
194
+ validateStatus: (status) => status < 500
195
+ });
196
+
197
+ return createAccessibilityResult(response.status, fieldName);
198
+ } catch (error) {
199
+ // Return null to signal that GET should be attempted
200
+ return null;
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Attempts to check URL accessibility using GET request
206
+ *
207
+ * @param {string} url - The URL to check
208
+ * @param {string} fieldName - Name of the field being checked
209
+ * @returns {Promise<Object>} Result object
210
+ */
211
+ async function attemptGetRequest(url, fieldName) {
212
+ try {
213
+ const response = await axios.get(url, {
214
+ timeout: HTTP_TIMEOUT,
215
+ maxRedirects: MAX_REDIRECTS,
216
+ validateStatus: (status) => status < 500
217
+ });
218
+
219
+ return createAccessibilityResult(response.status, fieldName);
220
+ } catch (error) {
221
+ return {
222
+ isAccessible: false,
223
+ message: `${fieldName} is not accessible: ${error.code || error.message}`
224
+ };
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Creates a standardized accessibility result based on HTTP status code
230
+ *
231
+ * @param {number} statusCode - HTTP status code
232
+ * @param {string} fieldName - Name of the field being checked
233
+ * @returns {Object} Accessibility result
234
+ */
235
+ function createAccessibilityResult(statusCode, fieldName) {
236
+ if (statusCode === 200) {
237
+ return {
238
+ isAccessible: true,
239
+ statusCode: 200
240
+ };
241
+ } else {
242
+ return {
243
+ isAccessible: false,
244
+ statusCode: statusCode,
245
+ message: `${fieldName} returned HTTP ${statusCode}`
246
+ };
247
+ }
248
+ }
249
+
250
+ /**
251
+ * Validates a single external spec entry
252
+ *
253
+ * @param {Object} spec - The external spec object to validate
254
+ * @param {number} index - Index of the spec in the array
255
+ * @param {boolean} checkAccessibility - Whether to check URL accessibility
256
+ * @returns {Promise<Object>} Validation results for this spec
257
+ */
258
+ async function validateExternalSpec(spec, index, checkAccessibility = true) {
259
+ const results = {
260
+ specIndex: index,
261
+ specId: spec.external_spec || `[spec ${index}]`,
262
+ errors: [],
263
+ warnings: [],
264
+ success: []
265
+ };
266
+
267
+ // Check gh_page field existence
268
+ if (!('gh_page' in spec)) {
269
+ results.errors.push('Field "gh_page" is missing');
270
+ } else if (!spec.gh_page) {
271
+ results.errors.push('Field "gh_page" is empty');
272
+ } else {
273
+ results.success.push('Field "gh_page" exists');
274
+
275
+ // Validate gh_page structure
276
+ const ghPageStructure = validateGitHubPagesStructure(spec.gh_page);
277
+ if (!ghPageStructure.isValid) {
278
+ results.errors.push(`gh_page structure invalid: ${ghPageStructure.message}`);
279
+ } else {
280
+ results.success.push('Field "gh_page" has valid URL structure');
281
+
282
+ if (ghPageStructure.message) {
283
+ results.warnings.push(ghPageStructure.message);
284
+ }
285
+
286
+ // Check gh_page accessibility
287
+ if (checkAccessibility) {
288
+ const accessibility = await checkUrlAccessibility(spec.gh_page, 'gh_page');
289
+ if (accessibility.isAccessible) {
290
+ results.success.push(`gh_page is accessible (HTTP ${accessibility.statusCode})`);
291
+ } else {
292
+ results.errors.push(accessibility.message || 'gh_page is not accessible');
293
+ }
294
+ }
295
+ }
296
+ }
297
+
298
+ // Check url field existence
299
+ if (!('url' in spec)) {
300
+ results.errors.push('Field "url" is missing');
301
+ } else if (!spec.url) {
302
+ results.errors.push('Field "url" is empty');
303
+ } else {
304
+ results.success.push('Field "url" exists');
305
+
306
+ // Validate url structure
307
+ const urlStructure = validateGitHubRepoStructure(spec.url);
308
+ if (!urlStructure.isValid) {
309
+ results.errors.push(`url structure invalid: ${urlStructure.message}`);
310
+ } else {
311
+ results.success.push('Field "url" has valid GitHub repository structure');
312
+
313
+ // Check url accessibility
314
+ if (checkAccessibility) {
315
+ const accessibility = await checkUrlAccessibility(spec.url, 'url');
316
+ if (accessibility.isAccessible) {
317
+ results.success.push(`url is accessible (HTTP ${accessibility.statusCode})`);
318
+ } else {
319
+ results.errors.push(accessibility.message || 'url is not accessible');
320
+ }
321
+ }
322
+ }
323
+ }
324
+
325
+ return results;
326
+ }
327
+
328
+ /**
329
+ * Validates external specification URLs in specs.json
330
+ *
331
+ * This health check performs comprehensive validation of external spec references:
332
+ * 1. Checks if gh_page field exists and is not empty
333
+ * 2. Validates gh_page URL structure (proper format for GitHub Pages)
334
+ * 3. Checks if gh_page URL is accessible (returns HTTP 200)
335
+ * 4. Checks if url field exists and is not empty
336
+ * 5. Validates url structure (proper format for GitHub repository)
337
+ * 6. Checks if url is accessible (returns HTTP 200)
338
+ *
339
+ * @param {import('../providers.js').Provider} provider - The provider instance for file operations
340
+ * @param {Object} options - Validation options
341
+ * @param {boolean} options.checkAccessibility - Whether to check URL accessibility (default: true)
342
+ * @returns {Promise<import('../health-check-utils.js').HealthCheckResult>} The health check result
343
+ *
344
+ * @example
345
+ * ```javascript
346
+ * const provider = createLocalProvider('/path/to/repo');
347
+ * const result = await checkExternalSpecsUrls(provider);
348
+ * console.log(result.status); // 'pass', 'fail', or 'warn'
349
+ * ```
350
+ */
351
+ export async function checkExternalSpecsUrls(provider, options = {}) {
352
+ const { checkAccessibility = true } = options;
353
+
354
+ try {
355
+ // Check if specs.json exists
356
+ const exists = await provider.fileExists('specs.json');
357
+ if (!exists) {
358
+ return createHealthCheckResult(
359
+ CHECK_NAME,
360
+ 'fail',
361
+ 'specs.json not found - cannot validate external specs',
362
+ {
363
+ suggestions: [
364
+ 'Create a specs.json file in the repository root',
365
+ 'Run the specs-json health check first'
366
+ ]
367
+ }
368
+ );
369
+ }
370
+
371
+ // Read and parse specs.json
372
+ const content = await provider.readFile('specs.json');
373
+ let specsData;
374
+
375
+ try {
376
+ specsData = JSON.parse(content);
377
+ } catch (parseError) {
378
+ return createHealthCheckResult(
379
+ CHECK_NAME,
380
+ 'fail',
381
+ 'specs.json contains invalid JSON',
382
+ { parseError: parseError.message }
383
+ );
384
+ }
385
+
386
+ // Check if specs array exists
387
+ if (!specsData.specs || !Array.isArray(specsData.specs) || specsData.specs.length === 0) {
388
+ return createHealthCheckResult(
389
+ CHECK_NAME,
390
+ 'fail',
391
+ 'No specs found in specs.json',
392
+ { details: 'specs.json must contain a "specs" array with at least one entry' }
393
+ );
394
+ }
395
+
396
+ const spec = specsData.specs[0];
397
+
398
+ // Check if external_specs exists
399
+ if (!spec.external_specs) {
400
+ return createHealthCheckResult(
401
+ CHECK_NAME,
402
+ 'pass',
403
+ 'No external_specs defined (this is acceptable)',
404
+ {
405
+ info: 'This specification does not reference external specifications',
406
+ note: 'If you want to add external specs, add an "external_specs" array to your spec'
407
+ }
408
+ );
409
+ }
410
+
411
+ // Validate external_specs is an array
412
+ if (!Array.isArray(spec.external_specs)) {
413
+ return createHealthCheckResult(
414
+ CHECK_NAME,
415
+ 'fail',
416
+ 'external_specs must be an array',
417
+ { actualType: typeof spec.external_specs }
418
+ );
419
+ }
420
+
421
+ // If empty array, that's acceptable
422
+ if (spec.external_specs.length === 0) {
423
+ return createHealthCheckResult(
424
+ CHECK_NAME,
425
+ 'pass',
426
+ 'external_specs array is empty (this is acceptable)',
427
+ { info: 'No external specifications are configured' }
428
+ );
429
+ }
430
+
431
+ // Validate each external spec
432
+ const allResults = [];
433
+ const totalErrors = [];
434
+ const totalWarnings = [];
435
+ const totalSuccess = [];
436
+
437
+ for (let i = 0; i < spec.external_specs.length; i++) {
438
+ const extSpec = spec.external_specs[i];
439
+ const result = await validateExternalSpec(extSpec, i, checkAccessibility);
440
+ allResults.push(result);
441
+
442
+ totalErrors.push(...result.errors.map(err => `${result.specId}: ${err}`));
443
+ totalWarnings.push(...result.warnings.map(warn => `${result.specId}: ${warn}`));
444
+ totalSuccess.push(...result.success.map(succ => `${result.specId}: ${succ}`));
445
+ }
446
+
447
+ // Determine overall status
448
+ let status = 'pass';
449
+ let message = `All ${spec.external_specs.length} external spec(s) validated successfully`;
450
+
451
+ if (totalErrors.length > 0) {
452
+ status = 'fail';
453
+ message = `Found ${totalErrors.length} error(s) in external specs`;
454
+ } else if (totalWarnings.length > 0) {
455
+ status = 'warn';
456
+ message = `External specs validated with ${totalWarnings.length} warning(s)`;
457
+ }
458
+
459
+ return createHealthCheckResult(
460
+ CHECK_NAME,
461
+ status,
462
+ message,
463
+ {
464
+ totalSpecs: spec.external_specs.length,
465
+ errors: totalErrors,
466
+ warnings: totalWarnings,
467
+ success: totalSuccess,
468
+ detailedResults: allResults,
469
+ accessibilityChecked: checkAccessibility
470
+ }
471
+ );
472
+
473
+ } catch (error) {
474
+ return createErrorResult(CHECK_NAME, error);
475
+ }
476
+ }
477
+
478
+ // Export as default for backward compatibility
479
+ export default {
480
+ CHECK_ID,
481
+ CHECK_NAME,
482
+ CHECK_DESCRIPTION,
483
+ checkExternalSpecsUrls
484
+ };