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,518 @@
1
+ /**
2
+ * @fileoverview Package.json health check module
3
+ *
4
+ * This module validates the existence and structure of package.json files in
5
+ * specification repositories. It ensures that essential package metadata is
6
+ * present and properly formatted for Node.js compatibility.
7
+ *
8
+ * It also validates spec-up-t specific requirements:
9
+ * - Presence of spec-up-t dependency with correct version range
10
+ * - Required npm scripts from configScriptsKeys
11
+ *
12
+ * @author spec-up-t-healthcheck
13
+ */
14
+
15
+ import { createHealthCheckResult, createErrorResult } from '../health-check-utils.js';
16
+
17
+ /**
18
+ * The identifier for this health check, used in reports and registries.
19
+ * @type {string}
20
+ */
21
+ export const CHECK_ID = 'package-json';
22
+
23
+ /**
24
+ * Human-readable name for this health check.
25
+ * @type {string}
26
+ */
27
+ export const CHECK_NAME = 'Package.json Validation';
28
+
29
+ /**
30
+ * Description of what this health check validates.
31
+ * @type {string}
32
+ */
33
+ export const CHECK_DESCRIPTION = 'Validates the existence and structure of package.json file';
34
+
35
+ /**
36
+ * Required fields that must be present in a valid package.json file.
37
+ * These fields are essential for proper Node.js package identification.
38
+ * @type {readonly string[]}
39
+ */
40
+ const REQUIRED_FIELDS = Object.freeze(['name', 'version']);
41
+
42
+ /**
43
+ * Recommended fields that should be present in a well-formed package.json.
44
+ * Missing these fields will generate warnings rather than failures.
45
+ * @type {readonly string[]}
46
+ */
47
+ const RECOMMENDED_FIELDS = Object.freeze(['description', 'author', 'license']);
48
+
49
+ /**
50
+ * GitHub URL for the starter pack repository that defines the reference configuration.
51
+ * This is used to fetch the latest recommended spec-up-t version dynamically.
52
+ * @type {string}
53
+ */
54
+ const STARTER_PACK_PACKAGE_URL = 'https://raw.githubusercontent.com/trustoverip/spec-up-t-starter-pack/main/package.spec-up-t.json';
55
+
56
+ /**
57
+ * GitHub URL for the config scripts keys that define required npm scripts.
58
+ * This is used to fetch the latest required scripts dynamically.
59
+ * @type {string}
60
+ */
61
+ const CONFIG_SCRIPTS_URL = 'https://raw.githubusercontent.com/trustoverip/spec-up-t/master/src/install-from-boilerplate/config-scripts-keys.js';
62
+
63
+ /**
64
+ * Cache duration for fetched external configuration (in milliseconds).
65
+ * Set to 1 hour to avoid excessive network requests while keeping data reasonably fresh.
66
+ * @type {number}
67
+ */
68
+ const CACHE_DURATION = 60 * 60 * 1000; // 1 hour
69
+
70
+ /**
71
+ * In-memory cache for external configuration data.
72
+ * @type {Object}
73
+ * @private
74
+ */
75
+ let configCache = {
76
+ starterPackVersion: null,
77
+ configScripts: null,
78
+ lastFetch: 0
79
+ };
80
+
81
+ /**
82
+ * Fetches the latest spec-up-t version from the starter pack repository.
83
+ *
84
+ * This function retrieves the reference package.json from the spec-up-t-starter-pack
85
+ * repository to determine the currently recommended spec-up-t version. Results are
86
+ * cached to minimize network requests.
87
+ *
88
+ * @returns {Promise<string|null>} The spec-up-t version string (e.g., "^1.3.0") or null if fetch fails
89
+ * @private
90
+ */
91
+ async function fetchStarterPackVersion() {
92
+ const now = Date.now();
93
+
94
+ // Return cached version if still valid
95
+ if (configCache.starterPackVersion && (now - configCache.lastFetch) < CACHE_DURATION) {
96
+ return configCache.starterPackVersion;
97
+ }
98
+
99
+ try {
100
+ const response = await fetch(STARTER_PACK_PACKAGE_URL);
101
+ if (!response.ok) {
102
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
103
+ }
104
+
105
+ const packageData = await response.json();
106
+ const version = packageData?.dependencies?.[`spec-up-t`];
107
+
108
+ if (version) {
109
+ configCache.starterPackVersion = version;
110
+ configCache.lastFetch = now;
111
+ return version;
112
+ }
113
+
114
+ return null;
115
+ } catch (error) {
116
+ // Return cached version if available, even if expired
117
+ return configCache.starterPackVersion || null;
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Fetches the required npm scripts from the config-scripts-keys.js file.
123
+ *
124
+ * This function retrieves the reference configScriptsKeys from the spec-up-t
125
+ * repository to determine which npm scripts should be present. Results are
126
+ * cached to minimize network requests.
127
+ *
128
+ * @returns {Promise<Object|null>} The configScriptsKeys object or null if fetch fails
129
+ * @private
130
+ */
131
+ async function fetchConfigScriptsKeys() {
132
+ const now = Date.now();
133
+
134
+ // Return cached scripts if still valid
135
+ if (configCache.configScripts && (now - configCache.lastFetch) < CACHE_DURATION) {
136
+ return configCache.configScripts;
137
+ }
138
+
139
+ try {
140
+ const response = await fetch(CONFIG_SCRIPTS_URL);
141
+ if (!response.ok) {
142
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
143
+ }
144
+
145
+ const scriptContent = await response.text();
146
+
147
+ // Extract configScriptsKeys using regex since we're parsing JS, not JSON
148
+ const match = scriptContent.match(/const configScriptsKeys = ({[\s\S]*?});/);
149
+ if (!match) {
150
+ return null;
151
+ }
152
+
153
+ // Convert the JavaScript object literal to JSON
154
+ // This is a simplified parser that handles the specific format used
155
+ const scriptsObj = parseConfigScriptsObject(match[1]);
156
+
157
+ if (scriptsObj) {
158
+ configCache.configScripts = scriptsObj;
159
+ configCache.lastFetch = now;
160
+ return scriptsObj;
161
+ }
162
+
163
+ return null;
164
+ } catch (error) {
165
+ // Return cached scripts if available, even if expired
166
+ return configCache.configScripts || null;
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Parses a JavaScript object literal string into a proper object.
172
+ *
173
+ * This function handles the specific format used in config-scripts-keys.js,
174
+ * extracting key-value pairs from the object literal. It uses a simple
175
+ * regex-based approach suitable for the expected format.
176
+ *
177
+ * @param {string} objStr - The JavaScript object literal string
178
+ * @returns {Object|null} The parsed object or null if parsing fails
179
+ * @private
180
+ */
181
+ function parseConfigScriptsObject(objStr) {
182
+ try {
183
+ // Extract all key-value pairs from the object literal
184
+ // Handles escaped quotes within string values
185
+ const pairs = objStr.matchAll(/^\s*"([^"]+)":\s*"((?:[^"\\]|\\.)*)"/gm);
186
+ const result = {};
187
+
188
+ for (const match of pairs) {
189
+ // Unescape the captured value to match JSON-parsed strings
190
+ const unescapedValue = match[2].replace(/\\"/g, '"').replace(/\\\\/g, '\\');
191
+ result[match[1]] = unescapedValue;
192
+ }
193
+
194
+ return Object.keys(result).length > 0 ? result : null;
195
+ } catch (error) {
196
+ return null;
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Validates that the spec-up-t dependency is present and has a compatible version.
202
+ *
203
+ * This function checks if:
204
+ * - The spec-up-t dependency exists in package.json
205
+ * - The version range is compatible with the reference version from the starter pack
206
+ *
207
+ * @param {Object} packageData - The parsed package.json data
208
+ * @param {string|null} referenceVersion - The reference version from starter pack
209
+ * @returns {Object} Validation result with status and details
210
+ * @private
211
+ */
212
+ function validateSpecUpTDependency(packageData, referenceVersion) {
213
+ const dependencies = packageData.dependencies || {};
214
+ const devDependencies = packageData.devDependencies || {};
215
+ const allDeps = { ...dependencies, ...devDependencies };
216
+
217
+ const specUpTVersion = allDeps['spec-up-t'];
218
+
219
+ // Check if spec-up-t dependency exists
220
+ if (!specUpTVersion) {
221
+ return {
222
+ isValid: false,
223
+ severity: 'fail',
224
+ message: 'spec-up-t dependency not found in dependencies or devDependencies',
225
+ details: {
226
+ expectedInDependencies: true,
227
+ foundInDependencies: false
228
+ }
229
+ };
230
+ }
231
+
232
+ // If we couldn't fetch the reference version, just verify presence
233
+ if (!referenceVersion) {
234
+ return {
235
+ isValid: true,
236
+ severity: 'warn',
237
+ message: 'spec-up-t dependency found, but could not verify version against starter pack (network issue)',
238
+ details: {
239
+ currentVersion: specUpTVersion,
240
+ referenceVersionUnavailable: true
241
+ }
242
+ };
243
+ }
244
+
245
+ // Compare version ranges
246
+ const versionMatch = specUpTVersion === referenceVersion;
247
+
248
+ if (!versionMatch) {
249
+ return {
250
+ isValid: false,
251
+ severity: 'warn',
252
+ message: `spec-up-t version differs from starter pack recommendation`,
253
+ details: {
254
+ currentVersion: specUpTVersion,
255
+ recommendedVersion: referenceVersion,
256
+ starterPackUrl: STARTER_PACK_PACKAGE_URL
257
+ }
258
+ };
259
+ }
260
+
261
+ return {
262
+ isValid: true,
263
+ severity: 'pass',
264
+ message: 'spec-up-t dependency is correctly configured',
265
+ details: {
266
+ currentVersion: specUpTVersion,
267
+ matchesStarterPack: true
268
+ }
269
+ };
270
+ }
271
+
272
+ /**
273
+ * Validates that required npm scripts are present in package.json.
274
+ *
275
+ * This function checks if the required scripts from configScriptsKeys are
276
+ * present in the package.json scripts section. It reports missing scripts
277
+ * and scripts with different implementations.
278
+ *
279
+ * @param {Object} packageData - The parsed package.json data
280
+ * @param {Object|null} configScriptsKeys - The reference scripts from spec-up-t
281
+ * @returns {Object} Validation result with status and details
282
+ * @private
283
+ */
284
+ function validateScripts(packageData, configScriptsKeys) {
285
+ const packageScripts = packageData.scripts || {};
286
+
287
+ // If we couldn't fetch the reference scripts, skip validation
288
+ if (!configScriptsKeys) {
289
+ return {
290
+ isValid: true,
291
+ severity: 'warn',
292
+ message: 'Could not verify npm scripts against spec-up-t reference (network issue)',
293
+ details: {
294
+ referenceScriptsUnavailable: true,
295
+ scriptCount: Object.keys(packageScripts).length
296
+ }
297
+ };
298
+ }
299
+
300
+ const missingScripts = [];
301
+ const differentScripts = [];
302
+
303
+ // Check each required script
304
+ for (const [scriptName, expectedCommand] of Object.entries(configScriptsKeys)) {
305
+ if (!packageScripts[scriptName]) {
306
+ missingScripts.push(scriptName);
307
+ } else if (packageScripts[scriptName] !== expectedCommand) {
308
+ differentScripts.push({
309
+ name: scriptName,
310
+ current: packageScripts[scriptName],
311
+ expected: expectedCommand
312
+ });
313
+ }
314
+ }
315
+
316
+ // Determine severity based on what's missing or different
317
+ if (missingScripts.length > 0) {
318
+ return {
319
+ isValid: false,
320
+ severity: 'fail',
321
+ message: `Missing required npm scripts: ${missingScripts.join(', ')}`,
322
+ details: {
323
+ missingScripts,
324
+ differentScripts: differentScripts.length > 0 ? differentScripts : undefined,
325
+ totalRequired: Object.keys(configScriptsKeys).length,
326
+ configScriptsUrl: CONFIG_SCRIPTS_URL
327
+ }
328
+ };
329
+ }
330
+
331
+ if (differentScripts.length > 0) {
332
+ return {
333
+ isValid: false,
334
+ severity: 'warn',
335
+ message: `Some npm scripts differ from spec-up-t reference: ${differentScripts.map(s => s.name).join(', ')}`,
336
+ details: {
337
+ differentScripts,
338
+ configScriptsUrl: CONFIG_SCRIPTS_URL
339
+ }
340
+ };
341
+ }
342
+
343
+ return {
344
+ isValid: true,
345
+ severity: 'pass',
346
+ message: 'All required npm scripts are present and correct',
347
+ details: {
348
+ scriptCount: Object.keys(packageScripts).length,
349
+ requiredScriptCount: Object.keys(configScriptsKeys).length,
350
+ allScriptsMatch: true
351
+ }
352
+ };
353
+ }
354
+
355
+ /**
356
+ * Validates the existence and structure of package.json in a repository.
357
+ *
358
+ * This health check ensures that a valid package.json file exists at the repository root
359
+ * and contains the required fields for a proper Node.js package. It checks for the
360
+ * presence of essential metadata and validates JSON structure.
361
+ *
362
+ * The check performs the following validations:
363
+ * - File exists at repository root
364
+ * - File contains valid JSON
365
+ * - Required fields (name, version) are present
366
+ * - Recommended fields are present (warnings if missing)
367
+ * - spec-up-t dependency is present with correct version
368
+ * - Required npm scripts from configScriptsKeys are present
369
+ *
370
+ * @param {import('../providers.js').Provider} provider - The provider instance for file operations
371
+ * @returns {Promise<import('../health-check-utils.js').HealthCheckResult>} The health check result with validation details
372
+ *
373
+ * @example
374
+ * ```javascript
375
+ * const provider = createLocalProvider('/path/to/repo');
376
+ * const result = await checkPackageJson(provider);
377
+ * console.log(result.status); // 'pass', 'fail', or 'warn'
378
+ * ```
379
+ */
380
+ export async function checkPackageJson(provider) {
381
+ try {
382
+ // Check if package.json exists
383
+ const exists = await provider.fileExists('package.json');
384
+ if (!exists) {
385
+ return createHealthCheckResult(
386
+ CHECK_NAME,
387
+ 'fail',
388
+ 'package.json not found in repository root'
389
+ );
390
+ }
391
+
392
+ // Read and parse the package.json file
393
+ const content = await provider.readFile('package.json');
394
+ let packageData;
395
+
396
+ try {
397
+ packageData = JSON.parse(content);
398
+ } catch (parseError) {
399
+ return createHealthCheckResult(
400
+ CHECK_NAME,
401
+ 'fail',
402
+ 'package.json contains invalid JSON',
403
+ {
404
+ parseError: parseError.message,
405
+ fileContent: content.substring(0, 500) + (content.length > 500 ? '...' : '')
406
+ }
407
+ );
408
+ }
409
+
410
+ // Validate required fields
411
+ const missingRequired = REQUIRED_FIELDS.filter(field =>
412
+ !packageData[field] || (typeof packageData[field] === 'string' && packageData[field].trim() === '')
413
+ );
414
+
415
+ if (missingRequired.length > 0) {
416
+ return createHealthCheckResult(
417
+ CHECK_NAME,
418
+ 'fail',
419
+ `Missing required fields: ${missingRequired.join(', ')}`,
420
+ {
421
+ missingRequired,
422
+ presentFields: Object.keys(packageData),
423
+ packageSample: extractPackageSample(packageData)
424
+ }
425
+ );
426
+ }
427
+
428
+ // Fetch external reference data (with caching)
429
+ const [referenceVersion, configScriptsKeys] = await Promise.all([
430
+ fetchStarterPackVersion(),
431
+ fetchConfigScriptsKeys()
432
+ ]);
433
+
434
+ // Validate spec-up-t dependency
435
+ const depValidation = validateSpecUpTDependency(packageData, referenceVersion);
436
+
437
+ // Validate npm scripts
438
+ const scriptsValidation = validateScripts(packageData, configScriptsKeys);
439
+
440
+ // Check for recommended fields (warnings)
441
+ const missingRecommended = RECOMMENDED_FIELDS.filter(field =>
442
+ !packageData[field] || (typeof packageData[field] === 'string' && packageData[field].trim() === '')
443
+ );
444
+
445
+ // Aggregate all validation results
446
+ const details = {
447
+ packageSample: extractPackageSample(packageData),
448
+ hasAllRequired: true,
449
+ missingRecommended,
450
+ fieldCount: Object.keys(packageData).length,
451
+ dependency: depValidation.details,
452
+ scripts: scriptsValidation.details
453
+ };
454
+
455
+ // Determine overall status based on all validations
456
+ const validations = [
457
+ { severity: missingRecommended.length > 0 ? 'warn' : 'pass', message: missingRecommended.length > 0 ? `Missing recommended fields: ${missingRecommended.join(', ')}` : null },
458
+ { severity: depValidation.severity, message: depValidation.message },
459
+ { severity: scriptsValidation.severity, message: scriptsValidation.message }
460
+ ];
461
+
462
+ // Priority: fail > warn > pass
463
+ const hasFail = validations.some(v => v.severity === 'fail');
464
+ const hasWarn = validations.some(v => v.severity === 'warn');
465
+
466
+ let overallStatus = 'pass';
467
+ let messages = [];
468
+
469
+ if (hasFail) {
470
+ overallStatus = 'fail';
471
+ messages = validations.filter(v => v.severity === 'fail').map(v => v.message);
472
+ } else if (hasWarn) {
473
+ overallStatus = 'warn';
474
+ messages = validations.filter(v => v.severity === 'warn').map(v => v.message);
475
+ } else {
476
+ messages = ['package.json is valid and well-formed with correct spec-up-t configuration'];
477
+ }
478
+
479
+ return createHealthCheckResult(
480
+ CHECK_NAME,
481
+ overallStatus,
482
+ messages.join('; '),
483
+ details
484
+ );
485
+
486
+ } catch (error) {
487
+ return createErrorResult(CHECK_NAME, error, {
488
+ context: 'checking package.json file',
489
+ provider: provider.type
490
+ });
491
+ }
492
+ }
493
+
494
+ /**
495
+ * Extracts a safe sample of package.json data for reporting purposes.
496
+ *
497
+ * This function creates a sanitized version of package data that can be safely
498
+ * included in health check results without exposing sensitive information.
499
+ *
500
+ * @param {Object} packageData - The parsed package.json data
501
+ * @returns {Object} A sanitized sample of the package data
502
+ * @private
503
+ */
504
+ function extractPackageSample(packageData) {
505
+ const safeFields = ['name', 'version', 'description', 'author', 'license'];
506
+ const sample = {};
507
+
508
+ for (const field of safeFields) {
509
+ if (packageData[field]) {
510
+ sample[field] = packageData[field];
511
+ }
512
+ }
513
+
514
+ return sample;
515
+ }
516
+
517
+ // Export the health check function as default for easy registration
518
+ export default checkPackageJson;