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,263 @@
1
+ /**
2
+ * @fileoverview Specification files health check module
3
+ *
4
+ * This module validates the presence and accessibility of specification files
5
+ * in repositories. It searches for markdown files in common specification
6
+ * directories and ensures that specification content is properly organized.
7
+ *
8
+ * @author spec-up-t-healthcheck
9
+ */
10
+
11
+ import { createHealthCheckResult, createErrorResult } from '../health-check-utils.js';
12
+
13
+ /**
14
+ * The identifier for this health check, used in reports and registries.
15
+ * @type {string}
16
+ */
17
+ export const CHECK_ID = 'spec-files';
18
+
19
+ /**
20
+ * Human-readable name for this health check.
21
+ * @type {string}
22
+ */
23
+ export const CHECK_NAME = 'Specification Files Discovery';
24
+
25
+ /**
26
+ * Description of what this health check validates.
27
+ * @type {string}
28
+ */
29
+ export const CHECK_DESCRIPTION = 'Discovers and validates specification files in the repository';
30
+
31
+ /**
32
+ * Common directory paths where specification files are typically located.
33
+ * These paths are searched in order of preference.
34
+ * @type {readonly string[]}
35
+ */
36
+ const SPEC_DIRECTORIES = Object.freeze([
37
+ 'spec/',
38
+ 'specs/',
39
+ 'docs/',
40
+ 'documentation/',
41
+ 'doc/'
42
+ ]);
43
+
44
+ /**
45
+ * File extensions that are considered specification files.
46
+ * @type {readonly string[]}
47
+ */
48
+ const SPEC_EXTENSIONS = Object.freeze(['.md', '.markdown', '.rst', '.txt']);
49
+
50
+ /**
51
+ * Common specification file names that indicate primary specification content.
52
+ * These files are given higher priority in reporting.
53
+ * @type {readonly string[]}
54
+ */
55
+ const PRIMARY_SPEC_NAMES = Object.freeze([
56
+ 'spec.md',
57
+ 'specification.md',
58
+ 'README.md',
59
+ 'index.md',
60
+ 'main.md'
61
+ ]);
62
+
63
+ /**
64
+ * Checks for the presence and accessibility of specification files in the repository.
65
+ *
66
+ * This health check searches for markdown and other documentation files in common
67
+ * specification directories as well as the repository root. It validates that
68
+ * specification content is available and properly organized.
69
+ *
70
+ * The check performs the following discovery:
71
+ * - Searches common spec directories (spec/, docs/, etc.)
72
+ * - Looks for markdown and text files in root directory
73
+ * - Identifies primary specification files
74
+ * - Reports on file organization and accessibility
75
+ *
76
+ * @param {import('../providers.js').Provider} provider - The provider instance for file operations
77
+ * @returns {Promise<import('../health-check-utils.js').HealthCheckResult>} The health check result with file discovery details
78
+ *
79
+ * @example
80
+ * ```javascript
81
+ * const provider = createLocalProvider('/path/to/repo');
82
+ * const result = await checkSpecFiles(provider);
83
+ * console.log(result.details.specFiles); // Array of found specification files
84
+ * ```
85
+ */
86
+ export async function checkSpecFiles(provider) {
87
+ try {
88
+ const discoveryResult = await discoverSpecificationFiles(provider);
89
+
90
+ const {
91
+ specFiles,
92
+ specDirectory,
93
+ primarySpecs,
94
+ rootSpecFiles,
95
+ searchedPaths,
96
+ totalFiles
97
+ } = discoveryResult;
98
+
99
+ // No specification files found
100
+ if (totalFiles === 0) {
101
+ return createHealthCheckResult(
102
+ CHECK_NAME,
103
+ 'fail',
104
+ 'No specification files found in repository',
105
+ {
106
+ searchedPaths,
107
+ searchedExtensions: SPEC_EXTENSIONS,
108
+ suggestions: [
109
+ 'Create a spec/ or docs/ directory',
110
+ 'Add a README.md file with specification content',
111
+ 'Ensure specification files use supported extensions (.md, .markdown, .rst, .txt)'
112
+ ]
113
+ }
114
+ );
115
+ }
116
+
117
+ // Determine health status - keep it simple like the original
118
+ let status = 'pass';
119
+ let message = `Found ${totalFiles} specification file${totalFiles === 1 ? '' : 's'}`;
120
+
121
+ const details = {
122
+ specFiles: specFiles.map(f => f.name),
123
+ specDirectory,
124
+ primarySpecs: primarySpecs.map(f => f.name),
125
+ rootSpecFiles: rootSpecFiles.map(f => f.name),
126
+ totalFiles,
127
+ hasOrganizedSpecs: !!specDirectory,
128
+ hasPrimarySpecs: primarySpecs.length > 0,
129
+ searchedPaths
130
+ };
131
+
132
+ // Only add organization info to message, don't change status
133
+ if (specDirectory) {
134
+ message += ` in organized ${specDirectory} directory`;
135
+ } else if (rootSpecFiles.length > 0) {
136
+ message += ' in repository root';
137
+ // Only warn if there are many unorganized files in root (more aggressive threshold)
138
+ if (rootSpecFiles.length > 5) {
139
+ details.organizationSuggestion = 'Consider moving specification files to a dedicated directory like spec/ or docs/';
140
+ }
141
+ }
142
+
143
+ // Don't warn about missing primary specs - this is optional organizational advice only
144
+ if (primarySpecs.length === 0 && totalFiles > 1) {
145
+ details.primarySpecSuggestion = 'Consider adding a main specification file (spec.md, README.md, or index.md)';
146
+ }
147
+
148
+ return createHealthCheckResult(CHECK_NAME, status, message, details);
149
+
150
+ } catch (error) {
151
+ return createErrorResult(CHECK_NAME, error, {
152
+ context: 'discovering specification files',
153
+ provider: provider.type
154
+ });
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Discovers all specification files in the repository.
160
+ *
161
+ * This function performs the actual file discovery logic, searching through
162
+ * common specification directories and the repository root for relevant files.
163
+ *
164
+ * @param {import('../providers.js').Provider} provider - The provider instance for file operations
165
+ * @returns {Promise<Object>} Discovery result with found files and metadata
166
+ * @private
167
+ */
168
+ async function discoverSpecificationFiles(provider) {
169
+ let specFiles = [];
170
+ let specDirectory = null;
171
+ let primarySpecs = [];
172
+ let rootSpecFiles = [];
173
+ const searchedPaths = [];
174
+
175
+ // Search spec directories first
176
+ for (const dir of SPEC_DIRECTORIES) {
177
+ searchedPaths.push(dir);
178
+ try {
179
+ const files = await provider.listFiles(dir);
180
+ if (files.length > 0) {
181
+ const relevantFiles = filterSpecificationFiles(files);
182
+ if (relevantFiles.length > 0) {
183
+ specDirectory = dir;
184
+ specFiles = relevantFiles;
185
+ primarySpecs = identifyPrimarySpecs(relevantFiles, dir);
186
+ break; // Use first directory with spec files
187
+ }
188
+ }
189
+ } catch (dirError) {
190
+ // Directory doesn't exist or can't be accessed, continue searching
191
+ }
192
+ }
193
+
194
+ // Also check root directory for specification files
195
+ try {
196
+ const rootFiles = await provider.listFiles('');
197
+ const rootRelevantFiles = filterSpecificationFiles(rootFiles);
198
+ rootSpecFiles = rootRelevantFiles;
199
+
200
+ // If no organized spec directory found, use root files as primary
201
+ if (!specDirectory && rootRelevantFiles.length > 0) {
202
+ specFiles = rootRelevantFiles;
203
+ primarySpecs = identifyPrimarySpecs(rootRelevantFiles, '');
204
+ }
205
+ } catch (rootError) {
206
+ // Can't access root directory
207
+ }
208
+
209
+ const totalFiles = specFiles.length + (specDirectory ? 0 : rootSpecFiles.length);
210
+
211
+ return {
212
+ specFiles,
213
+ specDirectory,
214
+ primarySpecs,
215
+ rootSpecFiles,
216
+ searchedPaths,
217
+ totalFiles
218
+ };
219
+ }
220
+
221
+ /**
222
+ * Filters a list of files to include only specification-relevant files.
223
+ *
224
+ * @param {Array} files - Array of file objects from provider.listFiles()
225
+ * @returns {Array} Filtered array of specification files
226
+ * @private
227
+ */
228
+ function filterSpecificationFiles(files) {
229
+ return files.filter(file => {
230
+ if (!file.isFile) return false;
231
+
232
+ return SPEC_EXTENSIONS.some(ext =>
233
+ file.name.toLowerCase().endsWith(ext.toLowerCase())
234
+ );
235
+ });
236
+ }
237
+
238
+ /**
239
+ * Identifies primary specification files from a list of files.
240
+ *
241
+ * Primary specification files are those with common names that typically
242
+ * contain the main specification content.
243
+ *
244
+ * @param {Array} files - Array of specification files
245
+ * @param {string} directory - The directory containing the files
246
+ * @returns {Array} Array of primary specification files
247
+ * @private
248
+ */
249
+ function identifyPrimarySpecs(files, directory) {
250
+ const primaryFiles = [];
251
+
252
+ for (const file of files) {
253
+ const fileName = file.name.toLowerCase();
254
+ if (PRIMARY_SPEC_NAMES.some(name => fileName === name.toLowerCase())) {
255
+ primaryFiles.push(file);
256
+ }
257
+ }
258
+
259
+ return primaryFiles;
260
+ }
261
+
262
+ // Export the health check function as default for easy registration
263
+ export default checkSpecFiles;
@@ -0,0 +1,361 @@
1
+ /**
2
+ * @fileoverview Specs.json health check module
3
+ *
4
+ * This module validates the existence and structure of specs.json files in
5
+ * specification repositories. It ensures that essential spec metadata is
6
+ * present and properly formatted.
7
+ *
8
+ * @author spec-up-t-healthcheck
9
+ */
10
+
11
+ import { createHealthCheckResult, createErrorResult } from '../health-check-utils.js';
12
+
13
+ /**
14
+ * The identifier for this health check, used in reports and registries.
15
+ * @type {string}
16
+ */
17
+ export const CHECK_ID = 'specs-json';
18
+
19
+ /**
20
+ * Human-readable name for this health check.
21
+ * @type {string}
22
+ */
23
+ export const CHECK_NAME = 'Specs.json Validation';
24
+
25
+ /**
26
+ * Description of what this health check validates.
27
+ * @type {string}
28
+ */
29
+ export const CHECK_DESCRIPTION = 'Validates the existence and structure of specs.json file';
30
+
31
+ /**
32
+ * Required fields that must be present in a valid specs.json file.
33
+ * These fields are essential for proper spec functionality in Spec-Up-T.
34
+ * @type {readonly string[]}
35
+ */
36
+ const REQUIRED_FIELDS = Object.freeze([
37
+ 'title',
38
+ 'description',
39
+ 'author',
40
+ 'spec_directory',
41
+ 'spec_terms_directory',
42
+ 'output_path',
43
+ 'markdown_paths',
44
+ 'logo',
45
+ 'logo_link',
46
+ 'source'
47
+ ]);
48
+
49
+ /**
50
+ * Warning fields that should be present but missing values only trigger warnings.
51
+ * @type {readonly string[]}
52
+ */
53
+ const WARNING_FIELDS = Object.freeze(['favicon']);
54
+
55
+ /**
56
+ * Optional fields that provide info if missing.
57
+ * @type {readonly string[]}
58
+ */
59
+ const OPTIONAL_FIELDS = Object.freeze(['anchor_symbol', 'katex']);
60
+
61
+ /**
62
+ * Validates the existence and structure of specs.json in a repository.
63
+ *
64
+ * This health check ensures that a valid specs.json file exists at the repository root
65
+ * and contains the required fields for proper Spec-Up-T functionality. It performs
66
+ * comprehensive validation of the specs.json structure including the specs array.
67
+ *
68
+ * The check performs the following validations:
69
+ * - File exists at repository root
70
+ * - File contains valid JSON
71
+ * - Root object contains exactly one 'specs' field
72
+ * - 'specs' is an array with exactly one object
73
+ * - Required fields are present and non-empty
74
+ * - Warning fields are checked (favicon)
75
+ * - Optional fields are noted if missing
76
+ * - Source object has required subfields
77
+ *
78
+ * @param {import('../providers.js').Provider} provider - The provider instance for file operations
79
+ * @returns {Promise<import('../health-check-utils.js').HealthCheckResult>} The health check result with validation details
80
+ *
81
+ * @example
82
+ * ```javascript
83
+ * const provider = createLocalProvider('/path/to/repo');
84
+ * const result = await checkSpecsJson(provider);
85
+ * console.log(result.status); // 'pass', 'fail', or 'warn'
86
+ * ```
87
+ */
88
+ export async function checkSpecsJson(provider) {
89
+ try {
90
+ // Check if specs.json exists
91
+ const exists = await provider.fileExists('specs.json');
92
+ if (!exists) {
93
+ return createHealthCheckResult(
94
+ CHECK_NAME,
95
+ 'fail',
96
+ 'specs.json not found in repository root',
97
+ {
98
+ suggestions: [
99
+ 'Create a specs.json file in your repository root',
100
+ 'Use the Spec-Up-T boilerplate as a template',
101
+ 'Ensure the file is named exactly "specs.json"'
102
+ ]
103
+ }
104
+ );
105
+ }
106
+
107
+ // Read and parse the specs.json file
108
+ const content = await provider.readFile('specs.json');
109
+ let specsData;
110
+
111
+ try {
112
+ specsData = JSON.parse(content);
113
+ } catch (parseError) {
114
+ return createHealthCheckResult(
115
+ CHECK_NAME,
116
+ 'fail',
117
+ 'specs.json contains invalid JSON',
118
+ {
119
+ parseError: parseError.message,
120
+ fileContent: content.substring(0, 500) + (content.length > 500 ? '...' : '')
121
+ }
122
+ );
123
+ }
124
+
125
+ // Validate basic structure
126
+ const structureValidation = validateBasicStructure(specsData);
127
+ if (!structureValidation.isValid) {
128
+ return createHealthCheckResult(
129
+ CHECK_NAME,
130
+ 'fail',
131
+ structureValidation.message,
132
+ { structureError: structureValidation.details }
133
+ );
134
+ }
135
+
136
+ const spec = specsData.specs[0];
137
+ const validationResults = {
138
+ errors: [],
139
+ warnings: [],
140
+ info: [],
141
+ success: []
142
+ };
143
+
144
+ // Validate required fields
145
+ validateRequiredFields(spec, validationResults);
146
+
147
+ // Validate warning fields
148
+ validateWarningFields(spec, validationResults);
149
+
150
+ // Validate optional fields
151
+ validateOptionalFields(spec, validationResults);
152
+
153
+ // Validate field types and structure
154
+ validateFieldTypes(spec, validationResults);
155
+
156
+ // Determine overall status
157
+ let status = 'pass';
158
+ let message = 'specs.json is valid';
159
+
160
+ if (validationResults.errors.length > 0) {
161
+ status = 'fail';
162
+ message = `specs.json has ${validationResults.errors.length} error(s)`;
163
+ } else if (validationResults.warnings.length > 0) {
164
+ status = 'warn';
165
+ message = `specs.json is valid but has ${validationResults.warnings.length} warning(s)`;
166
+ }
167
+
168
+ return createHealthCheckResult(
169
+ CHECK_NAME,
170
+ status,
171
+ message,
172
+ {
173
+ errors: validationResults.errors,
174
+ warnings: validationResults.warnings,
175
+ info: validationResults.info,
176
+ success: validationResults.success,
177
+ totalIssues: validationResults.errors.length + validationResults.warnings.length
178
+ }
179
+ );
180
+
181
+ } catch (error) {
182
+ return createErrorResult(CHECK_NAME, error);
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Validates the basic structure of specs.json
188
+ * @param {any} data - Parsed JSON data
189
+ * @returns {{isValid: boolean, message?: string, details?: any}}
190
+ */
191
+ function validateBasicStructure(data) {
192
+ // Check if data is an object
193
+ if (typeof data !== 'object' || data === null || Array.isArray(data)) {
194
+ return {
195
+ isValid: false,
196
+ message: 'specs.json must contain a JSON object at root level',
197
+ details: { actualType: Array.isArray(data) ? 'array' : typeof data }
198
+ };
199
+ }
200
+
201
+ // Check if it contains exactly one field called 'specs'
202
+ const keys = Object.keys(data);
203
+ if (keys.length !== 1) {
204
+ return {
205
+ isValid: false,
206
+ message: `Root object should contain exactly one field, found ${keys.length}: [${keys.join(', ')}]`,
207
+ details: { foundKeys: keys }
208
+ };
209
+ }
210
+
211
+ if (keys[0] !== 'specs') {
212
+ return {
213
+ isValid: false,
214
+ message: `Root object should contain field 'specs', found '${keys[0]}'`,
215
+ details: { foundKey: keys[0] }
216
+ };
217
+ }
218
+
219
+ // Check if specs is an array
220
+ if (!Array.isArray(data.specs)) {
221
+ return {
222
+ isValid: false,
223
+ message: 'Field "specs" should be an array',
224
+ details: { actualType: typeof data.specs }
225
+ };
226
+ }
227
+
228
+ // Check if array contains exactly one object
229
+ if (data.specs.length !== 1) {
230
+ return {
231
+ isValid: false,
232
+ message: `Field "specs" should contain exactly one object, found ${data.specs.length}`,
233
+ details: { arrayLength: data.specs.length }
234
+ };
235
+ }
236
+
237
+ // Check if the single item is an object
238
+ if (typeof data.specs[0] !== 'object' || data.specs[0] === null || Array.isArray(data.specs[0])) {
239
+ return {
240
+ isValid: false,
241
+ message: 'The item in "specs" array should be an object',
242
+ details: { actualType: Array.isArray(data.specs[0]) ? 'array' : typeof data.specs[0] }
243
+ };
244
+ }
245
+
246
+ return { isValid: true };
247
+ }
248
+
249
+ /**
250
+ * Validates required fields
251
+ * @param {Object} spec - The spec object to validate
252
+ * @param {Object} results - Results accumulator
253
+ */
254
+ function validateRequiredFields(spec, results) {
255
+ REQUIRED_FIELDS.forEach(field => {
256
+ if (!(field in spec)) {
257
+ results.errors.push(`Required field "${field}" is missing`);
258
+ } else if (spec[field] === null || spec[field] === undefined || spec[field] === '') {
259
+ results.errors.push(`Required field "${field}" is empty or null`);
260
+ } else if (Array.isArray(spec[field]) && spec[field].length === 0) {
261
+ results.errors.push(`Required field "${field}" is an empty array`);
262
+ } else {
263
+ results.success.push(`Required field "${field}" is present and valid`);
264
+ }
265
+ });
266
+ }
267
+
268
+ /**
269
+ * Validates warning fields
270
+ * @param {Object} spec - The spec object to validate
271
+ * @param {Object} results - Results accumulator
272
+ */
273
+ function validateWarningFields(spec, results) {
274
+ WARNING_FIELDS.forEach(field => {
275
+ if (!(field in spec)) {
276
+ results.warnings.push(`Recommended field "${field}" is missing`);
277
+ } else if (spec[field] === null || spec[field] === undefined || spec[field] === '') {
278
+ results.warnings.push(`Recommended field "${field}" is empty or null`);
279
+ } else {
280
+ results.success.push(`Recommended field "${field}" is present and valid`);
281
+ }
282
+ });
283
+ }
284
+
285
+ /**
286
+ * Validates optional fields
287
+ * @param {Object} spec - The spec object to validate
288
+ * @param {Object} results - Results accumulator
289
+ */
290
+ function validateOptionalFields(spec, results) {
291
+ OPTIONAL_FIELDS.forEach(field => {
292
+ if (!(field in spec)) {
293
+ results.info.push(`Optional field "${field}" is not set (this is acceptable)`);
294
+ } else {
295
+ results.success.push(`Optional field "${field}" is present`);
296
+ }
297
+ });
298
+ }
299
+
300
+ /**
301
+ * Validates specific field types and formats
302
+ * @param {Object} spec - The spec object to validate
303
+ * @param {Object} results - Results accumulator
304
+ */
305
+ function validateFieldTypes(spec, results) {
306
+ // Validate markdown_paths is array of strings
307
+ if (spec.markdown_paths && Array.isArray(spec.markdown_paths)) {
308
+ if (spec.markdown_paths.every(item => typeof item === 'string')) {
309
+ results.success.push('Field "markdown_paths" contains valid string array');
310
+ } else {
311
+ results.errors.push('Field "markdown_paths" should contain only strings');
312
+ }
313
+ }
314
+
315
+ // Validate source object structure
316
+ if (spec.source && typeof spec.source === 'object') {
317
+ const requiredSourceFields = ['host', 'account', 'repo', 'branch'];
318
+ let sourceValid = true;
319
+
320
+ requiredSourceFields.forEach(field => {
321
+ if (!(field in spec.source) || !spec.source[field]) {
322
+ results.errors.push(`Source field "${field}" is missing or empty`);
323
+ sourceValid = false;
324
+ }
325
+ });
326
+
327
+ if (sourceValid) {
328
+ results.success.push('Source object structure is valid');
329
+ }
330
+ }
331
+
332
+ // Validate external_specs if present
333
+ if (spec.external_specs) {
334
+ if (Array.isArray(spec.external_specs)) {
335
+ spec.external_specs.forEach((extSpec, index) => {
336
+ const requiredExtFields = ['external_spec', 'gh_page', 'url', 'terms_dir'];
337
+ requiredExtFields.forEach(field => {
338
+ if (!(field in extSpec) || !extSpec[field]) {
339
+ results.errors.push(`External spec ${index} missing "${field}"`);
340
+ }
341
+ });
342
+ });
343
+ results.success.push(`External specs array contains ${spec.external_specs.length} entries`);
344
+ } else {
345
+ results.errors.push('Field "external_specs" should be an array');
346
+ }
347
+ }
348
+
349
+ // Validate katex is boolean if present
350
+ if ('katex' in spec && typeof spec.katex !== 'boolean') {
351
+ results.errors.push('Field "katex" should be a boolean value');
352
+ }
353
+ }
354
+
355
+ // Export as default for backward compatibility
356
+ export default {
357
+ CHECK_ID,
358
+ CHECK_NAME,
359
+ CHECK_DESCRIPTION,
360
+ checkSpecsJson
361
+ };