onetaskgraph-github-projects 0.14.9

A onetaskgraph source over GitHub Projects.
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
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
//! GitHub's static validation of a GraphQL request, against the complete schema GitHub
//! publishes.
//!
//! Every GitHub double here puts each request to [`validate`] first and answers a refusal the
//! way GitHub does — HTTP 200 with an `errors` array and no `data` — so a request GitHub would
//! refuse cannot pass a test. The schema is `fixtures/github-schema.docs.graphql`, GitHub's
//! published file whole; `fixtures/README.md` holds its provenance and why it is not a
//! reduction.
//!
//! The validation is graphql-js's — the GraphQL Foundation's reference implementation, npm's
//! own `graphql-17.0.2.tgz` committed beside this file and held to npm's published integrity —
//! run by `validate.mjs` beside this file: every rule of the
//! specification's Validation section (`specifiedRules`) and, for a request, the coercion of
//! its variables (`CoerceVariableValues`, §6.1.2). It is not a Rust crate because none fits this
//! workspace's dependency policy unchanged: `apollo-compiler` brings second versions of
//! `bitflags`, `windows-sys` and `hashbrown`, `bluejay-validator` a second `unicode-width`, and
//! `graphql-tools` a licence the policy does not admit.
//!
//! It is not in the workspace's `package.json` either, because that file is an input of every
//! project in the graph: naming it there selects every crate's tests, the live lanes included,
//! for a change to this one crate's test double. `validate.mjs` unpacks the tarball into
//! [`modules`] and imports it from there, holding that copy to the tarball's files byte for byte
//! on every start and unpacking it again when it differs.
//!
//! Everything the check writes is under the workspace's `target` ([`state`]) and nothing is
//! inside a crate's directory: a release records the digest of each crate's directory when it
//! builds and refuses to publish one that has moved since (`scripts/release_cd.py`), and the
//! release journeys run beside these tests in one checkout.
//!
//! One `bun` process per clone serves every test process on the machine (see [`Validators`]),
//! because building GitHub's whole schema is costly in time and memory and nextest runs each test
//! in a process of its own, so building it per process would repeat that cost for every test of
//! the suites these doubles serve. A test that would hold that server up for
//! the others — stopping it, or sending documents that take seconds to refuse — takes one of its
//! own instead ([`serve_privately`]), beside it.
//! A request nested past what it can recurse through is refused rather than crashing it —
//! JavaScript reports an exhausted stack as an exception — and the variables' nesting is bounded
//! here before they are sent.
//!
//! What it does not hold a request to is what only GitHub's executor knows — a node id's
//! existence, a permission, a rate or a node-count limit, a custom scalar's spelling. Its
//! messages are graphql-js's, not GitHub's wording; the shape of a refusal, an `errors` array
//! of `{message, locations}` objects and no `data`, is GraphQL's own (§7.1.2).
//!
//! Included by path from the loopback board in `onetaskgraph-e2e-support` as well as from this
//! crate's own tests, so both doubles hold to one implementation.

use serde_json::{Value, json};
use std::collections::HashMap;
use std::fs::{File, OpenOptions};
use std::io::{self, BufRead, BufReader, Write};
use std::net::{Ipv4Addr, SocketAddr, TcpStream};
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::sync::{Mutex, MutexGuard, OnceLock, PoisonError};
use std::time::Duration;

// llmlint: ignore[contracts_have_one_source_or_a_drift_gate] A dated snapshot of an external contract by design: it is pinned to the provenance in fixtures/README.md, which states its gates and why upstream freshness is not one.
/// GitHub's published GraphQL schema, whole.
pub const SCHEMA: &str = include_str!("../fixtures/github-schema.docs.graphql");

/// The deepest a request's variables may nest before they are refused unsent: far beyond any
/// value this source sends, and far short of what serializing them could exhaust.
const NESTING_LIMIT: usize = 200;

/// The protocol `validate.mjs` speaks, sent in each connection's hello; it is `PROTOCOL` there.
const PROTOCOL: u64 = 1;

/// How many times a request is put to a server before the check gives up: a server stops when it
/// is stale or idle, so a request in flight then is sent again to the one started after it.
const ATTEMPTS: usize = 4;

