noxid-cli 0.2.0

The Noxid compiler command line: check, build, test, adapt, and the agent surface
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
649
650
651
652
653
654
655
656
657
658
659
//! npm admission gate: a `.nox` file may import an npm package only when a
//! vetting record under `plugins/<package>/VETTING.md` covers the exact
//! locked version and integrity hash (docs/npm-admission.md). Without a
//! record the build warns; `--strict-npm` (and CI) escalates to an error.
use std::fs;
use std::path::{Path, PathBuf};

/// Root package name of an npm specifier; `None` for relative/absolute
/// (project-local) sources, which are vetted as source files instead.
pub fn npm_package_name(source: &str) -> Option<String> {
    if source.starts_with('.') || source.starts_with('/') {
        return None;
    }
    let mut segments = source.split('/');
    let first = segments.next()?;
    if first.is_empty() {
        return None;
    }
    if let Some(stripped) = first.strip_prefix('@') {
        let second = segments.next()?;
        if stripped.is_empty() || second.is_empty() {
            return None;
        }
        Some(format!("{first}/{second}"))
    } else {
        Some(first.to_string())
    }
}

/// Directory name for a package's plugin record; scoped names flatten so
/// the record path has no nested `@scope` directory.
pub fn plugin_directory_name(package: &str) -> String {
    package.replace('@', "").replace('/', "__")
}

fn find_upward(start: &Path, relative: &str) -> Option<PathBuf> {
    let mut current = Some(start);
    while let Some(dir) = current {
        let candidate = dir.join(relative);
        if candidate.exists() {
            return Some(candidate);
        }
        current = dir.parent();
    }
    None
}

/// Resolve the locked `(version, integrity)` for a package from the nearest
/// committed pnpm lockfile above the project root.
pub fn locked_package(project_root: &Path, package: &str) -> Option<(String, String)> {
    let lockfile = find_upward(project_root, "pnpm-lock.yaml")?;
    let text = fs::read_to_string(&lockfile).ok()?;
    locked_package_from_text(&text, package)
}

fn locked_package_from_text(text: &str, package: &str) -> Option<(String, String)> {
    let prefix = format!("{package}@");
    let mut lines = text.lines();
    while let Some(line) = lines.next() {
        let Some(key) = line.strip_prefix("  ") else {
            continue;
        };
        // pnpm quotes scoped package keys in YAML while ordinary package keys
        // are commonly bare. Normalize only the two supported YAML quote
        // forms; nested importer fields retain extra indentation and cannot
        // match the package prefix.
        let Some(key) = key.strip_suffix(':') else {
            continue;
        };
        let key = key
            .strip_prefix('\'')
            .and_then(|value| value.strip_suffix('\''))
            .or_else(|| {
                key.strip_prefix('"')
                    .and_then(|value| value.strip_suffix('"'))
            })
            .unwrap_or(key);
        let Some(version) = key.strip_prefix(&prefix) else {
            continue;
        };
        // Versions with peer-dependency suffixes ("1.0.0(react@18)") name
        // the same resolution; keep the plain version.
        let version = version.split('(').next().unwrap_or(version).to_string();
        for follow in lines.by_ref() {
            let follow = follow.trim();
            if let Some(resolution) = follow.strip_prefix("resolution: {integrity: ") {
                let integrity = resolution
                    .trim_end_matches('}')
                    .trim_end_matches(',')
                    .trim()
                    .to_string();
                return Some((version, integrity));
            }
            if follow.is_empty() {
                break;
            }
        }
        return None;
    }
    None
}

/// Parse `version:` and `integrity:` header lines from a vetting record.
pub fn vetting_record(project_root: &Path, package: &str) -> Option<(String, String, PathBuf)> {
    let relative = format!("plugins/{}/VETTING.md", plugin_directory_name(package));
    let path = find_upward(project_root, &relative)?;
    let text = fs::read_to_string(&path).ok()?;
    let mut version = None;
    let mut integrity = None;
    for line in text.lines() {
        let line = line.trim();
        if let Some(value) = line.strip_prefix("version:") {
            version = Some(value.trim().to_string());
        } else if let Some(value) = line.strip_prefix("integrity:") {
            integrity = Some(value.trim().to_string());
        }
    }
    Some((version?, integrity?, path))
}

