bynk 0.97.0

The Bynk developer front-end — links the compiler pipeline in-process and orchestrates the Node toolchain (doctor / new / dev).
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
//! `bynk doctor` — the capability model, the checks, and the exit-code
//! contract.
//!
//! Probes are **grouped by the capability they unlock**, not listed flat, so a
//! compile-only user is never told they are "unhealthy" for lacking `wrangler`.
//! The exit-code contract turns on *what an invocation asks about* (ADR: the
//! doctor output / exit-code contract):
//!
//! - **Bare `bynk doctor`** is informational. It surveys everything but treats
//!   only the *compile floor* (`bynkc` resolvable and not majorly skewed) as
//!   required, so it exits `0` even with `test`/`dev` unavailable.
//! - **`--only <capability>`** promotes that capability's tools to required.
//! - **`--strict`** promotes *all* warnings (optional gaps, `npx`
//!   provisionability, minor skew) to failures, for an all-green CI gate.

use std::path::PathBuf;

use crate::compiler::{Compiler, Origin, Skew};
use crate::probe::{self, DetectOpts, Probe, Toolbox};

/// A unit of work a user might want to do, and the tools it needs.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Capability {
    /// `bynkc` compile / check / fmt. Always satisfiable if `bynkc` resolved;
    /// also the home of the driver↔compiler skew check.
    Compile,
    /// `bynk test` — Node and one of `tsc`/`tsx` (the runner ladder).
    Test,
    /// `dev` / deploy to Cloudflare — Node and `wrangler`.
    Deploy,
    /// Editor support — `bynkc-lsp`. Optional; never a failure (except strict).
    Editor,
    /// Build Bynk from source — a Rust toolchain. Contributor-only; reported
    /// only inside the Bynk repo.
    BuildFromSource,
}

impl Capability {
    pub fn token(self) -> &'static str {
        match self {
            Capability::Compile => "compile",
            Capability::Test => "test",
            Capability::Deploy => "deploy",
            Capability::Editor => "editor",
            Capability::BuildFromSource => "build",
        }
    }

    /// Optional capabilities never fail a run on their own — they note, and
    /// `--strict` escalates.
    pub fn is_optional(self) -> bool {
        matches!(self, Capability::Editor | Capability::BuildFromSource)
    }
}

/// Health of a single row or a whole capability. Ordered: `Ok < Warn < Fail`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Level {
    Ok,
    Warn,
    Fail,
}

/// One rendered line under a capability: a tool (or an any-of group like
/// `tsc | tsx`), its health, a human detail, and a remedy when it is not `Ok`.
#[derive(Debug, Clone)]
pub struct Row {
    pub label: String,
    pub level: Level,
    pub detail: String,
    pub remedy: Option<String>,
}

/// A capability and its rows, with the aggregated health.
#[derive(Debug, Clone)]
pub struct CapabilityReport {
    pub capability: Capability,
    pub optional: bool,
    pub rows: Vec<Row>,
    pub level: Level,
}

/// The whole `doctor` result.
#[derive(Debug, Clone)]
pub struct Report {
    pub driver_version: String,
    pub compiler: Compiler,
    pub capabilities: Vec<CapabilityReport>,
}

/// User-facing knobs.
#[derive(Debug, Clone, Default)]
pub struct DoctorOptions {
    /// Scope the gate to one capability (promotes its tools to required).
    pub only: Option<Capability>,
    /// Escalate every warning to a failure.
    pub strict: bool,
}

/// Environment facts the caller supplies (real values in `main`, fixed values
/// in tests).
#[derive(Debug, Clone)]
pub struct Context {
    /// Discovered project root (`bynk.toml`), for project-local resolution.
    pub project_root: Option<PathBuf>,
    /// Whether to include the contributor `build` capability.
    pub in_repo: bool,
    /// Minimum supported Node major (single-sourced from `bynkc`).
    pub node_floor: u32,
}

impl Report {
    /// Should the process exit non-zero, given the options?
    ///
    /// Non-zero iff a *required* capability has a hard failure, or — under
    /// `--strict` — any capability is less than `Ok`. The compile floor is
    /// always required; `--only <cap>` adds that capability.
    pub fn exit_nonzero(&self, opts: &DoctorOptions) -> bool {
        for cap in &self.capabilities {
            let required =
                cap.capability == Capability::Compile || opts.only == Some(cap.capability);
            if required && cap.level == Level::Fail {
                return true;
            }
        }
        if opts.strict && self.capabilities.iter().any(|c| c.level != Level::Ok) {
            return true;
        }
        false
    }