/// The longest one answer may take: far beyond the largest document this source sends, and a
/// bound on how long a server that has stopped answering can hold a test.
const ANSWER_WITHIN: Duration = Duration::from_secs(120);

/// The workspace root: every crate including this module sits at `crates/<name>`.
fn workspace_root() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..")
}

/// This module's own directory, which holds `validate.mjs` and the tarball beside it.
///
/// Found from where this file is rather than from a crate's name, because the directory a
/// crate is built from is not always its name: `scripts/check-fixture-discrimination.sh`
/// builds this crate's tests from a copy under another name. `file!()` is this file's path as
/// cargo handed it to rustc — relative to the workspace root, the directory rustc runs in, or
/// absolute — so joining it to that root names this file whichever crate includes it.
fn here() -> PathBuf {
    let file = workspace_root().join(file!());
    file.parent()
        .expect("a source file has a parent directory")
        .to_path_buf()
}

/// Where the check keeps what it writes: under the workspace's `target`, which no crate's
/// directory holds, so a test run moves nothing a release hashes.
fn state() -> PathBuf {
    workspace_root().join("target").join("github-schema")
}

/// Where `validate.mjs` unpacks graphql-js, handed to it as `GITHUB_SCHEMA_MODULES`.
fn modules() -> PathBuf {
    state().join("node_modules")
}

/// Where this clone's shared server records its port and its starters take their lock.
fn shared_rendezvous() -> PathBuf {
    state().join("server")
}

/// One connection to the server, greeted.
struct Connection {
    reader: BufReader<TcpStream>,
    writer: TcpStream,
}

impl Connection {
    /// Connects to the server at `port` and greets it: `Ok` with the connection and the
    /// schema's digest once it answers ready, `Err` when nothing current answers there.
    fn open(port: u16) -> io::Result<(Self, String)> {
        let address = SocketAddr::from((Ipv4Addr::LOCALHOST, port));
        let writer = TcpStream::connect_timeout(&address, Duration::from_secs(5))?;
        writer.set_nodelay(true)?;
        writer.set_read_timeout(Some(ANSWER_WITHIN))?;
        let mut connection = Self {
            reader: BufReader::new(writer.try_clone()?),
            writer,
        };
        let ready = connection.exchange(&json!({ "hello": PROTOCOL }).to_string())?;
        match ready["schema_sha256"].as_str() {
            Some(digest) if ready["ready"] == json!(true) => Ok((connection, digest.to_owned())),
            _ => Err(io::Error::other(format!("the server answered {ready}"))),
        }
    }

    fn exchange(&mut self, line: &str) -> io::Result<Value> {
        self.writer.write_all(format!("{line}\n").as_bytes())?;
        let mut answer = String::new();
        if self.reader.read_line(&mut answer)? == 0 {
            return Err(io::ErrorKind::UnexpectedEof.into());
        }
        serde_json::from_str(&answer).map_err(io::Error::other)
    }
}

/// The server this clone's check runs, shared by every test process on the machine; the
/// connections this process keeps open to it; and the answers it has given.
///
/// A process takes an idle connection from [`Validators::idle`] for each request, opening one
/// when none is idle, so its test threads' requests go to the server side by side rather than
/// in turn behind one lock. The server answers them as they arrive, keeping each document's
/// validation so one sent again with new variables costs only their coercion, and a request
/// already answered is answered from [`Validators::answers`] without being sent — the validation
/// is a function of the request and the schema alone, both fixed while a server runs.
///
/// The server records its port in its rendezvous directory's `endpoint.json`. A process that finds none
/// answering there takes the OS lock on `start.lock` beside it, looks once more, and starts one,
/// so concurrent first requests start one server between them. It is started in a process group
/// of its own, outside any test's, so a test that times out does not take it down; and it
/// stops by itself once idle, so nothing is left running after the tests.
struct Validators {
    rendezvous: PathBuf,
    /// The schema a server this starts builds from, and how long it idles before it stops when
    /// not its own default.
    schema: PathBuf,
    idle_for: Option<Duration>,
    idle: Mutex<Vec<Connection>>,
    schema_sha256: OnceLock<String>,
    answers: Mutex<HashMap<String, Vec<Value>>>,
}