/// Validate every npm package imported by `.nox` code. Returns warning
/// lines in non-strict mode; in strict mode the first violation is an error.
pub fn validate_npm_imports(
    project_root: &Path,
    sources: impl IntoIterator<Item = String>,
    strict: bool,
) -> Result<Vec<String>, String> {
    let mut packages = sources
        .into_iter()
        .filter_map(|source| npm_package_name(&source))
        .collect::<Vec<_>>();
    packages.sort();
    packages.dedup();
    let mut warnings = Vec::new();
    for package in packages {
        let locked = locked_package(project_root, &package);
        let record = vetting_record(project_root, &package);
        let problem = match (&locked, &record) {
            (None, _) => Some(format!(
                "`{package}` is imported but not present in the committed lockfile"
            )),
            (Some(_), None) => Some(format!(
                "`{package}` has no vetting record; run `noxid vet {package}` and review plugins/{}/VETTING.md",
                plugin_directory_name(&package)
            )),
            (Some((version, integrity)), Some((recorded_version, recorded_integrity, path))) => {
                if version != recorded_version {
                    Some(format!(
                        "`{package}` is locked at {version} but {} covers {recorded_version}; re-vet with `noxid vet {package}`",
                        path.display()
                    ))
                } else if integrity != recorded_integrity {
                    Some(format!(
                        "`{package}` integrity {integrity} does not match the vetting record in {}; the package content changed — re-vet with `noxid vet {package}`",
                        path.display()
                    ))
                } else {
                    None
                }
            }
        };
        if let Some(problem) = problem {
            if strict {
                return Err(format!("error[NPM_IMPORT_UNVETTED]: {problem}"));
            }
            warnings.push(format!("warning[NPM_IMPORT_UNVETTED]: {problem}"));
        }
    }
    Ok(warnings)
}

/// The compiler-owned vendored files this binary ships that the project
/// actually holds on disk. `plugins/drizzle-orm/adapter.js` is the one that
/// matters: `is_compiler_owned_drizzle_adapter` selects it as the project's
/// principal authority by *position*, so its contents are only ever vouched
/// for by the ledger's hash.
fn vendored_plugin_files_present(project_root: &Path) -> Vec<&'static str> {
    crate::scaffold::embedded_plugins()
        .files
        .iter()
        .map(|(relative, _)| *relative)
        .filter(|relative| project_root.join(relative).is_file())
        .collect()
}

/// A project with vendored plugin files and no ledger has deleted the only
/// record of which vetted copy those files are. Before this refusal existed,
/// removing one dotfile silently disabled the whole integrity check and a
/// modified adapter built clean; the check now fails closed. A project that
/// vendors nothing still takes the `Ok(())` path, because there is nothing for
/// a ledger to record.
fn refuse_vendored_plugins_without_a_ledger(
    project_root: &Path,
    ledger_path: &Path,
) -> Result<(), String> {
    let vendored = vendored_plugin_files_present(project_root);
    if vendored.is_empty() {
        return Ok(());
    }
    Err(format!(
        "error[PLUGIN_LEDGER_MISSING]: {} vendors compiler-owned plugin file(s) ({}) but has no          {}. That ledger is the only record of which vetted copy those files are, and          `plugins/drizzle-orm/adapter.js` is this project's data boundary — without the ledger a          modified copy would build unnoticed. Restore the ledger from version control, or          re-vendor the files from this compiler with `noxid vet --sync` inside the project.",
        project_root.display(),
        vendored.join(", "),
        ledger_path
            .file_name()
            .and_then(|name| name.to_str())
            .unwrap_or(crate::scaffold::PLUGIN_LEDGER),
    ))
}

/// One `"key": "value"` field, read out of the ledger without a JSON
/// dependency: the ledger is compiler-written, so a flat scan is exact, and a
/// hand-edited file that no longer matches this shape is refused, not guessed.
fn ledger_field(entry: &str, name: &str) -> Option<String> {
    let key = format!("\"{name}\": \"");
    let start = entry.find(&key)? + key.len();
    let end = entry[start..].find('"')? + start;
    Some(entry[start..end].to_string())
}