    /// One-word overall summary for the human header.
    pub fn is_all_ok(&self) -> bool {
        self.capabilities.iter().all(|c| c.level == Level::Ok)
    }
}

/// Run the checks against a toolbox and a resolved compiler.
pub fn diagnose(
    tb: &dyn Toolbox,
    compiler: &Compiler,
    ctx: &Context,
    opts: &DoctorOptions,
) -> Report {
    let root = ctx.project_root.as_deref();
    let mut capabilities = vec![compile_report(compiler)];

    // Only build the capability the user scoped to (plus the always-on compile
    // floor), so `--only test` doesn't probe Cloudflare. With no filter, build
    // them all.
    let want = |cap: Capability| opts.only.is_none() || opts.only == Some(cap);

    if want(Capability::Test) {
        let node = detect_node(tb, root, ctx.node_floor);
        let runner = detect_runner(tb, root);
        capabilities.push(capability(Capability::Test, vec![node, runner]));
    }
    if want(Capability::Deploy) {
        let node = detect_node(tb, root, ctx.node_floor);
        let wrangler = detect_npm_tool(tb, root, "wrangler", "npm install -g wrangler");
        capabilities.push(capability(Capability::Deploy, vec![node, wrangler]));
    }
    if want(Capability::Editor) {
        let lsp = detect_plain(
            tb,
            "bynkc-lsp",
            "install bynkc-lsp (or download from releases)",
        );
        capabilities.push(capability(Capability::Editor, vec![lsp]));
    }
    if ctx.in_repo && want(Capability::BuildFromSource) {
        let cargo = detect_plain(tb, "cargo", "install Rust via https://rustup.rs");
        capabilities.push(capability(Capability::BuildFromSource, vec![cargo]));
    }

    Report {
        driver_version: crate::DRIVER_VERSION.to_string(),
        compiler: compiler.clone(),
        capabilities,
    }
}

/// Compile/check/fmt. The compiler is **linked in-process** (slice 7 / ADR 0101),
/// so it is always available and cannot skew against itself — the always-ok row.
/// The external-`bynkc` resolution + skew check applies **only** under a
/// `BYNK_BYNKC` override (`Origin::Override`), the one path on which a second,
/// skewable compiler enters; with no override there is nothing external to check
/// (amends ADR 0084).
fn compile_report(compiler: &Compiler) -> CapabilityReport {
    let mut rows = vec![Row {
        label: "compiler".into(),
        level: Level::Ok,
        detail: "in-process".into(),
        remedy: None,
    }];

    // Only when the user explicitly pointed `bynk` at an external compiler does a
    // second binary — and thus skew — exist. Report it then, and only then.
    if matches!(compiler.origin, Some(Origin::Override)) {
        let ver = compiler
            .version
            .map(|v| v.to_string())
            .unwrap_or_else(|| "unknown".into());
        let row = match (&compiler.path, compiler.skew) {
            (None, _) => Row {
                label: "bynkc (override)".into(),
                level: Level::Fail,
                detail: "$BYNK_BYNKC set but not found".into(),
                remedy: Some("fix BYNK_BYNKC, or unset it to use the in-process compiler".into()),
            },
            (Some(_), Some(Skew::Major)) => Row {
                label: "bynkc (override)".into(),
                level: Level::Fail,
                detail: format!("{ver} — major skew vs driver"),
                remedy: Some("align the override bynkc with bynk, or unset BYNK_BYNKC".into()),
            },
            (Some(_), Some(Skew::Minor)) => Row {
                label: "bynkc (override)".into(),
                level: Level::Warn,
                detail: format!("{ver} — minor skew vs driver"),
                remedy: Some("align the override bynkc with bynk, or unset BYNK_BYNKC".into()),
            },
            (Some(_), _) => Row {
                label: "bynkc (override)".into(),
                level: Level::Ok,
                detail: format!("{ver} (override)"),
                remedy: None,
            },
        };
        rows.push(row);
    }

    let level = rows.iter().map(|r| r.level).max().unwrap_or(Level::Ok);
    CapabilityReport {
        capability: Capability::Compile,
        optional: false,
        rows,
        level,
    }
}

/// Aggregate a capability from its rows (worst row wins).
fn capability(cap: Capability, rows: Vec<Row>) -> CapabilityReport {
    let level = rows.iter().map(|r| r.level).max().unwrap_or(Level::Ok);
    CapabilityReport {
        capability: cap,
        optional: cap.is_optional(),
        rows,
        level,
    }
}

