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,350 @@
1
+ /**
2
+ * @fileoverview .gitignore health check module
3
+ *
4
+ * This module validates the existence and content of .gitignore files in
5
+ * specification repositories. It ensures that essential entries are present
6
+ * to prevent common files from being accidentally committed to version control.
7
+ *
8
+ * The check validates:
9
+ * - Existence of .gitignore file
10
+ * - Valid content (not empty, properly formatted)
11
+ * - Presence of required entries that should be ignored
12
+ *
13
+ * @author spec-up-t-healthcheck
14
+ */
15
+
16
+ import { createHealthCheckResult, createErrorResult } from '../health-check-utils.js';
17
+
18
+ /**
19
+ * The identifier for this health check, used in reports and registries.
20
+ * @type {string}
21
+ */
22
+ export const CHECK_ID = 'gitignore';
23
+
24
+ /**
25
+ * Human-readable name for this health check.
26
+ * @type {string}
27
+ */
28
+ export const CHECK_NAME = '.gitignore Validation';
29
+
30
+ /**
31
+ * Description of what this health check validates.
32
+ * @type {string}
33
+ */
34
+ export const CHECK_DESCRIPTION = 'Validates the existence and content of .gitignore file';
35
+
36
+ /**
37
+ * GitHub URL for the boilerplate .gitignore file that defines the reference entries.
38
+ * This is used to fetch the latest required .gitignore entries dynamically.
39
+ * @type {string}
40
+ */
41
+ const BOILERPLATE_GITIGNORE_URL = 'https://raw.githubusercontent.com/trustoverip/spec-up-t/master/src/install-from-boilerplate/boilerplate/gitignore';
42
+
43
+ /**
44
+ * Cache duration for fetched boilerplate .gitignore (in milliseconds).
45
+ * Set to 1 hour to avoid excessive network requests while keeping data reasonably fresh.
46
+ * @type {number}
47
+ */
48
+ const CACHE_DURATION = 60 * 60 * 1000; // 1 hour
49
+
50
+ /**
51
+ * In-memory cache for required entries.
52
+ * @type {Object}
53
+ * @private
54
+ */
55
+ let entriesCache = {
56
+ entries: null,
57
+ lastFetch: 0
58
+ };
59
+
60
+ /**
61
+ * Fallback required entries if the remote fetch fails.
62
+ * These entries prevent common files and directories from being committed.
63
+ *
64
+ * Each entry can appear as-is or with variations (e.g., /node_modules, node_modules/, etc.)
65
+ *
66
+ * @type {readonly string[]}
67
+ */
68
+ const FALLBACK_REQUIRED_ENTRIES = Object.freeze([
69
+ 'node_modules',
70
+ '*.log',
71
+ 'dist',
72
+ '*.bak',
73
+ '*.tmp',
74
+ '.DS_Store',
75
+ '.env',
76
+ 'coverage',
77
+ 'build',
78
+ '.history',
79
+ '/.cache/'
80
+ ]);
81
+
82
+ /**
83
+ * Fetches the latest required .gitignore entries from the boilerplate repository.
84
+ *
85
+ * This function retrieves the reference .gitignore file from the spec-up-t
86
+ * repository to determine the currently required entries. Results are cached
87
+ * to minimize network requests.
88
+ *
89
+ * The boilerplate .gitignore is parsed line by line, filtering out empty lines
90
+ * and comments to extract the actual entries.
91
+ *
92
+ * @returns {Promise<string[]>} Array of required .gitignore entries, or fallback entries if fetch fails
93
+ * @private
94
+ */
95
+ async function fetchRequiredEntries() {
96
+ const now = Date.now();
97
+
98
+ // Return cached entries if still valid
99
+ if (entriesCache.entries && (now - entriesCache.lastFetch) < CACHE_DURATION) {
100
+ return entriesCache.entries;
101
+ }
102
+
103
+ try {
104
+ const response = await fetch(BOILERPLATE_GITIGNORE_URL);
105
+ if (!response.ok) {
106
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
107
+ }
108
+
109
+ const content = await response.text();
110
+
111
+ // Parse the .gitignore content to extract entries
112
+ const entries = parseGitignoreContent(content);
113
+
114
+ if (entries.length === 0) {
115
+ throw new Error('No entries found in boilerplate .gitignore');
116
+ }
117
+
118
+ // Cache the fetched entries
119
+ entriesCache.entries = entries;
120
+ entriesCache.lastFetch = now;
121
+
122
+ return entries;
123
+ } catch (error) {
124
+ // If fetch fails, return cached entries if available, otherwise use fallback
125
+ if (entriesCache.entries) {
126
+ return entriesCache.entries;
127
+ }
128
+
129
+ // Use fallback entries as last resort
130
+ return Array.from(FALLBACK_REQUIRED_ENTRIES);
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Normalizes a .gitignore line for comparison.
136
+ *
137
+ * This function removes leading/trailing slashes and whitespace to allow
138
+ * flexible matching of entries that may be written in different styles.
139
+ *
140
+ * Examples:
141
+ * - "/node_modules/" -> "node_modules"
142
+ * - "node_modules/" -> "node_modules"
143
+ * - " node_modules " -> "node_modules"
144
+ *
145
+ * @param {string} line - The line to normalize
146
+ * @returns {string} The normalized line
147
+ * @private
148
+ */
149
+ function normalizeLine(line) {
150
+ return line.trim().replace(/^\/+|\/+$/g, '');
151
+ }
152
+
153
+ /**
154
+ * Checks if a required entry is present in the .gitignore content.
155
+ *
156
+ * This function performs a flexible match that accounts for different ways
157
+ * the entry might be written (with or without slashes, wildcards, etc.)
158
+ *
159
+ * @param {string} requiredEntry - The required entry to look for
160
+ * @param {string[]} normalizedLines - Array of normalized .gitignore lines
161
+ * @returns {boolean} True if the entry is present
162
+ * @private
163
+ */
164
+ function isEntryPresent(requiredEntry, normalizedLines) {
165
+ const normalizedRequired = normalizeLine(requiredEntry);
166
+
167
+ return normalizedLines.some(line => {
168
+ // Exact match after normalization
169
+ if (line === normalizedRequired) {
170
+ return true;
171
+ }
172
+
173
+ // For wildcard patterns, check if the pattern is present
174
+ if (normalizedRequired.includes('*') && line === normalizedRequired) {
175
+ return true;
176
+ }
177
+
178
+ // For paths with slashes, check if the base name matches
179
+ const requiredBase = normalizedRequired.split('/').pop();
180
+ const lineBase = line.split('/').pop();
181
+
182
+ if (requiredBase === lineBase) {
183
+ return true;
184
+ }
185
+
186
+ return false;
187
+ });
188
+ }
189
+
190
+ /**
191
+ * Parses .gitignore content into an array of valid entries.
192
+ *
193
+ * This function:
194
+ * - Splits content by newlines
195
+ * - Removes empty lines
196
+ * - Removes comment lines (starting with #)
197
+ * - Trims whitespace
198
+ *
199
+ * @param {string} content - The raw .gitignore content
200
+ * @returns {string[]} Array of valid .gitignore entries
201
+ * @private
202
+ */
203
+ function parseGitignoreContent(content) {
204
+ return content
205
+ .split('\n')
206
+ .map(line => line.trim())
207
+ .filter(line => line.length > 0 && !line.startsWith('#'));
208
+ }
209
+
210
+ /**
211
+ * Validates the .gitignore file in a specification repository.
212
+ *
213
+ * This function performs a comprehensive check of the .gitignore file:
214
+ * 1. Verifies the file exists
215
+ * 2. Validates that it has content
216
+ * 3. Checks for required entries
217
+ * 4. Reports missing entries as warnings or failures
218
+ *
219
+ * @param {import('../providers.js').Provider} provider - The provider instance for repository access
220
+ * @returns {Promise<import('../health-check-utils.js').HealthCheckResult>} Result of the .gitignore validation
221
+ *
222
+ * @example
223
+ * ```javascript
224
+ * const provider = createLocalProvider('/path/to/repo');
225
+ * const result = await checkGitignore(provider);
226
+ * console.log(result.status); // 'pass', 'warn', or 'fail'
227
+ * console.log(result.message);
228
+ * console.log(result.details.missingEntries);
229
+ * ```
230
+ */
231
+ export async function checkGitignore(provider) {
232
+ try {
233
+ // Fetch the required entries from the boilerplate repository
234
+ const requiredEntries = await fetchRequiredEntries();
235
+
236
+ // Check if .gitignore file exists
237
+ const exists = await provider.fileExists('.gitignore');
238
+
239
+ if (!exists) {
240
+ return createHealthCheckResult(
241
+ CHECK_NAME,
242
+ 'fail',
243
+ '.gitignore file not found - repository should have a .gitignore file',
244
+ {
245
+ fileExists: false,
246
+ recommendation: 'Create a .gitignore file with common exclusion patterns',
247
+ boilerplateUrl: BOILERPLATE_GITIGNORE_URL
248
+ }
249
+ );
250
+ }
251
+
252
+ // Read .gitignore content
253
+ const content = await provider.readFile('.gitignore');
254
+
255
+ // Check if file has valid content
256
+ if (!content || content.trim().length === 0) {
257
+ return createHealthCheckResult(
258
+ CHECK_NAME,
259
+ 'fail',
260
+ '.gitignore file is empty - should contain exclusion patterns',
261
+ {
262
+ fileExists: true,
263
+ isEmpty: true,
264
+ recommendation: 'Add common exclusion patterns to .gitignore',
265
+ boilerplateUrl: BOILERPLATE_GITIGNORE_URL
266
+ }
267
+ );
268
+ }
269
+
270
+ // Parse .gitignore content
271
+ const lines = parseGitignoreContent(content);
272
+
273
+ if (lines.length === 0) {
274
+ return createHealthCheckResult(
275
+ CHECK_NAME,
276
+ 'fail',
277
+ '.gitignore file contains no valid entries (only comments or empty lines)',
278
+ {
279
+ fileExists: true,
280
+ hasOnlyComments: true,
281
+ recommendation: 'Add valid exclusion patterns to .gitignore',
282
+ boilerplateUrl: BOILERPLATE_GITIGNORE_URL
283
+ }
284
+ );
285
+ }
286
+
287
+ // Normalize lines for comparison
288
+ const normalizedLines = lines.map(normalizeLine);
289
+
290
+ // Check for missing required entries
291
+ const missingEntries = requiredEntries.filter(
292
+ required => !isEntryPresent(required, normalizedLines)
293
+ );
294
+
295
+ // Build details object
296
+ const details = {
297
+ fileExists: true,
298
+ totalEntries: lines.length,
299
+ requiredEntriesCount: requiredEntries.length,
300
+ presentEntriesCount: requiredEntries.length - missingEntries.length,
301
+ missingEntries: missingEntries.length > 0 ? missingEntries : undefined,
302
+ sample: lines.slice(0, 10), // Include first 10 entries as sample
303
+ boilerplateUrl: BOILERPLATE_GITIGNORE_URL,
304
+ usedFallback: entriesCache.entries === FALLBACK_REQUIRED_ENTRIES
305
+ };
306
+
307
+ // Determine status and message
308
+ if (missingEntries.length === 0) {
309
+ return createHealthCheckResult(
310
+ CHECK_NAME,
311
+ 'pass',
312
+ '.gitignore file is valid and contains all required entries',
313
+ details
314
+ );
315
+ }
316
+
317
+ // If some required entries are missing, it's a warning
318
+ const missingCount = missingEntries.length;
319
+ const message = `${missingCount} required ${missingCount === 1 ? 'entry' : 'entries'} missing from .gitignore: ${missingEntries.join(', ')}`;
320
+
321
+ return createHealthCheckResult(
322
+ CHECK_NAME,
323
+ 'warn',
324
+ message,
325
+ details
326
+ );
327
+
328
+ } catch (error) {
329
+ return createErrorResult(CHECK_NAME, error, {
330
+ context: 'checking .gitignore file',
331
+ provider: provider.type
332
+ });
333
+ }
334
+ }
335
+
336
+ /**
337
+ * Clears the cached required entries.
338
+ * This is primarily useful for testing to force a fresh fetch.
339
+ *
340
+ * @private
341
+ */
342
+ export function clearEntriesCache() {
343
+ entriesCache = {
344
+ entries: null,
345
+ lastFetch: 0
346
+ };
347
+ }
348
+
349
+ // Export the health check function as default for easy registration
350
+ export default checkGitignore;