/// The `{ ... }` objects inside the ledger's `"<name>": [ ... ]` array.
fn ledger_entries(ledger: &str, name: &str) -> Vec<String> {
    let key = format!("\"{name}\": [");
    let Some(start) = ledger.find(&key).map(|index| index + key.len()) else {
        return Vec::new();
    };
    let Some(end) = ledger[start..].find(']').map(|index| index + start) else {
        return Vec::new();
    };
    let mut entries = Vec::new();
    let mut rest = &ledger[start..end];
    while let Some(open) = rest.find('{') {
        let Some(close) = rest[open..].find('}').map(|index| index + open) else {
            break;
        };
        entries.push(rest[open..=close].to_string());
        rest = &rest[close + 1..];
    }
    entries
}

/// Validate a scaffolded project's vendored plugins (WO-54): every vetted file
/// still hashes to what `noxid new` wrote, and the committed lockfile resolves
/// each pinned package to exactly the version and integrity its vetting record
/// covers. This is a hard refusal, not a warning: the project already holds the
/// review, so there is no "unreviewed but tolerated" state left to warn about.
pub fn validate_vendored_plugins(project_root: &Path) -> Result<(), String> {
    let ledger_path = project_root.join(crate::scaffold::PLUGIN_LEDGER);
    let Ok(ledger) = fs::read_to_string(&ledger_path) else {
        return refuse_vendored_plugins_without_a_ledger(project_root, &ledger_path);
    };
    let files = ledger_entries(&ledger, "files");
    if files.is_empty() {
        return Err(format!(
            "error[PLUGIN_VENDOR_DRIFT]: {} lists no vendored files; restore it from `noxid new`, \
             or delete it if this project no longer vendors compiler-owned plugins",
            ledger_path.display()
        ));
    }
    for entry in files {
        let (Some(relative), Some(expected)) =
            (ledger_field(&entry, "path"), ledger_field(&entry, "sha256"))
        else {
            return Err(format!(
                "error[PLUGIN_VENDOR_DRIFT]: {} has an entry without both `path` and `sha256`; \
                 the ledger is compiler-written — scaffold a fresh project with `noxid new` and \
                 copy its ledger back",
                ledger_path.display()
            ));
        };
        let path = project_root.join(&relative);
        let contents = fs::read(&path).map_err(|error| {
            format!(
                "error[PLUGIN_VENDOR_DRIFT]: {} is named by {} but cannot be read ({error}); the \
                 vetted plugin files travel with the project and are part of its build",
                path.display(),
                ledger_path.display()
            )
        })?;
        let actual = crate::sha256::hex_digest(&contents);
        if actual != expected {
            return Err(format!(
                "error[PLUGIN_VENDOR_DRIFT]: {} no longer matches the vetted copy recorded in {} \
                 (expected sha256 {expected}, found {actual}); the adapter is the compiler-owned \
                 data boundary, not an editable project file. Restore the original, or vet a \
                 replacement and record it under plugins/ before building.",
                path.display(),
                ledger_path.display()
            ));
        }
    }
    for entry in ledger_entries(&ledger, "pins") {
        let (Some(package), Some(version), Some(integrity)) = (
            ledger_field(&entry, "package"),
            ledger_field(&entry, "version"),
            ledger_field(&entry, "integrity"),
        ) else {
            return Err(format!(
                "error[NPM_IMPORT_UNVETTED]: a pin in {} is missing `package`, `version`, or \
                 `integrity`; every vendored plugin names the exact release it was reviewed \
                 against",
                ledger_path.display()
            ));
        };
        let Some((locked_version, locked_integrity)) = locked_package(project_root, &package)
        else {
            return Err(format!(
                "error[NPM_IMPORT_UNVETTED]: `{package}` is pinned at {version} by {} but the \
                 committed lockfile does not resolve it; run `pnpm install --frozen-lockfile` \
                 before building, and keep the pinned version in package.json",
                ledger_path.display()
            ));
        };
        if locked_version != version {
            return Err(format!(
                "error[NPM_IMPORT_UNVETTED]: `{package}` is locked at {locked_version} but the \
                 vendored vetting record covers {version}; the adapter is only reviewed against \
                 the pinned release. Restore `\"{package}\": \"{version}\"` in package.json and \
                 re-run `pnpm install --frozen-lockfile`, or re-vet with `noxid vet {package}`."
            ));
        }
        if locked_integrity != integrity {
            return Err(format!(
                "error[NPM_IMPORT_UNVETTED]: `{package}@{version}` resolves to integrity \
                 {locked_integrity}, not the reviewed {integrity}; the published content changed \
                 under the same version. Re-vet with `noxid vet {package}` before building."
            ));
        }
    }
    Ok(())
}

