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.
package/bin/ratchet.js ADDED
@@ -0,0 +1,327 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * deepflow ratchet
4
+ * Mechanical health-check gate with auto-revert on failure.
5
+ *
6
+ * Usage: node bin/ratchet.js
7
+ *
8
+ * Outputs exactly one JSON line to stdout:
9
+ * {"result":"PASS"}
10
+ * {"result":"FAIL","stage":"build","log":"..."}
11
+ * {"result":"SALVAGEABLE","stage":"lint","log":"..."}
12
+ *
13
+ * Exit codes: 0=PASS, 1=FAIL, 2=SALVAGEABLE
14
+ * On FAIL: executes `git revert HEAD --no-edit` before exiting.
15
+ */
16
+
17
+ 'use strict';
18
+
19
+ const fs = require('fs');
20
+ const path = require('path');
21
+ const { execFileSync, spawnSync } = require('child_process');
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Git / path helpers
25
+ // ---------------------------------------------------------------------------
26
+
27
+ function gitCommonDir(cwd) {
28
+ try {
29
+ return execFileSync('git', ['rev-parse', '--git-common-dir'], {
30
+ encoding: 'utf8',
31
+ cwd,
32
+ stdio: ['ignore', 'pipe', 'ignore'],
33
+ }).trim();
34
+ } catch (_) {
35
+ return null;
36
+ }
37
+ }
38
+
39
+ function mainRepoRoot(cwd) {
40
+ const commonDir = gitCommonDir(cwd);
41
+ if (!commonDir) return cwd;
42
+ // --git-common-dir returns absolute path for worktrees, relative for normal repos
43
+ const absCommonDir = path.isAbsolute(commonDir)
44
+ ? commonDir
45
+ : path.resolve(cwd, commonDir);
46
+ // common dir is <repo>/.git for normal repos, <repo>/.git/worktrees/<name> for worktrees
47
+ // Walk up until we find a directory that is not inside .git
48
+ return path.resolve(absCommonDir, '..', '..').replace(/[/\\]\.git.*$/, '') ||
49
+ path.dirname(absCommonDir);
50
+ }
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Config loading (simple regex, consistent with bin/install.js style)
54
+ // ---------------------------------------------------------------------------
55
+
56
+ function loadConfig(repoRoot) {
57
+ const configPath = path.join(repoRoot, '.deepflow', 'config.yaml');
58
+ const cfg = {};
59
+ if (!fs.existsSync(configPath)) return cfg;
60
+
61
+ const text = fs.readFileSync(configPath, 'utf8');
62
+
63
+ const keys = ['build_command', 'test_command', 'typecheck_command', 'lint_command'];
64
+ for (const key of keys) {
65
+ const m = text.match(new RegExp(`^${key}:\\s*["']?([^"'\\n]+?)["']?\\s*$`, 'm'));
66
+ if (m) cfg[key] = m[1].trim();
67
+ }
68
+
69
+ return cfg;
70
+ }
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Snapshot: read auto-snapshot.txt and absolutize paths
74
+ // ---------------------------------------------------------------------------
75
+
76
+ function loadSnapshotFiles(repoRoot) {
77
+ const snapshotPath = path.join(repoRoot, '.deepflow', 'auto-snapshot.txt');
78
+ if (!fs.existsSync(snapshotPath)) return [];
79
+
80
+ return fs.readFileSync(snapshotPath, 'utf8')
81
+ .split('\n')
82
+ .map(l => l.trim())
83
+ .filter(l => l.length > 0)
84
+ .map(rel => path.join(repoRoot, rel));
85
+ }
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // Project type detection
89
+ // ---------------------------------------------------------------------------
90
+
91
+ function detectProjectType(repoRoot) {
92
+ if (fs.existsSync(path.join(repoRoot, 'package.json'))) return 'node';
93
+ if (fs.existsSync(path.join(repoRoot, 'pyproject.toml'))) return 'python';
94
+ if (fs.existsSync(path.join(repoRoot, 'Cargo.toml'))) return 'rust';
95
+ if (fs.existsSync(path.join(repoRoot, 'go.mod'))) return 'go';
96
+ return 'unknown';
97
+ }
98
+
99
+ function hasNpmScript(repoRoot, scriptName) {
100
+ try {
101
+ const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8'));
102
+ return !!(pkg.scripts && pkg.scripts[scriptName]);
103
+ } catch (_) {
104
+ return false;
105
+ }
106
+ }
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // Command builders per project type
110
+ // ---------------------------------------------------------------------------
111
+
112
+ function buildCommands(repoRoot, projectType, snapshotFiles, cfg) {
113
+ const cmds = {};
114
+
115
+ if (projectType === 'node') {
116
+ // build
117
+ if (cfg.build_command) {
118
+ cmds.build = cfg.build_command;
119
+ } else if (hasNpmScript(repoRoot, 'build')) {
120
+ cmds.build = 'npm run build';
121
+ }
122
+ // test — always use snapshot files, never test-discovery flags
123
+ if (cfg.test_command) {
124
+ cmds.test = cfg.test_command;
125
+ } else if (snapshotFiles.length > 0) {
126
+ cmds.test = ['node', '--test', ...snapshotFiles];
127
+ }
128
+ // typecheck
129
+ if (cfg.typecheck_command) {
130
+ cmds.typecheck = cfg.typecheck_command;
131
+ } else {
132
+ // only add if tsc is available
133
+ cmds.typecheck = 'npx tsc --noEmit';
134
+ }
135
+ // lint
136
+ if (cfg.lint_command) {
137
+ cmds.lint = cfg.lint_command;
138
+ } else if (hasNpmScript(repoRoot, 'lint')) {
139
+ cmds.lint = 'npm run lint';
140
+ }
141
+
142
+ } else if (projectType === 'python') {
143
+ // build: skip
144
+ if (cfg.build_command) cmds.build = cfg.build_command;
145
+ // test
146
+ if (cfg.test_command) {
147
+ cmds.test = cfg.test_command;
148
+ } else if (snapshotFiles.length > 0) {
149
+ cmds.test = ['pytest', ...snapshotFiles];
150
+ } else {
151
+ cmds.test = 'pytest';
152
+ }
153
+ // typecheck
154
+ if (cfg.typecheck_command) {
155
+ cmds.typecheck = cfg.typecheck_command;
156
+ } else {
157
+ cmds.typecheck = 'mypy .';
158
+ }
159
+ // lint
160
+ if (cfg.lint_command) {
161
+ cmds.lint = cfg.lint_command;
162
+ } else {
163
+ cmds.lint = 'ruff check .';
164
+ }
165
+
166
+ } else if (projectType === 'rust') {
167
+ cmds.build = cfg.build_command || 'cargo build';
168
+ cmds.test = cfg.test_command || 'cargo test';
169
+ // typecheck: skip (cargo build covers it)
170
+ if (cfg.typecheck_command) cmds.typecheck = cfg.typecheck_command;
171
+ cmds.lint = cfg.lint_command || 'cargo clippy';
172
+
173
+ } else if (projectType === 'go') {
174
+ cmds.build = cfg.build_command || 'go build ./...';
175
+ // go test doesn't work well with individual file paths — use packages
176
+ cmds.test = cfg.test_command || 'go test ./...';
177
+ // typecheck: skip
178
+ if (cfg.typecheck_command) cmds.typecheck = cfg.typecheck_command;
179
+ cmds.lint = cfg.lint_command || 'go vet ./...';
180
+
181
+ } else {
182
+ // unknown: only use config overrides
183
+ if (cfg.build_command) cmds.build = cfg.build_command;
184
+ if (cfg.test_command) cmds.test = cfg.test_command;
185
+ if (cfg.typecheck_command) cmds.typecheck = cfg.typecheck_command;
186
+ if (cfg.lint_command) cmds.lint = cfg.lint_command;
187
+ }
188
+
189
+ return cmds;
190
+ }
191
+
192
+ // ---------------------------------------------------------------------------
193
+ // Command runner
194
+ // ---------------------------------------------------------------------------
195
+
196
+ // Parse a string command into [executable, ...args] safely
197
+ function parseCommand(cmd) {
198
+ // Very simple tokenizer — handles quoted strings and plain tokens
199
+ const tokens = [];
200
+ let current = '';
201
+ let inQuote = null;
202
+
203
+ for (let i = 0; i < cmd.length; i++) {
204
+ const ch = cmd[i];
205
+ if (inQuote) {
206
+ if (ch === inQuote) { inQuote = null; }
207
+ else { current += ch; }
208
+ } else if (ch === '"' || ch === "'") {
209
+ inQuote = ch;
210
+ } else if (ch === ' ') {
211
+ if (current) { tokens.push(current); current = ''; }
212
+ } else {
213
+ current += ch;
214
+ }
215
+ }
216
+ if (current) tokens.push(current);
217
+ return tokens;
218
+ }
219
+
220
+ /**
221
+ * Run a command (string or array). Returns { ok, log }.
222
+ * Captures stdout+stderr combined for the log.
223
+ */
224
+ function runCommand(cmd, cwd) {
225
+ const args = Array.isArray(cmd) ? cmd : parseCommand(cmd);
226
+ const [exe, ...rest] = args;
227
+
228
+ const result = spawnSync(exe, rest, {
229
+ cwd,
230
+ encoding: 'utf8',
231
+ stdio: ['ignore', 'pipe', 'pipe'],
232
+ shell: false,
233
+ });
234
+
235
+ const log = ((result.stdout || '') + (result.stderr || '')).trim();
236
+
237
+ if (result.error) {
238
+ // executable not found / not available — treat as skip
239
+ return { ok: null, log: result.error.message };
240
+ }
241
+
242
+ return { ok: result.status === 0, log };
243
+ }
244
+
245
+ /**
246
+ * Check if an executable is available on PATH.
247
+ */
248
+ function commandExists(exe) {
249
+ const result = spawnSync(process.platform === 'win32' ? 'where' : 'which', [exe], {
250
+ encoding: 'utf8',
251
+ stdio: ['ignore', 'pipe', 'ignore'],
252
+ });
253
+ return result.status === 0;
254
+ }
255
+
256
+ // ---------------------------------------------------------------------------
257
+ // Auto-revert
258
+ // ---------------------------------------------------------------------------
259
+
260
+ function autoRevert(cwd) {
261
+ try {
262
+ spawnSync('git', ['revert', 'HEAD', '--no-edit'], {
263
+ cwd,
264
+ stdio: 'ignore',
265
+ });
266
+ } catch (_) {
267
+ // best-effort
268
+ }
269
+ }
270
+
271
+ // ---------------------------------------------------------------------------
272
+ // Health-check stages in order
273
+ // ---------------------------------------------------------------------------
274
+
275
+ const STAGE_ORDER = ['build', 'test', 'typecheck', 'lint'];
276
+
277
+ // Stages where failure is SALVAGEABLE (not FAIL)
278
+ const SALVAGEABLE_STAGES = new Set(['lint']);
279
+
280
+ // ---------------------------------------------------------------------------
281
+ // Main
282
+ // ---------------------------------------------------------------------------
283
+
284
+ function main() {
285
+ const cwd = process.cwd();
286
+ const repoRoot = mainRepoRoot(cwd);
287
+
288
+ const cfg = loadConfig(repoRoot);
289
+ const projectType = detectProjectType(repoRoot);
290
+ const snapshotFiles = loadSnapshotFiles(repoRoot);
291
+ const cmds = buildCommands(repoRoot, projectType, snapshotFiles, cfg);
292
+
293
+ for (const stage of STAGE_ORDER) {
294
+ const cmd = cmds[stage];
295
+ if (!cmd) continue; // stage not applicable
296
+
297
+ // For string commands, check if the primary executable exists
298
+ if (typeof cmd === 'string') {
299
+ const exe = parseCommand(cmd)[0];
300
+ // npx is always available if npm is; skip existence check for npx
301
+ if (exe !== 'npx' && !commandExists(exe)) continue;
302
+ }
303
+
304
+ const { ok, log } = runCommand(cmd, repoRoot);
305
+
306
+ if (ok === null) {
307
+ // executable spawning error — skip stage
308
+ continue;
309
+ }
310
+
311
+ if (!ok) {
312
+ if (SALVAGEABLE_STAGES.has(stage)) {
313
+ process.stdout.write(JSON.stringify({ result: 'SALVAGEABLE', stage, log }) + '\n');
314
+ process.exit(2);
315
+ } else {
316
+ autoRevert(cwd);
317
+ process.stdout.write(JSON.stringify({ result: 'FAIL', stage, log }) + '\n');
318
+ process.exit(1);
319
+ }
320
+ }
321
+ }
322
+
323
+ process.stdout.write(JSON.stringify({ result: 'PASS' }) + '\n');
324
+ process.exit(0);
325
+ }
326
+
327
+ main();