impl Validators {
    fn new(rendezvous: PathBuf) -> Self {
        Self {
            rendezvous,
            schema: here().join("../fixtures/github-schema.docs.graphql"),
            idle_for: None,
            idle: Mutex::new(Vec::new()),
            schema_sha256: OnceLock::new(),
            answers: Mutex::new(HashMap::new()),
        }
    }

    /// The rendezvous directory, made on every use rather than once: the start lock lives in it
    /// before any server has run, and a fresh clone has no such directory until one has.
    fn rendezvous(&self) -> PathBuf {
        std::fs::create_dir_all(&self.rendezvous).unwrap_or_else(|error| {
            panic!(
                "the GitHub schema check could not create {} ({error})",
                self.rendezvous.display()
            )
        });
        self.rendezvous.clone()
    }

    /// The port and process id of the running server, as it records them, connecting to it
    /// first.
    fn endpoint(&self) -> (u16, u64) {
        // Taken before the idle list is locked, since taking one locks it too.
        let connection = self.connection();
        self.idle().push(connection);
        let endpoint: Value = serde_json::from_slice(
            &std::fs::read(self.rendezvous().join("endpoint.json"))
                .expect("a running server's endpoint"),
        )
        .expect("the endpoint is JSON");
        (
            endpoint["port"]
                .as_u64()
                .and_then(|port| u16::try_from(port).ok())
                .expect("the endpoint names a port"),
            endpoint["pid"].as_u64().expect("the endpoint names a pid"),
        )
    }

    /// Stops the running server, if there is one, and waits until it no longer listens: a
    /// client greeting it with a protocol it does not speak is answered `stale`, and it stops.
    fn stop(&self) {
        self.idle().clear();
        let Ok(endpoint) = std::fs::read(self.rendezvous.join("endpoint.json")) else {
            return;
        };
        let Some(port) = serde_json::from_slice::<Value>(&endpoint)
            .ok()
            .and_then(|endpoint| endpoint["port"].as_u64())
            .and_then(|port| u16::try_from(port).ok())
        else {
            return;
        };
        let address = SocketAddr::from((Ipv4Addr::LOCALHOST, port));
        if let Ok(mut stream) = TcpStream::connect_timeout(&address, Duration::from_secs(5)) {
            let _ = stream.write_all(b"{\"hello\":0}\n");
            let _ = stream.set_read_timeout(Some(ANSWER_WITHIN));
            let _ = BufReader::new(stream).read_line(&mut String::new());
        }
        let asked = std::time::Instant::now();
        while TcpStream::connect_timeout(&address, Duration::from_secs(1)).is_ok()
            && asked.elapsed() < ANSWER_WITHIN
        {
            std::thread::sleep(Duration::from_millis(20));
        }
    }

