deepflow 0.1.89 → 0.1.91

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,869 @@
1
+ /**
2
+ * Tests for bin/ratchet.js — mechanical ratchet health-check script.
3
+ *
4
+ * Tests cover:
5
+ * 1. Project type detection from indicator files
6
+ * 2. Config override loading from .deepflow/config.yaml
7
+ * 3. Snapshot file loading and path absolutization
8
+ * 4. Command parsing (tokenizer)
9
+ * 5. Command building per project type
10
+ * 6. Health check stage ordering
11
+ * 7. JSON output format and exit codes
12
+ * 8. Source-level structural assertions
13
+ *
14
+ * Uses Node.js built-in node:test to avoid adding dependencies.
15
+ */
16
+
17
+ 'use strict';
18
+
19
+ const { test, describe, beforeEach, afterEach } = require('node:test');
20
+ const assert = require('node:assert/strict');
21
+ const fs = require('node:fs');
22
+ const path = require('node:path');
23
+ const os = require('node:os');
24
+ const { execFileSync, spawnSync } = require('node:child_process');
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Helpers
28
+ // ---------------------------------------------------------------------------
29
+
30
+ const RATCHET_PATH = path.resolve(__dirname, 'ratchet.js');
31
+ const RATCHET_SRC = fs.readFileSync(RATCHET_PATH, 'utf8');
32
+
33
+ function makeTmpDir() {
34
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'df-ratchet-test-'));
35
+ }
36
+
37
+ function rmrf(dir) {
38
+ if (fs.existsSync(dir)) {
39
+ fs.rmSync(dir, { recursive: true, force: true });
40
+ }
41
+ }
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Extract pure functions from ratchet.js source for unit testing.
45
+ // We eval the module source with main() replaced by a no-op, then capture exports.
46
+ // ---------------------------------------------------------------------------
47
+
48
+ const extractedFns = (() => {
49
+ // Replace the main() call at the end with exports
50
+ const modifiedSrc = RATCHET_SRC
51
+ .replace(/^main\(\);?\s*$/m, '') // Remove the main() call
52
+ .replace(/^#!.*$/m, ''); // Remove shebang
53
+
54
+ // Wrap in a function that returns the internal functions
55
+ const wrapped = `
56
+ ${modifiedSrc}
57
+ return {
58
+ detectProjectType,
59
+ loadConfig,
60
+ loadSnapshotFiles,
61
+ parseCommand,
62
+ hasNpmScript,
63
+ buildCommands,
64
+ };
65
+ `;
66
+
67
+ const factory = new Function('require', 'process', '__dirname', '__filename', 'module', 'exports', wrapped);
68
+ return factory(require, process, __dirname, __filename, module, exports);
69
+ })();
70
+
71
+ const {
72
+ detectProjectType,
73
+ loadConfig,
74
+ loadSnapshotFiles,
75
+ parseCommand,
76
+ hasNpmScript,
77
+ buildCommands,
78
+ } = extractedFns;
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // 1. Project type detection
82
+ // ---------------------------------------------------------------------------
83
+
84
+ describe('detectProjectType — detects from indicator files', () => {
85
+ let tmpDir;
86
+
87
+ beforeEach(() => { tmpDir = makeTmpDir(); });
88
+ afterEach(() => { rmrf(tmpDir); });
89
+
90
+ test('detects node project from package.json', () => {
91
+ fs.writeFileSync(path.join(tmpDir, 'package.json'), '{}');
92
+ assert.equal(detectProjectType(tmpDir), 'node');
93
+ });
94
+
95
+ test('detects python project from pyproject.toml', () => {
96
+ fs.writeFileSync(path.join(tmpDir, 'pyproject.toml'), '[project]');
97
+ assert.equal(detectProjectType(tmpDir), 'python');
98
+ });
99
+
100
+ test('detects rust project from Cargo.toml', () => {
101
+ fs.writeFileSync(path.join(tmpDir, 'Cargo.toml'), '[package]');
102
+ assert.equal(detectProjectType(tmpDir), 'rust');
103
+ });
104
+
105
+ test('detects go project from go.mod', () => {
106
+ fs.writeFileSync(path.join(tmpDir, 'go.mod'), 'module example');
107
+ assert.equal(detectProjectType(tmpDir), 'go');
108
+ });
109
+
110
+ test('returns unknown when no indicator files present', () => {
111
+ assert.equal(detectProjectType(tmpDir), 'unknown');
112
+ });
113
+
114
+ test('prefers node over python when both exist (package.json checked first)', () => {
115
+ fs.writeFileSync(path.join(tmpDir, 'package.json'), '{}');
116
+ fs.writeFileSync(path.join(tmpDir, 'pyproject.toml'), '[project]');
117
+ assert.equal(detectProjectType(tmpDir), 'node');
118
+ });
119
+ });
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // 2. Config override loading
123
+ // ---------------------------------------------------------------------------
124
+
125
+ describe('loadConfig — reads .deepflow/config.yaml ratchet section', () => {
126
+ let tmpDir;
127
+
128
+ beforeEach(() => { tmpDir = makeTmpDir(); });
129
+ afterEach(() => { rmrf(tmpDir); });
130
+
131
+ test('returns empty object when config file does not exist', () => {
132
+ const cfg = loadConfig(tmpDir);
133
+ assert.deepEqual(cfg, {});
134
+ });
135
+
136
+ test('returns empty object when config has no matching keys', () => {
137
+ const deepflowDir = path.join(tmpDir, '.deepflow');
138
+ fs.mkdirSync(deepflowDir, { recursive: true });
139
+ fs.writeFileSync(
140
+ path.join(deepflowDir, 'config.yaml'),
141
+ 'some_other_key: value\nmax_retries: 3\n'
142
+ );
143
+ const cfg = loadConfig(tmpDir);
144
+ assert.deepEqual(cfg, {});
145
+ });
146
+
147
+ test('parses build_command', () => {
148
+ const deepflowDir = path.join(tmpDir, '.deepflow');
149
+ fs.mkdirSync(deepflowDir, { recursive: true });
150
+ fs.writeFileSync(
151
+ path.join(deepflowDir, 'config.yaml'),
152
+ 'build_command: make build\n'
153
+ );
154
+ const cfg = loadConfig(tmpDir);
155
+ assert.equal(cfg.build_command, 'make build');
156
+ });
157
+
158
+ test('parses test_command', () => {
159
+ const deepflowDir = path.join(tmpDir, '.deepflow');
160
+ fs.mkdirSync(deepflowDir, { recursive: true });
161
+ fs.writeFileSync(
162
+ path.join(deepflowDir, 'config.yaml'),
163
+ 'test_command: pytest -v\n'
164
+ );
165
+ const cfg = loadConfig(tmpDir);
166
+ assert.equal(cfg.test_command, 'pytest -v');
167
+ });
168
+
169
+ test('parses typecheck_command', () => {
170
+ const deepflowDir = path.join(tmpDir, '.deepflow');
171
+ fs.mkdirSync(deepflowDir, { recursive: true });
172
+ fs.writeFileSync(
173
+ path.join(deepflowDir, 'config.yaml'),
174
+ 'typecheck_command: mypy src/\n'
175
+ );
176
+ const cfg = loadConfig(tmpDir);
177
+ assert.equal(cfg.typecheck_command, 'mypy src/');
178
+ });
179
+
180
+ test('parses lint_command', () => {
181
+ const deepflowDir = path.join(tmpDir, '.deepflow');
182
+ fs.mkdirSync(deepflowDir, { recursive: true });
183
+ fs.writeFileSync(
184
+ path.join(deepflowDir, 'config.yaml'),
185
+ 'lint_command: eslint .\n'
186
+ );
187
+ const cfg = loadConfig(tmpDir);
188
+ assert.equal(cfg.lint_command, 'eslint .');
189
+ });
190
+
191
+ test('parses all four commands from a single config', () => {
192
+ const deepflowDir = path.join(tmpDir, '.deepflow');
193
+ fs.mkdirSync(deepflowDir, { recursive: true });
194
+ fs.writeFileSync(
195
+ path.join(deepflowDir, 'config.yaml'),
196
+ [
197
+ 'build_command: npm run build',
198
+ 'test_command: npm test',
199
+ 'typecheck_command: tsc --noEmit',
200
+ 'lint_command: eslint src/',
201
+ ].join('\n') + '\n'
202
+ );
203
+ const cfg = loadConfig(tmpDir);
204
+ assert.equal(cfg.build_command, 'npm run build');
205
+ assert.equal(cfg.test_command, 'npm test');
206
+ assert.equal(cfg.typecheck_command, 'tsc --noEmit');
207
+ assert.equal(cfg.lint_command, 'eslint src/');
208
+ });
209
+
210
+ test('handles quoted values', () => {
211
+ const deepflowDir = path.join(tmpDir, '.deepflow');
212
+ fs.mkdirSync(deepflowDir, { recursive: true });
213
+ fs.writeFileSync(
214
+ path.join(deepflowDir, 'config.yaml'),
215
+ "build_command: 'make all'\n"
216
+ );
217
+ const cfg = loadConfig(tmpDir);
218
+ assert.equal(cfg.build_command, 'make all');
219
+ });
220
+
221
+ test('handles double-quoted values', () => {
222
+ const deepflowDir = path.join(tmpDir, '.deepflow');
223
+ fs.mkdirSync(deepflowDir, { recursive: true });
224
+ fs.writeFileSync(
225
+ path.join(deepflowDir, 'config.yaml'),
226
+ 'build_command: "cargo build --release"\n'
227
+ );
228
+ const cfg = loadConfig(tmpDir);
229
+ assert.equal(cfg.build_command, 'cargo build --release');
230
+ });
231
+
232
+ test('ignores keys embedded in other lines (not at start of line)', () => {
233
+ const deepflowDir = path.join(tmpDir, '.deepflow');
234
+ fs.mkdirSync(deepflowDir, { recursive: true });
235
+ fs.writeFileSync(
236
+ path.join(deepflowDir, 'config.yaml'),
237
+ '# build_command: should not match\nfoo_build_command: nope\n'
238
+ );
239
+ const cfg = loadConfig(tmpDir);
240
+ assert.equal(cfg.build_command, undefined);
241
+ });
242
+ });
243
+
244
+ // ---------------------------------------------------------------------------
245
+ // 3. Snapshot file loading and path absolutization
246
+ // ---------------------------------------------------------------------------
247
+
248
+ describe('loadSnapshotFiles — reads auto-snapshot.txt and absolutizes paths', () => {
249
+ let tmpDir;
250
+
251
+ beforeEach(() => { tmpDir = makeTmpDir(); });
252
+ afterEach(() => { rmrf(tmpDir); });
253
+
254
+ test('returns empty array when snapshot file does not exist', () => {
255
+ const files = loadSnapshotFiles(tmpDir);
256
+ assert.deepEqual(files, []);
257
+ });
258
+
259
+ test('returns empty array when snapshot file is empty', () => {
260
+ const deepflowDir = path.join(tmpDir, '.deepflow');
261
+ fs.mkdirSync(deepflowDir, { recursive: true });
262
+ fs.writeFileSync(path.join(deepflowDir, 'auto-snapshot.txt'), '');
263
+ const files = loadSnapshotFiles(tmpDir);
264
+ assert.deepEqual(files, []);
265
+ });
266
+
267
+ test('absolutizes relative paths using repo root', () => {
268
+ const deepflowDir = path.join(tmpDir, '.deepflow');
269
+ fs.mkdirSync(deepflowDir, { recursive: true });
270
+ fs.writeFileSync(
271
+ path.join(deepflowDir, 'auto-snapshot.txt'),
272
+ 'bin/install.test.js\ntest/integration.test.js\n'
273
+ );
274
+ const files = loadSnapshotFiles(tmpDir);
275
+ assert.equal(files.length, 2);
276
+ assert.equal(files[0], path.join(tmpDir, 'bin/install.test.js'));
277
+ assert.equal(files[1], path.join(tmpDir, 'test/integration.test.js'));
278
+ });
279
+
280
+ test('trims whitespace from entries', () => {
281
+ const deepflowDir = path.join(tmpDir, '.deepflow');
282
+ fs.mkdirSync(deepflowDir, { recursive: true });
283
+ fs.writeFileSync(
284
+ path.join(deepflowDir, 'auto-snapshot.txt'),
285
+ ' test/a.js \n test/b.js \n'
286
+ );
287
+ const files = loadSnapshotFiles(tmpDir);
288
+ assert.equal(files.length, 2);
289
+ assert.equal(files[0], path.join(tmpDir, 'test/a.js'));
290
+ assert.equal(files[1], path.join(tmpDir, 'test/b.js'));
291
+ });
292
+
293
+ test('ignores blank lines', () => {
294
+ const deepflowDir = path.join(tmpDir, '.deepflow');
295
+ fs.mkdirSync(deepflowDir, { recursive: true });
296
+ fs.writeFileSync(
297
+ path.join(deepflowDir, 'auto-snapshot.txt'),
298
+ 'a.js\n\n\nb.js\n\n'
299
+ );
300
+ const files = loadSnapshotFiles(tmpDir);
301
+ assert.equal(files.length, 2);
302
+ });
303
+
304
+ test('handles single entry', () => {
305
+ const deepflowDir = path.join(tmpDir, '.deepflow');
306
+ fs.mkdirSync(deepflowDir, { recursive: true });
307
+ fs.writeFileSync(
308
+ path.join(deepflowDir, 'auto-snapshot.txt'),
309
+ 'only-one.test.js\n'
310
+ );
311
+ const files = loadSnapshotFiles(tmpDir);
312
+ assert.equal(files.length, 1);
313
+ assert.equal(files[0], path.join(tmpDir, 'only-one.test.js'));
314
+ });
315
+ });
316
+
317
+ // ---------------------------------------------------------------------------
318
+ // 4. Command parsing (tokenizer)
319
+ // ---------------------------------------------------------------------------
320
+
321
+ describe('parseCommand — tokenizes command strings', () => {
322
+ test('splits simple command into tokens', () => {
323
+ assert.deepEqual(parseCommand('npm run build'), ['npm', 'run', 'build']);
324
+ });
325
+
326
+ test('handles single command with no args', () => {
327
+ assert.deepEqual(parseCommand('pytest'), ['pytest']);
328
+ });
329
+
330
+ test('handles double-quoted arguments', () => {
331
+ assert.deepEqual(
332
+ parseCommand('echo "hello world" foo'),
333
+ ['echo', 'hello world', 'foo']
334
+ );
335
+ });
336
+
337
+ test('handles single-quoted arguments', () => {
338
+ assert.deepEqual(
339
+ parseCommand("echo 'hello world' bar"),
340
+ ['echo', 'hello world', 'bar']
341
+ );
342
+ });
343
+
344
+ test('handles multiple spaces between tokens', () => {
345
+ assert.deepEqual(
346
+ parseCommand('npm run test'),
347
+ ['npm', 'run', 'test']
348
+ );
349
+ });
350
+
351
+ test('handles empty string', () => {
352
+ assert.deepEqual(parseCommand(''), []);
353
+ });
354
+
355
+ test('handles command with flags', () => {
356
+ assert.deepEqual(
357
+ parseCommand('npx tsc --noEmit'),
358
+ ['npx', 'tsc', '--noEmit']
359
+ );
360
+ });
361
+
362
+ test('handles complex command with paths', () => {
363
+ assert.deepEqual(
364
+ parseCommand('node --test /path/to/file.js'),
365
+ ['node', '--test', '/path/to/file.js']
366
+ );
367
+ });
368
+ });
369
+
370
+ // ---------------------------------------------------------------------------
371
+ // 5. hasNpmScript
372
+ // ---------------------------------------------------------------------------
373
+
374
+ describe('hasNpmScript — checks package.json for scripts', () => {
375
+ let tmpDir;
376
+
377
+ beforeEach(() => { tmpDir = makeTmpDir(); });
378
+ afterEach(() => { rmrf(tmpDir); });
379
+
380
+ test('returns true when script exists', () => {
381
+ fs.writeFileSync(
382
+ path.join(tmpDir, 'package.json'),
383
+ JSON.stringify({ scripts: { build: 'tsc', test: 'jest' } })
384
+ );
385
+ assert.equal(hasNpmScript(tmpDir, 'build'), true);
386
+ assert.equal(hasNpmScript(tmpDir, 'test'), true);
387
+ });
388
+
389
+ test('returns false when script does not exist', () => {
390
+ fs.writeFileSync(
391
+ path.join(tmpDir, 'package.json'),
392
+ JSON.stringify({ scripts: { build: 'tsc' } })
393
+ );
394
+ assert.equal(hasNpmScript(tmpDir, 'lint'), false);
395
+ });
396
+
397
+ test('returns false when no scripts section', () => {
398
+ fs.writeFileSync(
399
+ path.join(tmpDir, 'package.json'),
400
+ JSON.stringify({ name: 'test' })
401
+ );
402
+ assert.equal(hasNpmScript(tmpDir, 'build'), false);
403
+ });
404
+
405
+ test('returns false when package.json does not exist', () => {
406
+ assert.equal(hasNpmScript(tmpDir, 'build'), false);
407
+ });
408
+
409
+ test('returns false when package.json is invalid JSON', () => {
410
+ fs.writeFileSync(path.join(tmpDir, 'package.json'), 'not json{{{');
411
+ assert.equal(hasNpmScript(tmpDir, 'build'), false);
412
+ });
413
+ });
414
+
415
+ // ---------------------------------------------------------------------------
416
+ // 6. buildCommands — per project type
417
+ // ---------------------------------------------------------------------------
418
+
419
+ describe('buildCommands — node project', () => {
420
+ let tmpDir;
421
+
422
+ beforeEach(() => { tmpDir = makeTmpDir(); });
423
+ afterEach(() => { rmrf(tmpDir); });
424
+
425
+ test('uses npm run build when package.json has build script', () => {
426
+ fs.writeFileSync(
427
+ path.join(tmpDir, 'package.json'),
428
+ JSON.stringify({ scripts: { build: 'tsc' } })
429
+ );
430
+ const cmds = buildCommands(tmpDir, 'node', [], {});
431
+ assert.equal(cmds.build, 'npm run build');
432
+ });
433
+
434
+ test('does not set build when no build script in package.json', () => {
435
+ fs.writeFileSync(
436
+ path.join(tmpDir, 'package.json'),
437
+ JSON.stringify({ scripts: {} })
438
+ );
439
+ const cmds = buildCommands(tmpDir, 'node', [], {});
440
+ assert.equal(cmds.build, undefined);
441
+ });
442
+
443
+ test('uses snapshot files for test command when available', () => {
444
+ const snapshotFiles = ['/abs/path/to/test1.js', '/abs/path/to/test2.js'];
445
+ const cmds = buildCommands(tmpDir, 'node', snapshotFiles, {});
446
+ assert.ok(Array.isArray(cmds.test));
447
+ assert.deepEqual(cmds.test, ['node', '--test', ...snapshotFiles]);
448
+ });
449
+
450
+ test('does not set test when no snapshot files and no config', () => {
451
+ const cmds = buildCommands(tmpDir, 'node', [], {});
452
+ assert.equal(cmds.test, undefined);
453
+ });
454
+
455
+ test('sets typecheck to npx tsc --noEmit by default', () => {
456
+ const cmds = buildCommands(tmpDir, 'node', [], {});
457
+ assert.equal(cmds.typecheck, 'npx tsc --noEmit');
458
+ });
459
+
460
+ test('uses npm run lint when package.json has lint script', () => {
461
+ fs.writeFileSync(
462
+ path.join(tmpDir, 'package.json'),
463
+ JSON.stringify({ scripts: { lint: 'eslint .' } })
464
+ );
465
+ const cmds = buildCommands(tmpDir, 'node', [], {});
466
+ assert.equal(cmds.lint, 'npm run lint');
467
+ });
468
+
469
+ test('config overrides take precedence over auto-detection', () => {
470
+ fs.writeFileSync(
471
+ path.join(tmpDir, 'package.json'),
472
+ JSON.stringify({ scripts: { build: 'tsc', lint: 'eslint .' } })
473
+ );
474
+ const cfg = {
475
+ build_command: 'custom-build',
476
+ test_command: 'custom-test',
477
+ typecheck_command: 'custom-typecheck',
478
+ lint_command: 'custom-lint',
479
+ };
480
+ const cmds = buildCommands(tmpDir, 'node', ['/a.js'], cfg);
481
+ assert.equal(cmds.build, 'custom-build');
482
+ assert.equal(cmds.test, 'custom-test');
483
+ assert.equal(cmds.typecheck, 'custom-typecheck');
484
+ assert.equal(cmds.lint, 'custom-lint');
485
+ });
486
+ });
487
+
488
+ describe('buildCommands — python project', () => {
489
+ test('uses pytest with snapshot files when available', () => {
490
+ const snapshotFiles = ['/tests/test_a.py', '/tests/test_b.py'];
491
+ const cmds = buildCommands('/tmp', 'python', snapshotFiles, {});
492
+ assert.ok(Array.isArray(cmds.test));
493
+ assert.deepEqual(cmds.test, ['pytest', ...snapshotFiles]);
494
+ });
495
+
496
+ test('falls back to bare pytest when no snapshot files', () => {
497
+ const cmds = buildCommands('/tmp', 'python', [], {});
498
+ assert.equal(cmds.test, 'pytest');
499
+ });
500
+
501
+ test('sets mypy as default typecheck', () => {
502
+ const cmds = buildCommands('/tmp', 'python', [], {});
503
+ assert.equal(cmds.typecheck, 'mypy .');
504
+ });
505
+
506
+ test('sets ruff as default lint', () => {
507
+ const cmds = buildCommands('/tmp', 'python', [], {});
508
+ assert.equal(cmds.lint, 'ruff check .');
509
+ });
510
+
511
+ test('no build command by default', () => {
512
+ const cmds = buildCommands('/tmp', 'python', [], {});
513
+ assert.equal(cmds.build, undefined);
514
+ });
515
+ });
516
+
517
+ describe('buildCommands — rust project', () => {
518
+ test('sets cargo defaults', () => {
519
+ const cmds = buildCommands('/tmp', 'rust', [], {});
520
+ assert.equal(cmds.build, 'cargo build');
521
+ assert.equal(cmds.test, 'cargo test');
522
+ assert.equal(cmds.lint, 'cargo clippy');
523
+ });
524
+
525
+ test('no typecheck by default (cargo build covers it)', () => {
526
+ const cmds = buildCommands('/tmp', 'rust', [], {});
527
+ assert.equal(cmds.typecheck, undefined);
528
+ });
529
+ });
530
+
531
+ describe('buildCommands — go project', () => {
532
+ test('sets go defaults', () => {
533
+ const cmds = buildCommands('/tmp', 'go', [], {});
534
+ assert.equal(cmds.build, 'go build ./...');
535
+ assert.equal(cmds.test, 'go test ./...');
536
+ assert.equal(cmds.lint, 'go vet ./...');
537
+ });
538
+
539
+ test('no typecheck by default', () => {
540
+ const cmds = buildCommands('/tmp', 'go', [], {});
541
+ assert.equal(cmds.typecheck, undefined);
542
+ });
543
+ });
544
+
545
+ describe('buildCommands — unknown project type', () => {
546
+ test('returns empty commands with no config', () => {
547
+ const cmds = buildCommands('/tmp', 'unknown', [], {});
548
+ assert.equal(cmds.build, undefined);
549
+ assert.equal(cmds.test, undefined);
550
+ assert.equal(cmds.typecheck, undefined);
551
+ assert.equal(cmds.lint, undefined);
552
+ });
553
+
554
+ test('uses config overrides when provided', () => {
555
+ const cfg = {
556
+ build_command: 'make',
557
+ test_command: 'make test',
558
+ lint_command: 'make lint',
559
+ };
560
+ const cmds = buildCommands('/tmp', 'unknown', [], cfg);
561
+ assert.equal(cmds.build, 'make');
562
+ assert.equal(cmds.test, 'make test');
563
+ assert.equal(cmds.lint, 'make lint');
564
+ });
565
+ });
566
+
567
+ // ---------------------------------------------------------------------------
568
+ // 7. Health check stage ordering — source assertions
569
+ // ---------------------------------------------------------------------------
570
+
571
+ describe('STAGE_ORDER — build, test, typecheck, lint', () => {
572
+ test('source defines stages in correct order', () => {
573
+ const match = RATCHET_SRC.match(/STAGE_ORDER\s*=\s*\[([^\]]+)\]/);
574
+ assert.ok(match, 'STAGE_ORDER constant should exist in source');
575
+ const stages = match[1].replace(/['"]/g, '').split(',').map(s => s.trim());
576
+ assert.deepEqual(stages, ['build', 'test', 'typecheck', 'lint']);
577
+ });
578
+
579
+ test('only lint is SALVAGEABLE', () => {
580
+ const match = RATCHET_SRC.match(/SALVAGEABLE_STAGES\s*=\s*new Set\(\[([^\]]+)\]\)/);
581
+ assert.ok(match, 'SALVAGEABLE_STAGES constant should exist in source');
582
+ const stages = match[1].replace(/['"]/g, '').split(',').map(s => s.trim());
583
+ assert.deepEqual(stages, ['lint']);
584
+ });
585
+ });
586
+
587
+ // ---------------------------------------------------------------------------
588
+ // 8. JSON output format — source assertions
589
+ // ---------------------------------------------------------------------------
590
+
591
+ describe('JSON output — exactly one line with correct structure', () => {
592
+ test('PASS output is {"result":"PASS"}', () => {
593
+ assert.ok(
594
+ RATCHET_SRC.includes("JSON.stringify({ result: 'PASS' })"),
595
+ 'Source should output {"result":"PASS"} for success'
596
+ );
597
+ });
598
+
599
+ test('FAIL output includes result, stage, and log', () => {
600
+ assert.ok(
601
+ RATCHET_SRC.includes("JSON.stringify({ result: 'FAIL', stage, log })"),
602
+ 'Source should output {"result":"FAIL","stage":"...","log":"..."} for failures'
603
+ );
604
+ });
605
+
606
+ test('SALVAGEABLE output includes result, stage, and log', () => {
607
+ assert.ok(
608
+ RATCHET_SRC.includes("JSON.stringify({ result: 'SALVAGEABLE', stage, log })"),
609
+ 'Source should output {"result":"SALVAGEABLE","stage":"...","log":"..."} for salvageable'
610
+ );
611
+ });
612
+
613
+ test('all outputs end with newline', () => {
614
+ const outputLines = RATCHET_SRC.match(/process\.stdout\.write\(JSON\.stringify\([^)]+\)\s*\+\s*'\\n'\)/g);
615
+ assert.ok(outputLines, 'Should have stdout.write calls with JSON');
616
+ assert.equal(outputLines.length, 3, 'Should have exactly 3 JSON output lines (PASS, FAIL, SALVAGEABLE)');
617
+ });
618
+ });
619
+
620
+ // ---------------------------------------------------------------------------
621
+ // 9. Exit codes — 0=PASS, 1=FAIL, 2=SALVAGEABLE
622
+ // ---------------------------------------------------------------------------
623
+
624
+ describe('Exit codes — source assertions', () => {
625
+ test('PASS exits with 0', () => {
626
+ // Find the PASS block — it should call process.exit(0)
627
+ const passSection = RATCHET_SRC.match(/result:\s*'PASS'[\s\S]{0,100}process\.exit\((\d+)\)/);
628
+ assert.ok(passSection, 'PASS section should have process.exit');
629
+ assert.equal(passSection[1], '0');
630
+ });
631
+
632
+ test('FAIL exits with 1', () => {
633
+ const failSection = RATCHET_SRC.match(/result:\s*'FAIL'[\s\S]{0,100}process\.exit\((\d+)\)/);
634
+ assert.ok(failSection, 'FAIL section should have process.exit');
635
+ assert.equal(failSection[1], '1');
636
+ });
637
+
638
+ test('SALVAGEABLE exits with 2', () => {
639
+ const salvSection = RATCHET_SRC.match(/result:\s*'SALVAGEABLE'[\s\S]{0,100}process\.exit\((\d+)\)/);
640
+ assert.ok(salvSection, 'SALVAGEABLE section should have process.exit');
641
+ assert.equal(salvSection[1], '2');
642
+ });
643
+ });
644
+
645
+ // ---------------------------------------------------------------------------
646
+ // 10. Auto-revert on FAIL — source assertions
647
+ // ---------------------------------------------------------------------------
648
+
649
+ describe('Auto-revert — source assertions', () => {
650
+ test('autoRevert function runs git revert HEAD --no-edit', () => {
651
+ assert.ok(
652
+ RATCHET_SRC.includes("'revert', 'HEAD', '--no-edit'"),
653
+ 'autoRevert should call git revert HEAD --no-edit'
654
+ );
655
+ });
656
+
657
+ test('autoRevert is called before FAIL output (not for SALVAGEABLE)', () => {
658
+ // FAIL path: autoRevert(cwd) then stdout.write FAIL then exit(1)
659
+ const failBlock = RATCHET_SRC.match(/autoRevert\(cwd\)[\s\S]*?result:\s*'FAIL'/);
660
+ assert.ok(failBlock, 'autoRevert should be called before FAIL output');
661
+
662
+ // SALVAGEABLE path should NOT call autoRevert
663
+ // Extract text between SALVAGEABLE_STAGES.has(stage)) { and the closing else {
664
+ const salvIdx = RATCHET_SRC.indexOf('SALVAGEABLE_STAGES.has(stage)');
665
+ assert.ok(salvIdx !== -1, 'SALVAGEABLE_STAGES.has(stage) should exist in source');
666
+ const elseIdx = RATCHET_SRC.indexOf('} else {', salvIdx);
667
+ assert.ok(elseIdx !== -1, 'else block after SALVAGEABLE check should exist');
668
+ const salvBlock = RATCHET_SRC.slice(salvIdx, elseIdx);
669
+ assert.ok(
670
+ !salvBlock.includes('autoRevert'),
671
+ 'SALVAGEABLE path should NOT call autoRevert'
672
+ );
673
+ });
674
+ });
675
+
676
+ // ---------------------------------------------------------------------------
677
+ // 11. Skip behavior — ENOENT handling
678
+ // ---------------------------------------------------------------------------
679
+
680
+ describe('Skip behavior — source assertions', () => {
681
+ test('runCommand returns ok:null on spawn error (ENOENT)', () => {
682
+ // Verify source handles result.error with ok: null
683
+ assert.ok(
684
+ RATCHET_SRC.includes('ok: null'),
685
+ 'runCommand should return ok: null on spawn error'
686
+ );
687
+ });
688
+
689
+ test('main loop skips stage when ok is null', () => {
690
+ assert.ok(
691
+ RATCHET_SRC.includes('ok === null'),
692
+ 'Main loop should check for ok === null to skip stages'
693
+ );
694
+ });
695
+
696
+ test('commandExists check runs before command execution for string commands', () => {
697
+ assert.ok(
698
+ RATCHET_SRC.includes('commandExists'),
699
+ 'Source should use commandExists to check executables'
700
+ );
701
+ });
702
+ });
703
+
704
+ // ---------------------------------------------------------------------------
705
+ // 12. Subprocess integration tests — run ratchet.js in controlled environments
706
+ // ---------------------------------------------------------------------------
707
+
708
+ describe('Subprocess integration — controlled execution', () => {
709
+ let tmpDir;
710
+
711
+ beforeEach(() => {
712
+ tmpDir = makeTmpDir();
713
+ // Initialize a minimal git repo so the script can find repo root
714
+ execFileSync('git', ['init'], { cwd: tmpDir, stdio: 'ignore' });
715
+ execFileSync('git', ['config', 'user.email', 'test@test.com'], { cwd: tmpDir, stdio: 'ignore' });
716
+ execFileSync('git', ['config', 'user.name', 'Test'], { cwd: tmpDir, stdio: 'ignore' });
717
+ });
718
+
719
+ afterEach(() => { rmrf(tmpDir); });
720
+
721
+ test('outputs valid JSON for unknown project type (PASS when no commands)', () => {
722
+ // No indicator files -> unknown project type -> no commands -> PASS
723
+ // Need at least one commit for git to work
724
+ fs.writeFileSync(path.join(tmpDir, 'dummy.txt'), 'hello');
725
+ execFileSync('git', ['add', '.'], { cwd: tmpDir, stdio: 'ignore' });
726
+ execFileSync('git', ['commit', '-m', 'init'], { cwd: tmpDir, stdio: 'ignore' });
727
+
728
+ const result = execFileSync(process.execPath, [RATCHET_PATH], {
729
+ cwd: tmpDir,
730
+ encoding: 'utf8',
731
+ stdio: ['ignore', 'pipe', 'pipe'],
732
+ });
733
+
734
+ const parsed = JSON.parse(result.trim());
735
+ assert.equal(parsed.result, 'PASS');
736
+ });
737
+
738
+ test('exit code is 0 for PASS', () => {
739
+ fs.writeFileSync(path.join(tmpDir, 'dummy.txt'), 'hello');
740
+ execFileSync('git', ['add', '.'], { cwd: tmpDir, stdio: 'ignore' });
741
+ execFileSync('git', ['commit', '-m', 'init'], { cwd: tmpDir, stdio: 'ignore' });
742
+
743
+ try {
744
+ execFileSync(process.execPath, [RATCHET_PATH], {
745
+ cwd: tmpDir,
746
+ encoding: 'utf8',
747
+ stdio: ['ignore', 'pipe', 'pipe'],
748
+ });
749
+ // If we get here, exit code was 0
750
+ assert.ok(true);
751
+ } catch (err) {
752
+ assert.fail(`Expected exit code 0 but got ${err.status}`);
753
+ }
754
+ });
755
+
756
+ test('JSON output is exactly one line', () => {
757
+ fs.writeFileSync(path.join(tmpDir, 'dummy.txt'), 'hello');
758
+ execFileSync('git', ['add', '.'], { cwd: tmpDir, stdio: 'ignore' });
759
+ execFileSync('git', ['commit', '-m', 'init'], { cwd: tmpDir, stdio: 'ignore' });
760
+
761
+ const result = execFileSync(process.execPath, [RATCHET_PATH], {
762
+ cwd: tmpDir,
763
+ encoding: 'utf8',
764
+ stdio: ['ignore', 'pipe', 'pipe'],
765
+ });
766
+
767
+ const lines = result.trim().split('\n');
768
+ assert.equal(lines.length, 1, 'Output should be exactly one line');
769
+ // Verify it parses as valid JSON
770
+ assert.doesNotThrow(() => JSON.parse(lines[0]));
771
+ });
772
+
773
+ test('FAIL exit code and JSON verified via extracted runCommand + buildCommands', () => {
774
+ // Instead of running the full script (which depends on mainRepoRoot git resolution),
775
+ // we test the failure path by directly exercising the extracted functions:
776
+ // buildCommands produces the commands, and we verify the source handles FAIL correctly.
777
+
778
+ // Verify that config override produces the expected build command
779
+ const deepflowDir = path.join(tmpDir, '.deepflow');
780
+ fs.mkdirSync(deepflowDir, { recursive: true });
781
+ fs.writeFileSync(
782
+ path.join(deepflowDir, 'config.yaml'),
783
+ 'build_command: false\n'
784
+ );
785
+ fs.writeFileSync(path.join(tmpDir, 'package.json'), '{}');
786
+
787
+ const cfg = loadConfig(tmpDir);
788
+ assert.equal(cfg.build_command, 'false', 'Config should parse build_command');
789
+
790
+ const cmds = buildCommands(tmpDir, 'node', [], cfg);
791
+ assert.equal(cmds.build, 'false', 'buildCommands should use config override for build');
792
+
793
+ // Verify the source code path: when build fails -> autoRevert -> FAIL -> exit(1)
794
+ // Already covered by source assertions, but confirm the stage ordering
795
+ const stageMatch = RATCHET_SRC.match(/STAGE_ORDER\s*=\s*\[([^\]]+)\]/);
796
+ const stages = stageMatch[1].replace(/['"]/g, '').split(',').map(s => s.trim());
797
+ assert.equal(stages[0], 'build', 'build should be first stage');
798
+
799
+ // Verify build is not in SALVAGEABLE_STAGES (so it results in FAIL, not SALVAGEABLE)
800
+ const salvMatch = RATCHET_SRC.match(/SALVAGEABLE_STAGES\s*=\s*new Set\(\[([^\]]+)\]\)/);
801
+ const salvStages = salvMatch[1].replace(/['"]/g, '').split(',').map(s => s.trim());
802
+ assert.ok(!salvStages.includes('build'), 'build should not be SALVAGEABLE');
803
+ });
804
+
805
+ test('SALVAGEABLE path verified via extracted functions for lint failure', () => {
806
+ // Same approach: verify the lint failure path produces SALVAGEABLE
807
+
808
+ const deepflowDir = path.join(tmpDir, '.deepflow');
809
+ fs.mkdirSync(deepflowDir, { recursive: true });
810
+ fs.writeFileSync(
811
+ path.join(deepflowDir, 'config.yaml'),
812
+ 'lint_command: false\n'
813
+ );
814
+ fs.writeFileSync(path.join(tmpDir, 'package.json'), '{}');
815
+
816
+ const cfg = loadConfig(tmpDir);
817
+ assert.equal(cfg.lint_command, 'false', 'Config should parse lint_command');
818
+
819
+ const cmds = buildCommands(tmpDir, 'node', [], cfg);
820
+ assert.equal(cmds.lint, 'false', 'buildCommands should use config override for lint');
821
+
822
+ // Verify lint IS in SALVAGEABLE_STAGES
823
+ const salvMatch = RATCHET_SRC.match(/SALVAGEABLE_STAGES\s*=\s*new Set\(\[([^\]]+)\]\)/);
824
+ const salvStages = salvMatch[1].replace(/['"]/g, '').split(',').map(s => s.trim());
825
+ assert.ok(salvStages.includes('lint'), 'lint should be in SALVAGEABLE_STAGES');
826
+
827
+ // Verify SALVAGEABLE path does NOT auto-revert (already tested in source assertions)
828
+ const salvIdx = RATCHET_SRC.indexOf('SALVAGEABLE_STAGES.has(stage)');
829
+ const elseIdx = RATCHET_SRC.indexOf('} else {', salvIdx);
830
+ const salvBlock = RATCHET_SRC.slice(salvIdx, elseIdx);
831
+ assert.ok(!salvBlock.includes('autoRevert'), 'SALVAGEABLE should not auto-revert');
832
+ });
833
+ });
834
+
835
+ // ---------------------------------------------------------------------------
836
+ // 13. Structural invariants
837
+ // ---------------------------------------------------------------------------
838
+
839
+ describe('Structural invariants — source assertions', () => {
840
+ test('script is pure Node.js (no require of external packages)', () => {
841
+ const requires = RATCHET_SRC.match(/require\(['"]([^'"]+)['"]\)/g) || [];
842
+ for (const req of requires) {
843
+ const mod = req.match(/require\(['"]([^'"]+)['"]\)/)[1];
844
+ assert.ok(
845
+ mod.startsWith('node:') || ['fs', 'path', 'child_process'].includes(mod),
846
+ `Unexpected dependency: ${mod} — ratchet.js must be pure Node.js`
847
+ );
848
+ }
849
+ });
850
+
851
+ test('script has shebang line', () => {
852
+ assert.ok(
853
+ RATCHET_SRC.startsWith('#!/usr/bin/env node'),
854
+ 'Script should start with #!/usr/bin/env node'
855
+ );
856
+ });
857
+
858
+ test('script uses strict mode', () => {
859
+ assert.ok(
860
+ RATCHET_SRC.includes("'use strict'"),
861
+ 'Script should use strict mode'
862
+ );
863
+ });
864
+
865
+ test('main() is called at the end', () => {
866
+ const lastNonEmpty = RATCHET_SRC.trim().split('\n').filter(l => l.trim()).pop();
867
+ assert.equal(lastNonEmpty.trim(), 'main();');
868
+ });
869
+ });