/// `noxid vet [package] [--sync]`.
///
/// With a package name this is the repository-side vetting tool (unchanged).
/// With no package it is the scaffolded-project side of WO-54: compare the
/// vendored plugin files against the copies this compiler embeds, and with
/// `--sync` rewrite the drifted ones. The two modes never mix — one reads an
/// npm tarball, the other reads only the binary's own bytes.
pub fn run_vet_command(args: impl Iterator<Item = String>) -> Result<(), String> {
    let mut sync = false;
    let mut package: Option<String> = None;
    for argument in args {
        match argument.as_str() {
            "--sync" => sync = true,
            other if other.starts_with("--") => {
                return Err(format!(
                    "unknown vet argument `{other}`; write `noxid vet <package[@version]>` to vet \
                     an npm package, or `noxid vet [--sync]` inside a scaffolded project to check \
                     or refresh its vendored plugins"
                ));
            }
            other if package.is_some() => {
                return Err(format!(
                    "noxid vet accepts one npm package specifier; received a second, `{other}`"
                ));
            }
            other => package = Some(other.to_string()),
        }
    }
    match (package, sync) {
        (Some(_), true) => Err(
            "`noxid vet --sync` refreshes a scaffolded project's vendored \
                                plugins from this compiler and takes no package name; run it \
                                from the project root, or drop `--sync` to vet a package"
                .into(),
        ),
        (Some(package), false) => run_vet(&package),
        (None, sync) => {
            let root = std::env::current_dir()
                .map_err(|error| format!("cannot resolve the current directory: {error}"))?;
            sync_vendored_plugins(&root, sync)
        }
    }
}

/// What one vendored file looks like in a project compared with this compiler.
enum FileState {
    /// Byte-identical to the embedded copy.
    Current,
    /// Present, but different bytes.
    Drifted { project: String, embedded: String },
    /// Named by this compiler but not on disk.
    Absent,
}