    fn kept(&self) -> MutexGuard<'_, HashMap<String, Vec<Value>>> {
        self.answers.lock().unwrap_or_else(PoisonError::into_inner)
    }

    fn idle(&self) -> MutexGuard<'_, Vec<Connection>> {
        self.idle.lock().unwrap_or_else(PoisonError::into_inner)
    }

    /// An idle connection, or a new one to the running server, which this starts if none runs.
    fn connection(&self) -> Connection {
        if let Some(connection) = self.idle().pop() {
            return connection;
        }
        let directory = self.rendezvous();
        let mut tried = Vec::new();
        for _ in 0..ATTEMPTS {
            match self.running(&directory) {
                Ok(connection) => return connection,
                Err(error) => tried.push(error.to_string()),
            }
            let lock = File::create(directory.join("start.lock"))
                .and_then(|lock| lock.lock().map(|()| lock))
                .expect("the GitHub schema check's start lock can be taken");
            let started = self.running(&directory).or_else(|_| self.start(&directory));
            drop(lock);
            match started {
                Ok(connection) => return connection,
                Err(error) => tried.push(error.to_string()),
            }
        }
        panic!(
            "the GitHub schema check could not be reached ({}); its server's standard error is \
             in {}",
            tried.join("; "),
            directory.join("server.log").display()
        )
    }

    /// A connection to the server `endpoint.json` names, if it answers ready.
    fn running(&self, directory: &std::path::Path) -> io::Result<Connection> {
        let endpoint: Value =
            serde_json::from_slice(&std::fs::read(directory.join("endpoint.json"))?)
                .map_err(io::Error::other)?;
        let port = endpoint["port"]
            .as_u64()
            .and_then(|port| u16::try_from(port).ok())
            .ok_or_else(|| io::Error::other(format!("endpoint.json holds {endpoint}")))?;
        let (connection, digest) = Connection::open(port)?;
        self.schema_sha256.get_or_init(|| digest);
        Ok(connection)
    }

    /// Starts a server and reads its first line rather than waiting for `endpoint.json`, so a
    /// server that cannot build the schema is reported with its own reason instead of a timeout.
    fn start(&self, directory: &std::path::Path) -> io::Result<Connection> {
        let here = here();
        let log = OpenOptions::new()
            .create(true)
            .append(true)
            .open(directory.join("server.log"))?;
        let mut command = Command::new("bun");
        command
            .arg(here.join("validate.mjs"))
            .arg(&self.schema)
            .arg("--serve")
            .arg(directory)
            // Outside the clone, so a server still idling holds no directory a test removes.
            .current_dir(std::env::temp_dir())
            // Running a script writes nothing it would otherwise cache.
            .env("BUN_RUNTIME_TRANSPILER_CACHE_PATH", "0")
            .env("GITHUB_SCHEMA_MODULES", modules())
            .stdin(Stdio::null())
            .stdout(Stdio::piped())
            .stderr(log);
        if let Some(idle_for) = self.idle_for {
            command.env("GITHUB_SCHEMA_IDLE_MS", idle_for.as_millis().to_string());
        }
        detach(&mut command);
        let mut child = spawn(&mut command).unwrap_or_else(|error| {
            panic!(
                "the GitHub schema check could not start `bun` ({error}); install the \
                 workspace's toolchain with `just bootstrap`"
            )
        });
        let output = child.stdout.take().expect("a piped standard output");
        // Reaped whenever it stops, on a thread nothing waits for: the server outlives the test
        // that started it more often than not, and this process ends without waiting for it.
        std::thread::spawn(move || child.wait());
        let mut ready = String::new();
        BufReader::new(output).read_line(&mut ready)?;
        let ready: Value = serde_json::from_str(&ready)
            .map_err(|_| io::Error::other(format!("the server started by answering {ready:?}")))?;
        if let Some(internal) = ready.get("internal") {
            panic!("the GitHub schema check did not start: {internal}");
        }
        let port = ready["port"]
            .as_u64()
            .and_then(|port| u16::try_from(port).ok())
            .ok_or_else(|| io::Error::other(format!("the server started with {ready}")))?;
        let (connection, digest) = Connection::open(port)?;
        self.schema_sha256.get_or_init(|| digest);
        Ok(connection)
    }

    fn ask(&self, request: &str) -> Vec<Value> {
        let mut failures = Vec::new();
        for _ in 0..ATTEMPTS {
            let mut connection = self.connection();
            match connection.exchange(request) {
                Ok(answer) => {
                    self.idle().push(connection);
                    if let Some(internal) = answer.get("internal") {
                        panic!("the GitHub schema check failed on {request}: {internal}");
                    }
                    return answer["errors"]
                        .as_array()
                        .cloned()
                        .expect("the GitHub schema check answers `errors`");
                }
                // The server stopped: every connection to it is closed, so none is kept.
                Err(error) => {
                    failures.push(error.to_string());
                    self.idle().clear();
                }
            }
        }
        panic!(
            "the GitHub schema check did not answer {request}: {}",
            failures.join("; ")
        )
    }

    fn answer(&self, request: String) -> Vec<Value> {
        if let Some(answer) = self.kept().get(&request) {
            return answer.clone();
        }
        let answer = self.ask(&request);
        let mut answers = self.kept();
        if answers.len() >= ANSWERS_KEPT {
            answers.clear();
        }
        answers.insert(request, answer.clone());
        answer
    }
}

/// Puts the server in a process group of its own, so the signal that ends a test's group — a
/// timeout, an interrupt — does not end the server other tests are using.
#[cfg(unix)]
fn detach(command: &mut Command) {
    use std::os::unix::process::CommandExt;
    command.process_group(0);
}

/// A new process group, so a console interrupt to the test's group does not reach the server.
#[cfg(windows)]
const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
/// No console window for the server.
#[cfg(windows)]
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
/// Outside the job object the test runs in, so ending that job does not end the server.
#[cfg(windows)]
const CREATE_BREAKAWAY_FROM_JOB: u32 = 0x0100_0000;

