spec-and-loop 3.3.3 → 3.3.4

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,440 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * runner-autocommit.js — Auto-commit pipeline split out of runner.js.
5
+ *
6
+ * Responsible for assembling the per-iteration staging allowlist, filtering
7
+ * gitignored paths, building the task-aware commit message, detecting
8
+ * protected OpenSpec artifacts (proposal.md / design.md / specs/**), and
9
+ * shelling out to `git add` / `git commit`. Every helper here was moved
10
+ * verbatim from runner.js — no behavior change.
11
+ *
12
+ * Conventional git tooling renders the first commit-message line in ~50–72
13
+ * columns, so we cap the subject line accordingly and preserve the full
14
+ * task descriptions in the commit body.
15
+ */
16
+
17
+ const path = require('path');
18
+ const childProcess = require('child_process');
19
+
20
+ const SUBJECT_MAX_LENGTH = 72;
21
+
22
+ function _formatAutoCommitIgnoreBlock(iteration, anomaly) {
23
+ const SEP = '================================================================================\n';
24
+ const pathLines = (anomaly.ignoredPaths || []).map(p => ` - ${p}`).join('\n');
25
+ return (
26
+ SEP +
27
+ `⚠ AUTO-COMMIT IGNORE FILTER FIRED (iteration ${iteration}, type: ${anomaly.type})\n` +
28
+ `Paths filtered because .gitignore matches:\n` +
29
+ pathLines + '\n' +
30
+ `Consequence: these paths are NOT in the latest commit.\n` +
31
+ `Remediation (pick one):\n` +
32
+ ` 1. git add -f <path> # one-time unblock, if you want it tracked\n` +
33
+ ` 2. edit .gitignore # narrow or remove the matching rule\n` +
34
+ ` 3. pass --no-auto-commit on the ralph-run invocation\n` +
35
+ SEP
36
+ );
37
+ }
38
+
39
+ /**
40
+ * Auto-commit changed files after a successful iteration.
41
+ * Silently skips if git is unavailable, there is nothing to commit, or the
42
+ * iteration did not complete any tasks.
43
+ *
44
+ * @param {number} iteration
45
+ * @param {object} opts
46
+ * @param {Array<object>} [opts.completedTasks]
47
+ * @param {Array<string>} [opts.filesToStage]
48
+ * @param {boolean} [opts.verbose]
49
+ */
50
+ function _autoCommit(iteration, opts = {}) {
51
+ const { completedTasks = [], filesToStage = [], tasksFile = null, verbose = false, reporter = null } = opts;
52
+ const message = _formatAutoCommitMessage(iteration, completedTasks);
53
+
54
+ if (!message) {
55
+ if (verbose) {
56
+ process.stderr.write('[mini-ralph] auto-commit skipped: no completed tasks detected\n');
57
+ }
58
+ return { attempted: false, committed: false, anomaly: null };
59
+ }
60
+
61
+ if (!Array.isArray(filesToStage) || filesToStage.length === 0) {
62
+ if (verbose) {
63
+ process.stderr.write('[mini-ralph] auto-commit skipped: no iteration files to stage\n');
64
+ }
65
+ return { attempted: false, committed: false, anomaly: null };
66
+ }
67
+
68
+ const protectedArtifacts = _detectProtectedCommitArtifacts(filesToStage, tasksFile);
69
+ if (protectedArtifacts.length > 0) {
70
+ const anomaly = {
71
+ type: 'protected_artifacts',
72
+ message:
73
+ 'Auto-commit blocked: loop-managed commits cannot include protected OpenSpec artifacts: ' +
74
+ protectedArtifacts.join(', '),
75
+ protectedArtifacts,
76
+ };
77
+
78
+ process.stderr.write(`[mini-ralph] warning: ${anomaly.message}\n`);
79
+ return { attempted: true, committed: false, anomaly };
80
+ }
81
+
82
+ const { kept: keptPaths, dropped: droppedPaths } = _filterGitignored(filesToStage, process.cwd());
83
+
84
+ if (droppedPaths.length > 0) {
85
+ const pathWord = droppedPaths.length === 1 ? 'path' : 'paths';
86
+ const allIgnored = keptPaths.length === 0;
87
+ const warnLines = allIgnored
88
+ ? [
89
+ `auto-commit iter ${iteration} skipped: all ${droppedPaths.length} ${pathWord} are gitignored`,
90
+ ...droppedPaths.map(p => ` - ${p}`),
91
+ ' hint: `git add -f <path>` once, or adjust .gitignore',
92
+ ].join('\n')
93
+ : [
94
+ `auto-commit iter ${iteration}: filtered ${droppedPaths.length} gitignored ${pathWord}, committing ${keptPaths.length} ${keptPaths.length === 1 ? 'other' : 'others'}`,
95
+ ...droppedPaths.map(p => ` - ${p}`),
96
+ ].join('\n');
97
+ if (reporter) {
98
+ reporter.note(warnLines, 'error');
99
+ } else {
100
+ const fallbackMsg = allIgnored
101
+ ? `Auto-commit skipped: all paths are gitignored: ${droppedPaths.join(', ')}`
102
+ : `Auto-commit filtered gitignored paths: ${droppedPaths.join(', ')}`;
103
+ process.stderr.write(`[mini-ralph] warning: ${fallbackMsg}\n`);
104
+ }
105
+ if (allIgnored) {
106
+ const anomaly = {
107
+ type: 'all_paths_ignored',
108
+ message: `Auto-commit skipped: all paths are gitignored: ${droppedPaths.join(', ')}`,
109
+ ignoredPaths: droppedPaths,
110
+ };
111
+ // task 5.1: emit loud direct stderr block, bypassing reporter dedup/buffering
112
+ process.stderr.write(_formatAutoCommitIgnoreBlock(iteration, anomaly));
113
+ return {
114
+ attempted: true,
115
+ committed: false,
116
+ anomaly,
117
+ };
118
+ }
119
+ }
120
+
121
+ const stagePaths = droppedPaths.length > 0 ? keptPaths : filesToStage;
122
+
123
+ try {
124
+ // Use `git add -A -- <paths>` (not plain `git add -- <paths>`) so deletions
125
+ // and renames are staged alongside modifications/additions. Tasks that call
126
+ // `git rm` via a shell tool leave the path absent from the working tree but
127
+ // still present in `git status --porcelain`, which means the plain form
128
+ // would error with `fatal: pathspec did not match`. Scoping to the per-path
129
+ // allowlist preserves the protected-artifact guarantee.
130
+ childProcess.execFileSync('git', ['add', '-A', '--', ...stagePaths], {
131
+ stdio: verbose ? 'inherit' : ['pipe', 'pipe', 'pipe'],
132
+ encoding: 'utf8',
133
+ });
134
+
135
+ const stagedFiles = childProcess.execFileSync('git', ['diff', '--cached', '--name-only'], {
136
+ stdio: ['pipe', 'pipe', 'pipe'],
137
+ encoding: 'utf8',
138
+ });
139
+
140
+ if (!stagedFiles.trim()) {
141
+ const anomaly = {
142
+ type: 'nothing_staged',
143
+ message: 'Auto-commit failed: nothing was staged after git add',
144
+ };
145
+
146
+ process.stderr.write(`[mini-ralph] warning: ${anomaly.message}\n`);
147
+ if (verbose) {
148
+ process.stderr.write('[mini-ralph] auto-commit skipped: nothing staged\n');
149
+ }
150
+ return { attempted: true, committed: false, anomaly };
151
+ }
152
+
153
+ childProcess.execFileSync('git', ['commit', '-m', message], {
154
+ stdio: verbose ? 'inherit' : ['pipe', 'pipe', 'pipe'],
155
+ encoding: 'utf8',
156
+ });
157
+
158
+ if (verbose) {
159
+ process.stderr.write(`[mini-ralph] auto-committed: ${message}\n`);
160
+ }
161
+ if (droppedPaths.length > 0) {
162
+ const anomaly = {
163
+ type: 'paths_ignored_filtered',
164
+ message: 'Auto-commit succeeded but filtered gitignored paths: ' + droppedPaths.join(', '),
165
+ ignoredPaths: droppedPaths,
166
+ };
167
+ // task 5.1: emit loud direct stderr block, bypassing reporter dedup/buffering
168
+ process.stderr.write(_formatAutoCommitIgnoreBlock(iteration, anomaly));
169
+ return {
170
+ attempted: true,
171
+ committed: true,
172
+ anomaly,
173
+ };
174
+ }
175
+ return { attempted: true, committed: true, anomaly: null };
176
+ } catch (err) {
177
+ const anomaly = {
178
+ type: 'commit_failed',
179
+ message: `Auto-commit failed: ${_gitErrorMessage(err)}`,
180
+ };
181
+
182
+ process.stderr.write(`[mini-ralph] warning: ${anomaly.message}\n`);
183
+ return { attempted: true, committed: false, anomaly };
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Filter gitignored paths out of a list using `git check-ignore --stdin`.
189
+ *
190
+ * Exit-code semantics of `git check-ignore`:
191
+ * 0 – at least one path is ignored; stdout lists the ignored paths.
192
+ * 1 – no paths are ignored (Node's execFileSync throws; we catch status===1).
193
+ * other / ENOENT / any thrown error – fallback: treat all paths as kept.
194
+ *
195
+ * @param {string[]} paths - Repo-relative paths to test.
196
+ * @param {string} cwd - Working directory for the git command.
197
+ * @returns {{ kept: string[], dropped: string[] }}
198
+ */
199
+ function _filterGitignored(paths, cwd) {
200
+ if (!Array.isArray(paths) || paths.length === 0) {
201
+ return { kept: [], dropped: [] };
202
+ }
203
+
204
+ try {
205
+ const stdout = childProcess.execFileSync(
206
+ 'git',
207
+ ['check-ignore', '--stdin'],
208
+ {
209
+ input: paths.join('\n'),
210
+ cwd: cwd || process.cwd(),
211
+ stdio: ['pipe', 'pipe', 'pipe'],
212
+ encoding: 'utf8',
213
+ }
214
+ );
215
+
216
+ const dropped = stdout
217
+ .split('\n')
218
+ .map((l) => l.trim())
219
+ .filter(Boolean);
220
+ const droppedSet = new Set(dropped);
221
+ const kept = paths.filter((p) => !droppedSet.has(p));
222
+ return { kept, dropped };
223
+ } catch (err) {
224
+ if (err && err.status === 1) {
225
+ return { kept: paths.slice(), dropped: [] };
226
+ }
227
+ return { kept: paths.slice(), dropped: [] };
228
+ }
229
+ }
230
+
231
+ function _mergePathLists(...lists) {
232
+ const merged = new Set();
233
+ for (const list of lists) {
234
+ for (const file of list || []) {
235
+ const relativeFile = _repoRelativePath(file);
236
+ if (relativeFile) {
237
+ merged.add(relativeFile);
238
+ }
239
+ }
240
+ }
241
+ return Array.from(merged);
242
+ }
243
+
244
+ /**
245
+ * Build the explicit per-iteration git staging allowlist.
246
+ *
247
+ * @param {Array<string>} filesChanged
248
+ * @param {Array<object>} completedTasks
249
+ * @param {string|null|undefined} tasksFile
250
+ * @returns {Array<string>}
251
+ */
252
+ function _buildAutoCommitAllowlist(filesChanged, completedTasks, tasksFile) {
253
+ const allowlist = new Set();
254
+
255
+ for (const file of filesChanged || []) {
256
+ const relativeFile = _repoRelativePath(file);
257
+ if (relativeFile) {
258
+ allowlist.add(relativeFile);
259
+ }
260
+ }
261
+
262
+ if (Array.isArray(completedTasks) && completedTasks.length > 0 && tasksFile) {
263
+ const relativeTasksFile = _repoRelativePath(tasksFile);
264
+ if (relativeTasksFile) {
265
+ allowlist.add(relativeTasksFile);
266
+ }
267
+ }
268
+
269
+ return Array.from(allowlist);
270
+ }
271
+
272
+ /**
273
+ * Return tasks that became completed during the current iteration.
274
+ *
275
+ * @param {Array<object>} beforeTasks
276
+ * @param {Array<object>} afterTasks
277
+ * @returns {Array<object>}
278
+ */
279
+ function _completedTaskDelta(beforeTasks, afterTasks) {
280
+ const beforeCompleted = new Set(
281
+ (beforeTasks || [])
282
+ .filter((task) => task.status === 'completed')
283
+ .map(_taskIdentity)
284
+ );
285
+
286
+ return (afterTasks || []).filter(
287
+ (task) => task.status === 'completed' && !beforeCompleted.has(_taskIdentity(task))
288
+ );
289
+ }
290
+
291
+ /**
292
+ * Build a task-aware commit message for an iteration.
293
+ *
294
+ * The subject line (first line) is kept short — conventional git tooling
295
+ * assumes ~50–72 characters — so `git log --oneline` stays readable even when
296
+ * the underlying task description is a multi-sentence normative blob. The
297
+ * full, untruncated task descriptions are preserved in the commit body.
298
+ *
299
+ * @param {number} iteration
300
+ * @param {Array<object>} completedTasks
301
+ * @returns {string}
302
+ */
303
+ function _formatAutoCommitMessage(iteration, completedTasks) {
304
+ if (!Array.isArray(completedTasks) || completedTasks.length === 0) {
305
+ return '';
306
+ }
307
+
308
+ const rawSummary = completedTasks.length === 1
309
+ ? completedTasks[0].description
310
+ : `complete ${completedTasks.length} tasks`;
311
+
312
+ const prefix = `Ralph iteration ${iteration}: `;
313
+ const subjectBudget = Math.max(20, SUBJECT_MAX_LENGTH - prefix.length);
314
+ const summary = _truncateSubjectSummary(rawSummary, subjectBudget);
315
+
316
+ const taskLines = completedTasks.map(
317
+ (task) => `- [x] ${task.fullDescription || task.description}`
318
+ );
319
+
320
+ return `${prefix}${summary}\n\nTasks completed:\n${taskLines.join('\n')}`;
321
+ }
322
+
323
+ /**
324
+ * Reduce a task description to a short, single-line commit subject.
325
+ *
326
+ * Strategy:
327
+ * 1. Collapse whitespace onto a single line.
328
+ * 2. Prefer the first sentence (up to `.`, `!`, `?`) when it is not itself
329
+ * longer than the allowed budget.
330
+ * 3. Otherwise hard-truncate at a word boundary and append an ellipsis.
331
+ *
332
+ * @param {string} text
333
+ * @param {number} budget
334
+ * @returns {string}
335
+ */
336
+ function _truncateSubjectSummary(text, budget) {
337
+ const oneLine = String(text == null ? '' : text).replace(/\s+/g, ' ').trim();
338
+ if (oneLine.length === 0) return '';
339
+ if (oneLine.length <= budget) return oneLine;
340
+
341
+ const sentenceMatch = oneLine.match(/^(.+?[.!?])(\s|$)/);
342
+ if (sentenceMatch) {
343
+ const candidate = sentenceMatch[1].trim();
344
+ if (candidate.length > 0 && candidate.length <= budget) {
345
+ return candidate;
346
+ }
347
+ }
348
+
349
+ const ellipsis = '…';
350
+ const hardBudget = Math.max(1, budget - ellipsis.length);
351
+ const sliced = oneLine.slice(0, hardBudget);
352
+ const lastSpace = sliced.lastIndexOf(' ');
353
+ const cut = lastSpace > Math.floor(hardBudget / 2) ? sliced.slice(0, lastSpace) : sliced;
354
+ return `${cut.replace(/[\s,;:.!?-]+$/, '')}${ellipsis}`;
355
+ }
356
+
357
+ function _taskIdentity(task) {
358
+ return task.number
359
+ ? `${task.number}|${task.fullDescription || task.description}`
360
+ : (task.fullDescription || task.description);
361
+ }
362
+
363
+ function _repoRelativePath(filePath) {
364
+ if (!filePath || typeof filePath !== 'string') return '';
365
+ const normalized = path.normalize(filePath);
366
+ if (!normalized || normalized === '.') return '';
367
+ const relative = path.isAbsolute(normalized)
368
+ ? path.relative(process.cwd(), normalized)
369
+ : normalized;
370
+
371
+ if (!relative || relative.startsWith('..')) {
372
+ return '';
373
+ }
374
+
375
+ return relative.split(path.sep).join('/');
376
+ }
377
+
378
+ function _detectProtectedCommitArtifacts(filesToStage, tasksFile) {
379
+ if (!Array.isArray(filesToStage) || filesToStage.length === 0 || !tasksFile) {
380
+ return [];
381
+ }
382
+
383
+ const relativeTasksFile = _repoRelativePath(tasksFile);
384
+ if (!relativeTasksFile) {
385
+ return [];
386
+ }
387
+
388
+ const changeRoot = path.posix.dirname(relativeTasksFile);
389
+ const protectedArtifacts = [];
390
+
391
+ for (const file of filesToStage) {
392
+ const normalized = _repoRelativePath(file);
393
+ if (!normalized) continue;
394
+
395
+ const isProposal = normalized === `${changeRoot}/proposal.md`;
396
+ const isDesign = normalized === `${changeRoot}/design.md`;
397
+ const isSpec = normalized.startsWith(`${changeRoot}/specs/`) && normalized.endsWith('/spec.md');
398
+
399
+ if (isProposal || isDesign || isSpec) {
400
+ protectedArtifacts.push(normalized);
401
+ }
402
+ }
403
+
404
+ return protectedArtifacts;
405
+ }
406
+
407
+ function _gitErrorMessage(err) {
408
+ if (!err) return 'unknown git error';
409
+
410
+ const stderr = _coerceGitErrorStream(err.stderr);
411
+ const stdout = _coerceGitErrorStream(err.stdout);
412
+
413
+ if (stderr) return stderr;
414
+ if (stdout) return stdout;
415
+ if (err.message) return err.message;
416
+ return 'unknown git error';
417
+ }
418
+
419
+ function _coerceGitErrorStream(stream) {
420
+ if (!stream) return '';
421
+ if (Buffer.isBuffer(stream)) return stream.toString('utf8').trim();
422
+ if (typeof stream === 'string') return stream.trim();
423
+ return '';
424
+ }
425
+
426
+ module.exports = {
427
+ _autoCommit,
428
+ _formatAutoCommitIgnoreBlock,
429
+ _filterGitignored,
430
+ _mergePathLists,
431
+ _buildAutoCommitAllowlist,
432
+ _completedTaskDelta,
433
+ _formatAutoCommitMessage,
434
+ _truncateSubjectSummary,
435
+ _taskIdentity,
436
+ _repoRelativePath,
437
+ _detectProtectedCommitArtifacts,
438
+ _gitErrorMessage,
439
+ _coerceGitErrorStream,
440
+ };