/// `noxid vet` / `noxid vet --sync` inside a scaffolded project.
///
/// The comparison is against the files compiled into this binary, never
/// against a checkout: a scaffolded project lives outside this repository and
/// has no `tools/` or `plugins/` above it to find. Without `--sync` this
/// reports and refuses; with `--sync` it rewrites the drifted files
/// byte-identically and re-renders the ledger. Nothing is ever updated
/// silently: every rewritten path is printed.
pub fn sync_vendored_plugins(project_root: &Path, sync: bool) -> Result<(), String> {
    let ledger_path = project_root.join(crate::scaffold::PLUGIN_LEDGER);
    let project_ledger = match fs::read_to_string(&ledger_path) {
        Ok(ledger) => ledger,
        // A directory holding compiler-owned vendored files with no ledger is
        // not "a directory that vendors none" — it is the exact state
        // `validate_vendored_plugins` now refuses, and this is the remedy that
        // refusal names. Treat the ledger as fully drifted so `--sync` writes
        // it back from this compiler.
        Err(_) if !vendored_plugin_files_present(project_root).is_empty() => String::new(),
        Err(error) => {
            return Err(format!(
                "error[PLUGIN_LEDGER_MISSING]: {} has no {} ({error}); `noxid vet` with no package \
                 checks the vetted plugin files a scaffolded project carries, and this directory \
                 vendors none. Run it from a project created by `noxid new --template app`, or write \
                 `noxid vet <package>` to vet an npm package.",
                project_root.display(),
                crate::scaffold::PLUGIN_LEDGER
            ));
        }
    };

    let embedded = crate::scaffold::embedded_plugins();
    let embedded_ledger = crate::scaffold::plugin_ledger(&embedded)?;

    let mut states = Vec::new();
    for (relative, contents) in &embedded.files {
        let path = project_root.join(relative);
        let state = match fs::read(&path) {
            Err(_) => FileState::Absent,
            Ok(found) if found == contents.as_bytes() => FileState::Current,
            Ok(found) => FileState::Drifted {
                project: crate::sha256::hex_digest(&found),
                embedded: crate::sha256::hex_digest(contents.as_bytes()),
            },
        };
        states.push((*relative, state));
    }
    let ledger_current = project_ledger == embedded_ledger;

    // Files the project's own ledger names that this compiler no longer
    // vendors. They are reported and left alone: `--sync` restores what this
    // compiler ships, it does not delete a project's files.
    let mut retired = Vec::new();
    for entry in ledger_entries(&project_ledger, "files") {
        let Some(relative) = ledger_field(&entry, "path") else {
            continue;
        };
        if !embedded
            .files
            .iter()
            .any(|(path, _)| *path == relative.as_str())
        {
            retired.push(relative);
        }
    }

    let width = embedded
        .files
        .iter()
        .map(|(path, _)| path.len())
        .chain(std::iter::once(crate::scaffold::PLUGIN_LEDGER.len()))
        .max()
        .unwrap_or(0);
    let mut stale = 0usize;
    for (relative, state) in &states {
        match state {
            FileState::Current => println!("  current  {relative}"),
            FileState::Drifted { project, embedded } => {
                stale += 1;
                println!(
                    "  drifted  {relative:<width$}  project {}, this compiler {}",
                    &project[..12],
                    &embedded[..12]
                );
            }
            FileState::Absent => {
                stale += 1;
                println!("  missing  {relative:<width$}  vendored by this compiler, not on disk");
            }
        }
    }
    if ledger_current {
        println!("  current  {}", crate::scaffold::PLUGIN_LEDGER);
    } else {
        stale += 1;
        println!(
            "  drifted  {:<width$}  source commit {}",
            crate::scaffold::PLUGIN_LEDGER,
            short_commit(&embedded.commit)
        );
    }
    for relative in &retired {
        println!(
            "  retired  {relative:<width$}  this compiler no longer vendors it; left in place"
        );
    }

    if stale == 0 {
        println!(
            "{} vendored plugin file(s) and {} already match this compiler ({}); nothing to sync",
            embedded.files.len(),
            crate::scaffold::PLUGIN_LEDGER,
            short_commit(&embedded.commit)
        );
        return Ok(());
    }
    if !sync {
        return Err(format!(
            "error[PLUGIN_VENDOR_DRIFT]: {stale} of {} vendored plugin record(s) in {} differ from \
             the vetted copies this compiler embeds ({}). Run `noxid vet --sync` to rewrite them \
             from this compiler, or restore the originals; `noxid vet` never updates a project on \
             its own.",
            embedded.files.len() + 1,
            project_root.display(),
            short_commit(&embedded.commit)
        ));
    }

    for (relative, state) in &states {
        if matches!(state, FileState::Current) {
            continue;
        }
        let path = project_root.join(relative);
        let contents = embedded
            .files
            .iter()
            .find(|(candidate, _)| candidate == relative)
            .map(|(_, contents)| contents)
            .expect("state built from the embedded set");
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent)
                .map_err(|error| format!("cannot create {}: {error}", parent.display()))?;
        }
        fs::write(&path, contents.as_bytes())
            .map_err(|error| format!("cannot write {}: {error}", path.display()))?;
        println!("synced {relative}");
    }
    if !ledger_current {
        fs::write(&ledger_path, &embedded_ledger)
            .map_err(|error| format!("cannot write {}: {error}", ledger_path.display()))?;
        println!("synced {}", crate::scaffold::PLUGIN_LEDGER);
    }
    println!(
        "vendored plugins now match this compiler ({})",
        short_commit(&embedded.commit)
    );

    // The ledger's pins come from the vetting records that were just written,
    // so a sync that changes a reviewed version leaves package.json and the
    // lockfile behind. Say so rather than letting the next `noxid build`
    // refuse with a code that looks unrelated to the sync.
    for entry in ledger_entries(&embedded_ledger, "pins") {
        let (Some(package), Some(version)) = (
            ledger_field(&entry, "package"),
            ledger_field(&entry, "version"),
        ) else {
            continue;
        };
        match locked_package(project_root, &package) {
            Some((locked, _)) if locked == version => {}
            _ => println!(
                "note: `{package}` is now vetted at {version}; set that version in package.json \
                 and re-run `pnpm install --frozen-lockfile` before building"
            ),
        }
    }
    Ok(())
}