/// Puts the server in a process group of its own and, where the job the test runs in allows it,
/// outside that job, so ending the test's job does not end the server other tests are using.
#[cfg(windows)]
fn detach(command: &mut Command) {
    use std::os::windows::process::CommandExt;
    command.creation_flags(CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW | CREATE_BREAKAWAY_FROM_JOB);
}

#[cfg(not(any(unix, windows)))]
fn detach(_command: &mut Command) {}

/// Spawns `command`; on Windows, inside the test's job when that job refuses a breakaway, in
/// which case a server outlives only the tests whose job it was started in and is started again
/// after.
fn spawn(command: &mut Command) -> io::Result<std::process::Child> {
    let spawned = command.spawn();
    #[cfg(windows)]
    if spawned.is_err() {
        use std::os::windows::process::CommandExt;
        return command
            .creation_flags(CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW)
            .spawn();
    }
    spawned
}

/// Every error GitHub's static validation answers `document`, sent with `operation_name` and
/// `variables`, with; empty when GitHub would run it. `variables` of `None` skips their
/// coercion, for a document whose values a caller supplies later.
fn errors(
    validators: &Validators,
    document: &str,
    operation_name: Option<&str>,
    variables: Option<&Value>,
) -> Vec<Value> {
    if variables.is_some_and(|variables| nesting(variables) > NESTING_LIMIT) {
        return vec![
            json!({"message": format!("Variables nest deeper than {NESTING_LIMIT} levels")}),
        ];
    }
    let request = json!({
        "query": document,
        "operationName": operation_name,
        "variables": variables,
        "coerce": variables.is_some(),
    })
    .to_string();
    validators.answer(request)
}

/// The most answers [`Validators`] keeps before it starts again from none: far more distinct
/// requests than one test process sends, and a bound on what an adversarial test can make it
/// hold.
const ANSWERS_KEPT: usize = 16_384;

/// A server of a test's own, beside the one every test process shares, until it is dropped:
/// for a test whose requests would hold the shared one up for every other test — one that stops
/// a server, or that sends documents each taking seconds to refuse. A server is single-threaded,
/// so either would add those seconds to whatever another test's double is asking at the time,
/// and a test timing its answers on the real clock would count them. Its rendezvous is a
/// scratch directory of its own, which dropping it stops the server in and removes.
#[must_use]
pub struct PrivateServer(Validators);

/// A server of the calling test's own; see [`PrivateServer`].
pub fn serve_privately() -> PrivateServer {
    static TAKEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
    let nanos = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map_or(0, |since| since.as_nanos());
    PrivateServer(Validators::new(std::env::temp_dir().join(format!(
        "onetaskgraph-github-schema-{}-{nanos}-{}",
        std::process::id(),
        TAKEN.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
    ))))
}

/// A server of the calling test's own, built from a copy of the committed schema in its own
/// rendezvous directory — so the test can change a file the server watches without touching the
/// crate — that stops once it has had no connection for `idle_for`. See [`PrivateServer`].
pub fn serve_privately_from_a_copy(idle_for: Duration) -> PrivateServer {
    let mut server = serve_privately();
    let copy = server.0.rendezvous().join("github-schema.docs.graphql");
    std::fs::copy(&server.0.schema, &copy).expect("the schema is copied for a private server");
    server.0.schema = copy;
    server.0.idle_for = Some(idle_for);
    server
}

impl PrivateServer {
    /// [`refuse`], answered by this server.
    #[must_use]
    pub fn refuse(&self, body: &Value) -> Option<Value> {
        refuse_by(&self.0, body)
    }

    /// [`server`] of this server.
    pub fn server(&self) -> (u16, u64) {
        self.0.endpoint()
    }

    /// The schema file this server was built from.
    pub fn schema(&self) -> &std::path::Path {
        &self.0.schema
    }

    /// Closes every connection this process holds open to the server, as a test process does
    /// when it ends.
    pub fn disconnect(&self) {
        self.0.idle().clear();
    }
}