fn detect_node(tb: &dyn Toolbox, root: Option<&std::path::Path>, floor: u32) -> Row {
    // A runtime is never npx-provisionable.
    let probe = probe::detect(
        tb,
        "node",
        DetectOpts {
            project_root: root,
            allow_npx: false,
        },
    );
    let remedy = format!("install Node.js ≥ {floor} from https://nodejs.org");
    if probe.is_missing() {
        return Row {
            label: "node".into(),
            level: Level::Fail,
            detail: "missing".into(),
            remedy: Some(remedy),
        };
    }
    let below = probe.version.map(|v| v.major < floor).unwrap_or(false);
    if below {
        let v = probe.version.unwrap();
        return Row {
            label: "node".into(),
            level: Level::Warn,
            detail: format!("v{v} below floor (≥ {floor})"),
            remedy: Some(remedy),
        };
    }
    Row {
        label: "node".into(),
        level: Level::Ok,
        detail: present_detail(&probe),
        remedy: None,
    }
}

/// The `tsc | tsx` runner requirement — satisfied by the *better* of the two.
fn detect_runner(tb: &dyn Toolbox, root: Option<&std::path::Path>) -> Row {
    let tsc = probe::detect(
        tb,
        "tsc",
        DetectOpts {
            project_root: root,
            allow_npx: true,
        },
    );
    let tsx = probe::detect(
        tb,
        "tsx",
        DetectOpts {
            project_root: root,
            allow_npx: true,
        },
    );
    let best = pick_better(&tsc, &tsx);
    let remedy = "npm install -g tsx (or: npm install -g typescript)".to_string();
    match best {
        Some(p) if p.is_present() => Row {
            label: "tsc | tsx".into(),
            level: Level::Ok,
            detail: format!("{} {}", p.tool, present_detail(p)),
            remedy: None,
        },
        Some(p) => Row {
            // provisionable via npx
            label: "tsc | tsx".into(),
            level: Level::Warn,
            detail: format!("{} provisionable via npx (not installed)", p.tool),
            remedy: Some(remedy),
        },
        None => Row {
            label: "tsc | tsx".into(),
            level: Level::Fail,
            detail: "missing".into(),
            remedy: Some(remedy),
        },
    }
}

fn detect_npm_tool(
    tb: &dyn Toolbox,
    root: Option<&std::path::Path>,
    tool: &str,
    remedy: &str,
) -> Row {
    let probe = probe::detect(
        tb,
        tool,
        DetectOpts {
            project_root: root,
            allow_npx: true,
        },
    );
    npm_row(tool, &probe, remedy)
}

fn detect_plain(tb: &dyn Toolbox, tool: &str, remedy: &str) -> Row {
    let probe = probe::detect(
        tb,
        tool,
        DetectOpts {
            project_root: None,
            allow_npx: false,
        },
    );
    if probe.is_present() {
        Row {
            label: tool.into(),
            level: Level::Ok,
            detail: present_detail(&probe),
            remedy: None,
        }
    } else {
        Row {
            label: tool.into(),
            level: Level::Fail,
            detail: "missing".into(),
            remedy: Some(remedy.into()),
        }
    }
}

fn npm_row(tool: &str, probe: &Probe, remedy: &str) -> Row {
    if probe.is_present() {
        Row {
            label: tool.into(),
            level: Level::Ok,
            detail: present_detail(probe),
            remedy: None,
        }
    } else if probe.is_provisionable() {
        Row {
            label: tool.into(),
            level: Level::Warn,
            detail: "provisionable via npx (not installed)".into(),
            remedy: Some(remedy.into()),
        }
    } else {
        Row {
            label: tool.into(),
            level: Level::Fail,
            detail: "missing".into(),
            remedy: Some(remedy.into()),
        }
    }
}

/// `path`/`project-local` beats `npx` beats `missing`; among installed, prefer
/// the first argument (caller order).
fn pick_better<'a>(a: &'a Probe, b: &'a Probe) -> Option<&'a Probe> {
    fn rank(p: &Probe) -> u8 {
        if p.is_present() {
            2
        } else if p.is_provisionable() {
            1
        } else {
            0
        }
    }
    let (ra, rb) = (rank(a), rank(b));
    if ra == 0 && rb == 0 {
        None
    } else if ra >= rb {
        Some(a)
    } else {
        Some(b)
    }
}

fn present_detail(probe: &Probe) -> String {
    let ver = probe
        .version
        .map(|v| format!("v{v}"))
        .unwrap_or_else(|| "installed".into());
    format!("{ver} ({})", probe.provenance.token())
}