/// A commit is quoted short in prose and in full in the ledger; `unknown` (a
/// build made outside a git checkout) has no short form.
fn short_commit(commit: &str) -> &str {
    if commit.len() >= 12 && commit.chars().all(|c| c.is_ascii_hexdigit()) {
        &commit[..12]
    } else {
        commit
    }
}

/// `noxid vet <package>`: run the vetting tool and scaffold the plugin record.
pub fn run_vet(package: &str) -> Result<(), String> {
    let repo_root = std::env::current_dir().map_err(|error| error.to_string())?;
    let script = find_upward(&repo_root, "tools/npm-vet.mjs")
        .ok_or("cannot find tools/npm-vet.mjs above the current directory")?;
    let output = std::process::Command::new("node")
        .arg(&script)
        .arg(package)
        .output()
        .map_err(|error| format!("cannot run the vetting tool: {error}"))?;
    let report = String::from_utf8_lossy(&output.stdout).to_string();
    if report.trim().is_empty() {
        return Err(format!(
            "the vetting tool produced no report:\n{}",
            String::from_utf8_lossy(&output.stderr)
        ));
    }
    // Pull package identity out of the JSON without a JSON dependency: the
    // report's first fields are flat strings written by our own tool.
    let field = |name: &str| -> Option<String> {
        let key = format!("\"{name}\": \"");
        let start = report.find(&key)? + key.len();
        let end = report[start..].find('"')? + start;
        Some(report[start..end].to_string())
    };
    let identity = field("package").ok_or("vet report is missing the package field")?;
    let (name, version) = identity
        .rsplit_once('@')
        .ok_or("vet report package field is not name@version")?;
    let integrity = field("integrity").ok_or("vet report is missing the integrity field")?;
    let verdict = field("verdict").unwrap_or_else(|| "REVIEW".into());
    let plugin_root = script
        .parent()
        .and_then(Path::parent)
        .ok_or("cannot resolve repository root")?
        .join("plugins")
        .join(plugin_directory_name(name));
    fs::create_dir_all(&plugin_root).map_err(|error| error.to_string())?;
    let record_path = plugin_root.join("VETTING.md");
    let record = format!(
        "# Vetting record: {name}\n\npackage: {name}\nversion: {version}\nintegrity: {integrity}\nverdict: {verdict}\nreviewed-by: (fill in)\ndate: (fill in)\n\n## Justifications\n\n(review every finding in the report below; justify or reject each\nhigh/medium finding in writing — see docs/npm-admission.md)\n\n## Stage-1 report\n\n```json\n{report}```\n",
    );
    fs::write(&record_path, record).map_err(|error| error.to_string())?;
    println!(
        "vetted {name}@{version} ({verdict}) -> {}",
        record_path.display()
    );
    if verdict == "REJECT-OR-JUSTIFY" {
        println!("high findings present: justify them in the record before admitting");
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn npm_specifiers_resolve_to_package_roots() {
        assert_eq!(
            npm_package_name("es-toolkit/array").as_deref(),
            Some("es-toolkit")
        );
        assert_eq!(
            npm_package_name("@formkit/tempo/dist").as_deref(),
            Some("@formkit/tempo")
        );
        assert_eq!(npm_package_name("./search.js"), None);
        assert_eq!(npm_package_name("../lib/helpers.js"), None);
        assert_eq!(plugin_directory_name("@formkit/tempo"), "formkit__tempo");
    }

    #[test]
    fn lock_lookup_accepts_pnpm_quoted_scoped_package_keys() {
        let lock = "packages:\n\n  '@formkit/auto-animate@0.10.0':\n    resolution: {integrity: sha512-test}\n\n  plain@1.2.3:\n    resolution: {integrity: sha512-plain}\n";
        assert_eq!(
            locked_package_from_text(lock, "@formkit/auto-animate"),
            Some(("0.10.0".into(), "sha512-test".into()))
        );
        assert_eq!(
            locked_package_from_text(lock, "plain"),
            Some(("1.2.3".into(), "sha512-plain".into()))
        );
    }
}