impl Drop for PrivateServer {
    fn drop(&mut self) {
        self.0.stop();
        // The server has stopped listening; on Windows its log stays open until it has exited.
        let directory = &self.0.rendezvous;
        let stopped = std::time::Instant::now();
        while std::fs::remove_dir_all(directory).is_err() && directory.exists() {
            if stopped.elapsed() > Duration::from_secs(10) {
                eprintln!(
                    "the private GitHub schema check's directory {} could not be removed",
                    directory.display()
                );
                return;
            }
            std::thread::sleep(Duration::from_millis(50));
        }
    }
}

/// This process's way to the shared server, connected on first use.
fn validators() -> &'static Validators {
    static VALIDATORS: OnceLock<Validators> = OnceLock::new();
    VALIDATORS.get_or_init(|| {
        let validators = Validators::new(shared_rendezvous());
        let connection = validators.connection();
        validators.idle().push(connection);
        validators
    })
}

/// Connects this process to the server now, starting it if none runs, rather than inside the
/// first request a double answers: building GitHub's schema takes a fraction of a second, and a
/// double whose test times its answers on the real clock would otherwise count that against
/// whichever request came first. Each double calls it before it starts listening.
pub fn warm() {
    validators();
}

/// The SHA-256, in lowercase hex, of the schema file the server built — [`SCHEMA`]'s bytes,
/// read from the same path.
pub fn schema_sha256() -> String {
    validators()
        .schema_sha256
        .get()
        .expect("a connected server reported the schema's digest")
        .clone()
}

/// Where the check lives and where it writes: `(its own directory, graphql-js's unpacked copy,
/// the shared server's rendezvous)`.
pub fn locations() -> (PathBuf, PathBuf, PathBuf) {
    (here(), modules(), shared_rendezvous())
}

/// The port and process id of the server this clone's checks share, as it records them,
/// connecting this process to it first.
pub fn server() -> (u16, u64) {
    validators().endpoint()
}

/// How deeply `value` nests, measured without recursion so that no depth of it can exhaust
/// the stack before it is refused.
fn nesting(value: &Value) -> usize {
    let mut deepest = 0;
    let mut pending = vec![(value, 1)];
    while let Some((value, depth)) = pending.pop() {
        deepest = deepest.max(depth);
        match value {
            Value::Array(items) => pending.extend(items.iter().map(|item| (item, depth + 1))),
            Value::Object(fields) => pending.extend(fields.values().map(|item| (item, depth + 1))),
            _ => {}
        }
    }
    deepest
}

fn messages(errors: Vec<Value>) -> Result<(), Vec<String>> {
    if errors.is_empty() {
        Ok(())
    } else {
        Err(errors
            .iter()
            .map(|error| error["message"].as_str().unwrap_or_default().to_owned())
            .collect())
    }
}

/// Whether GitHub's static validation accepts `document` sent with `variables`; `Err` carries
/// each message it would answer, in order.
pub fn validate(document: &str, variables: &Value) -> Result<(), Vec<String>> {
    messages(errors(validators(), document, None, Some(variables)))
}

/// [`validate`] of the document alone: every rule but the coercion of the variables' values,
/// for a document whose values a caller supplies later.
pub fn validate_document(document: &str) -> Result<(), Vec<String>> {
    messages(errors(validators(), document, None, None))
}

/// `body`, a GraphQL request's JSON — `query`, and `operationName` and `variables` when given —
/// put to GitHub's static validation: `Some` refusal body when GitHub would refuse it, in
/// GraphQL's error shape with no `data`, and `None` when it would run it or when `body` is not
/// a GraphQL request.
#[must_use]
pub fn refuse(body: &Value) -> Option<Value> {
    refuse_by(validators(), body)
}

fn refuse_by(validators: &Validators, body: &Value) -> Option<Value> {
    let document = body.get("query")?.as_str()?;
    let operation_name = match body.get("operationName") {
        None | Some(Value::Null) => None,
        Some(Value::String(name)) => Some(name.as_str()),
        Some(other) => {
            return Some(json!({"errors": [{
                "message": format!("operationName must be a string, not {other}")
            }]}));
        }
    };
    let empty = Value::Object(serde_json::Map::new());
    let variables = body
        .get("variables")
        .filter(|value| !value.is_null())
        .unwrap_or(&empty);
    let errors = errors(validators, document, operation_name, Some(variables));
    (!errors.is_empty()).then(|| json!({ "errors": errors }))
}