noxid-cli 0.2.1

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
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
//! WO-54 (folded into WO-50 round 2): a scaffolded project carries the vetted
//! plugin files it imports, pinned to the versions those files were reviewed
//! against, and reads and writes its declared scoped table through them.
//!
//! The round trip below is the point of the order: the template's endpoint and
//! action must reach real SQLite through the vendored adapter, not a
//! process-local map. It goes through `dist/server/handler.js`'s exported
//! `fetch` rather than `dist/server.mjs`, because the emitted deployment
//! adapters forward only `/_noxid/` paths today (WO-50 QA round 1, finding 2;
//! Lane A owns `crates/cli/src/deployment.rs`).

use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};

static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(0);

fn repository() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("../..")
        .canonicalize()
        .expect("canonical repository root")
}

struct Fixture {
    root: PathBuf,
}

impl Fixture {
    fn new(label: &str) -> Self {
        let nonce = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .expect("clock after Unix epoch")
            .as_nanos();
        let ordinal = NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed);
        let root = std::env::temp_dir().join(format!(
            "noxid-wo54-{label}-{}-{ordinal}-{nonce}",
            std::process::id()
        ));
        fs::create_dir_all(&root).expect("create fixture root");
        Self { root }
    }

    fn noxid(&self, directory: &Path, arguments: &[&str]) -> Output {
        Command::new(env!("CARGO_BIN_EXE_noxid"))
            .args(arguments)
            .current_dir(directory)
            .env_remove("DATABASE_URL")
            .env_remove("SESSION_SECRET")
            .output()
            .expect("run noxid")
    }

    fn scaffold_app(&self, name: &str) -> PathBuf {
        let output = self.noxid(&self.root, &["new", name, "--template", "app"]);
        assert_success(&output, "scaffold the app template");
        self.root.join(name)
    }
}

impl Drop for Fixture {
    fn drop(&mut self) {
        let _ = fs::remove_dir_all(&self.root);
    }
}

fn text(output: &Output) -> String {
    format!(
        "stdout:\n{}\nstderr:\n{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    )
}

fn assert_success(output: &Output, context: &str) {
    assert!(
        output.status.success(),
        "{context} failed:\n{}",
        text(output)
    );
}

fn node_binary() -> PathBuf {
    for candidate in [
        PathBuf::from("/opt/homebrew/opt/node@22/bin/node"),
        PathBuf::from("node"),
    ] {
        let available = Command::new(&candidate)
            .args([
                "--input-type=module",
                "--eval",
                "await import('node:sqlite')",
            ])
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .status();
        if matches!(available, Ok(status) if status.success()) {
            return candidate;
        }
    }
    panic!("WO-54 requires Node.js 22 with the node:sqlite synchronous API");
}

/// The developer's first install, which `noxid new` deliberately does not run.
///
/// `pnpm install --frozen-lockfile` is the documented step and is used when
/// pnpm is on PATH and its store can satisfy the committed lockfile. When it is
/// not (a sandbox with no pnpm, or an empty store and no network), the two
/// pinned packages are provisioned by linking this repository's own installed
/// tree — the same `drizzle-orm@0.45.2` and `postgres@3.4.9` the lockfile
/// names. Only the provisioning changes; every assertion below is identical.
fn install_pinned_packages(project: &Path) -> &'static str {
    let installed = Command::new("pnpm")
        .args(["install", "--frozen-lockfile", "--prefer-offline"])
        .current_dir(project)
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .status();
    if matches!(installed, Ok(status) if status.success()) {
        assert!(
            project.join("node_modules/drizzle-orm").exists(),
            "pnpm install --frozen-lockfile did not install the pinned adapter dependency"
        );
        return "pnpm install --frozen-lockfile";
    }
    let packages = repository().join("node_modules");
    assert!(
        packages.join("drizzle-orm").exists() && packages.join("postgres").exists(),
        "neither pnpm nor this repository's node_modules can provide the pinned packages"
    );
    #[cfg(unix)]
    std::os::unix::fs::symlink(&packages, project.join("node_modules"))
        .expect("link the repository's installed packages into the scaffold");
    #[cfg(windows)]
    std::os::windows::fs::symlink_dir(&packages, project.join("node_modules"))
        .expect("link the repository's installed packages into the scaffold");
    "vendored from this repository's node_modules"
}

fn run_node(node: &Path, directory: &Path, source: &str) -> Output {
    Command::new(node)
        .args(["--input-type=module", "--eval", source])
        .current_dir(directory)
        .output()
        .expect("run the Node probe")
}

#[test]
fn scaffolding_vendors_the_vetted_plugin_files_byte_identically_with_a_hash_ledger() {
    let fixture = Fixture::new("vendor");
    let project = fixture.scaffold_app("demo");
    let repository = repository();

    let vendored = [
        "plugins/drizzle-orm/VETTING.md",
        "plugins/drizzle-orm/adapter.js",
        "plugins/drizzle-orm/data-scopes.test.mjs",
        "plugins/postgres/VETTING.md",
        "tools/database-url.mjs",
        "tools/node-sqlite.mjs",
    ];
    let ledger = fs::read_to_string(project.join(".noxid-plugins.json")).expect("read the ledger");
    for relative in vendored {
        let scaffolded = fs::read(project.join(relative))
            .unwrap_or_else(|error| panic!("read scaffolded {relative}: {error}"));
        let original = fs::read(repository.join(relative))
            .unwrap_or_else(|error| panic!("read repository {relative}: {error}"));
        assert_eq!(
            scaffolded, original,
            "{relative} was not vendored byte-identically"
        );
        assert!(
            ledger.contains(&format!("\"path\": \"{relative}\"")),
            "{relative} is vendored but absent from the ledger:\n{ledger}"
        );
    }

    // The pins are the vetting records' own headers, not a second copy of the
    // versions that could drift away from the review.
    for (record, package) in [
        ("plugins/drizzle-orm/VETTING.md", "drizzle-orm"),
        ("plugins/postgres/VETTING.md", "postgres"),
    ] {
        let contents = fs::read_to_string(repository.join(record)).expect("read vetting record");
        let version = contents
            .lines()
            .find_map(|line| line.trim().strip_prefix("version:"))
            .expect("vetting record version")
            .trim();
        assert!(
            ledger.contains(&format!(
                "\"package\": \"{package}\", \"version\": \"{version}\""
            )),
            "the ledger does not pin {package} at the vetted {version}:\n{ledger}"
        );
        let manifest =
            fs::read_to_string(project.join("package.json")).expect("read scaffold package.json");
        assert!(
            manifest.contains(&format!("\"{package}\": \"{version}\"")),
            "package.json does not pin {package} at the vetted {version}:\n{manifest}"
        );
    }

    // `noxid new` runs no package manager: nothing is installed and no lock is
    // regenerated. The lockfile is the committed one from the template.
    assert!(
        !project.join("node_modules").exists(),
        "`noxid new` installed packages; the first install is the developer's"
    );
    assert_eq!(
        fs::read_to_string(project.join("pnpm-lock.yaml")).expect("scaffold lockfile"),
        fs::read_to_string(repository.join("examples/templates/app/pnpm-lock.yaml"))
            .expect("template lockfile"),
        "the scaffolded lockfile is not the committed template lockfile"
    );
}

#[test]
fn the_scaffolded_endpoint_and_action_round_trip_real_sqlite_through_the_vendored_adapter() {
    let fixture = Fixture::new("round-trip");
    let project = fixture.scaffold_app("demo");
    let provisioning = install_pinned_packages(&project);
    let node = node_binary();

    let database = project.join("app.db");
    let database_url = format!("sqlite://{}", database.display());
    let migrated = Command::new(env!("CARGO_BIN_EXE_noxid"))
        .args(["db", "migrate"])
        .current_dir(&project)
        .env("DATABASE_URL", &database_url)
        .output()
        .expect("migrate the scaffolded database");
    assert_success(&migrated, "migrate the scaffolded database");

    assert_success(
        &fixture.noxid(&project, &["build", ".", "--out-dir", "dist"]),
        "build the scaffolded project",
    );
    assert_success(
        &fixture.noxid(
            &project,
            &["adapt", ".", "--adapter", "node", "--out-dir", "dist"],
        ),
        "adapt the scaffolded project for Node",
    );

    // The endpoint is reached through the shipped handler's exported `fetch`,
    // which is the boundary the deployment adapters are supposed to forward to.
    let probe = format!(
        r#"
process.env.DATABASE_URL = {database_url:?};
process.env.SESSION_SECRET = "wo54-round-trip-secret";
const {{ fetch: handler }} = await import("./dist/server/handler.js");
const origin = "http://127.0.0.1:9999";
const call = (path, init) => handler(new Request(origin + path, init), process.env, {{ waitUntil() {{}} }});

// Round 4: the scaffold is secure by default. The unauthenticated refusal is
// asserted first, then the round trip runs under the session the shipped
// sign-in door mints.
const unauthenticated = await call("/api/notes/welcome");
console.log(`unauth-status=${{unauthenticated.status}}`);
console.log(`unauth-code=${{(await unauthenticated.json())?.error?.code ?? ""}}`);

const signIn = await call("/_noxid/actions/action%3AAppPage.signIn", {{
  method: "POST",
  headers: {{ "content-type": "application/json", "x-noxid-route-id": "route:/" }},
  body: JSON.stringify({{ arguments: {{ request: {{ displayName: "wo54trip" }} }} }}),
}});
const signInBody = await signIn.json();
const cookie = ((signIn.headers.getSetCookie?.() ?? [])[0] ?? "").split(";")[0];
console.log(`signin-status=${{signIn.status}}`);
console.log(`signin-principal=${{signInBody?.value?.userId ?? ""}}`);
console.log(`signin-cookie=${{cookie.startsWith("noxid_session=")}}`);

const before = await call("/api/notes/welcome", {{ headers: {{ cookie }} }});
const beforeType = before.headers.get("content-type") ?? "";
const beforeBody = await before.text();
console.log(`before-status=${{before.status}}`);
console.log(`before-json=${{beforeType.startsWith("application/json")}}`);
console.log(`before-empty=${{beforeBody.includes("\"notes\":[]")}}`);

const action = await call("/_noxid/actions/action%3AAppPage.createNote", {{
  method: "POST",
  headers: {{ "content-type": "application/json", "x-noxid-route-id": "route:/", cookie }},
  body: JSON.stringify({{ arguments: {{ request: {{ boardId: "welcome", title: "Written through SQLite" }} }} }}),
}});
const actionBody = await action.text();
console.log(`action-status=${{action.status}}`);
console.log(`action-created=${{actionBody.includes("Written through SQLite")}}`);

const after = await call("/api/notes/welcome", {{ headers: {{ cookie }} }});
const afterBody = await after.text();
console.log(`after-status=${{after.status}}`);
console.log(`after-read-write=${{afterBody.includes("Written through SQLite")}}`);

const {{ DatabaseSync }} = await import("node:sqlite");
const database = new DatabaseSync({database_url_path:?}, {{ readOnly: true }});
const rows = database.prepare("SELECT id, owner_id, board_id, title, done FROM notes ORDER BY id").all();
database.close();
console.log(`sqlite-rows=${{JSON.stringify(rows)}}`);
"#,
        database_url = database_url,
        database_url_path = database.display().to_string(),
    );
    let requests = run_node(&node, &project, &probe);
    let evidence = text(&requests);
    assert_success(&requests, "call the endpoint through the shipped handler");

    for expected in [
        "unauth-status=403",
        "unauth-code=SESSION_PRINCIPAL_REQUIRED",
        "signin-status=200",
        "signin-principal=wo54trip",
        "signin-cookie=true",
        "before-status=200",
        "before-json=true",
        "before-empty=true",
        "action-status=200",
        "action-created=true",
        "after-status=200",
        "after-read-write=true",
    ] {
        assert!(
            evidence.contains(expected),
            "the scaffolded full-stack round trip is missing `{expected}` (packages: \
             {provisioning}):\n{evidence}"
        );
    }
    // The row the action wrote is in the file, scoped to the runtime principal
    // the session middleware resolved — the declared policy, enforced.
    assert!(
        evidence.contains("\"owner_id\":\"wo54trip\"")
            && evidence.contains("\"title\":\"Written through SQLite\""),
        "the scoped SQLite table does not hold the principal-bound row the action wrote \
         (packages: {provisioning}):\n{evidence}"
    );
}

#[test]
fn editing_a_vendored_plugin_file_refuses_the_build() {
    let fixture = Fixture::new("vendor-drift");
    let project = fixture.scaffold_app("demo");
    let adapter = project.join("plugins/drizzle-orm/adapter.js");
    let original = fs::read_to_string(&adapter).expect("read the vendored adapter");
    fs::write(&adapter, format!("{original}\n// a local edit\n")).expect("edit the adapter");

    let built = fixture.noxid(&project, &["build", ".", "--out-dir", "dist"]);
    let evidence = text(&built);
    assert!(
        !built.status.success() && evidence.contains("error[PLUGIN_VENDOR_DRIFT]"),
        "an edited vendored adapter did not refuse the build:\n{evidence}"
    );
    assert!(
        !project.join("dist").exists(),
        "the refused build still emitted output"
    );
}

#[test]
fn a_lockfile_that_disagrees_with_the_pinned_vetted_version_refuses_the_build() {
    let fixture = Fixture::new("lock-drift");
    let project = fixture.scaffold_app("demo");
    let lockfile = project.join("pnpm-lock.yaml");
    let locked = fs::read_to_string(&lockfile).expect("read the scaffold lockfile");
    assert!(locked.contains("drizzle-orm@0.45.2:"), "{locked}");
    fs::write(
        &lockfile,
        locked.replace("drizzle-orm@0.45.2:", "drizzle-orm@0.45.1:"),
    )
    .expect("drift the lockfile");

    let built = fixture.noxid(&project, &["build", ".", "--out-dir", "dist"]);
    let evidence = text(&built);
    assert!(
        !built.status.success() && evidence.contains("error[NPM_IMPORT_UNVETTED]"),
        "a lockfile that disagrees with the vetted pin did not refuse the build:\n{evidence}"
    );
    assert!(
        evidence.contains("0.45.1") && evidence.contains("0.45.2"),
        "the refusal did not name both the locked and the vetted version:\n{evidence}"
    );
}

#[test]
fn a_missing_lockfile_entry_for_a_pinned_package_refuses_the_build() {
    let fixture = Fixture::new("lock-missing");
    let project = fixture.scaffold_app("demo");
    fs::remove_file(project.join("pnpm-lock.yaml")).expect("remove the scaffold lockfile");

    let built = fixture.noxid(&project, &["build", ".", "--out-dir", "dist"]);
    let evidence = text(&built);
    assert!(
        !built.status.success() && evidence.contains("error[NPM_IMPORT_UNVETTED]"),
        "a project with no lockfile for its pinned vetted packages still built:\n{evidence}"
    );
}

#[test]
fn the_vendored_data_scope_test_runs_inside_the_scaffold() {
    let fixture = Fixture::new("data-scopes");
    let project = fixture.scaffold_app("demo");
    let provisioning = install_pinned_packages(&project);
    let node = node_binary();

    let output = Command::new(&node)
        .args(["--test", "plugins/drizzle-orm/data-scopes.test.mjs"])
        .current_dir(&project)
        .output()
        .expect("run the vendored data-scopes test");
    assert!(
        output.status.success(),
        "the vendored scope-boundary proof does not run in a scaffolded project (packages: \
         {provisioning}):\n{}",
        text(&output)
    );
}

/// `noxid vet` inside a scaffolded project answers from the bytes compiled
/// into the binary, so these tests run from a fixture directory that has no
/// `plugins/`, `tools/`, or `Noxid.toml` above it — the round-2 failure was a
/// `tools/npm-vet.mjs` lookup that only resolved inside this checkout.
#[test]
fn vet_reports_vendored_drift_and_refuses_until_sync_restores_the_build() {
    let fixture = Fixture::new("vet-drift");
    let project = fixture.scaffold_app("demo");
    let adapter = project.join("plugins/drizzle-orm/adapter.js");
    let vetted = fs::read(&adapter).expect("read the vendored adapter");
    let ledger = fs::read(project.join(".noxid-plugins.json")).expect("read the ledger");

    let mut tampered = vetted.clone();
    let last = tampered.len() - 1;
    tampered[last] ^= 1;
    fs::write(&adapter, &tampered).expect("tamper with one vendored byte");

    let reported = fixture.noxid(&project, &["vet"]);
    let report = text(&reported);
    assert!(
        !reported.status.success(),
        "`noxid vet` accepted a drifted vendored file:\n{report}"
    );
    assert!(
        report.contains("PLUGIN_VENDOR_DRIFT")
            && report.contains("drifted  plugins/drizzle-orm/adapter.js")
            && report.contains("current  tools/node-sqlite.mjs"),
        "`noxid vet` did not name the drifted file in a per-file report:\n{report}"
    );
    assert_eq!(
        fs::read(&adapter).expect("read the adapter after a refusal"),
        tampered,
        "`noxid vet` without --sync rewrote a file"
    );

    let synced = fixture.noxid(&project, &["vet", "--sync"]);
    assert_success(&synced, "sync the drifted vendored file");
    assert!(
        text(&synced).contains("synced plugins/drizzle-orm/adapter.js"),
        "`--sync` rewrote a file without saying which:\n{}",
        text(&synced)
    );
    assert_eq!(
        fs::read(&adapter).expect("read the restored adapter"),
        vetted,
        "`--sync` did not restore the embedded copy byte for byte"
    );
    assert_eq!(
        fs::read(project.join(".noxid-plugins.json")).expect("read the ledger"),
        ledger,
        "`--sync` changed a ledger that already matched this compiler"
    );
    assert_success(
        &fixture.noxid(&project, &["build", ".", "--out-dir", "dist"]),
        "build the synced project",
    );
}

/// The acceptance case `--sync` exists for: a project scaffolded by an older
/// compiler, whose vendored files and ledger agree with each other (so
/// `noxid build` accepts them) but not with this compiler.
#[test]
fn an_older_scaffold_syncs_its_vendored_files_and_ledger_to_this_compiler() {
    let fixture = Fixture::new("vet-older-cli");
    let current = fixture.scaffold_app("current");

    let older = fixture.root.join("older");
    let scaffolded = Command::new(env!("CARGO_BIN_EXE_noxid"))
        .args(["new", "older", "--template", "app"])
        .current_dir(&fixture.root)
        .env(
            "NOXID_TEST_OLDER_VENDORED_PLUGIN",
            "plugins/drizzle-orm/adapter.js",
        )
        .output()
        .expect("scaffold with the older-compiler hook");
    assert_success(&scaffolded, "scaffold a project from an older compiler");

    let adapter = older.join("plugins/drizzle-orm/adapter.js");
    let ledger = older.join(".noxid-plugins.json");
    assert_ne!(
        fs::read(&adapter).expect("read the older adapter"),
        fs::read(current.join("plugins/drizzle-orm/adapter.js")).expect("read this adapter"),
        "the older-compiler hook produced the current vendored bytes"
    );
    // Self-consistent, so the build gate has nothing to say: only `noxid vet`
    // can tell this project it is behind.
    assert_success(
        &fixture.noxid(&older, &["build", ".", "--out-dir", "dist"]),
        "build the older scaffold, whose ledger matches its own files",
    );

    let reported = fixture.noxid(&older, &["vet"]);
    assert!(
        !reported.status.success() && text(&reported).contains("PLUGIN_VENDOR_DRIFT"),
        "`noxid vet` did not notice an older scaffold:\n{}",
        text(&reported)
    );

    let synced = fixture.noxid(&older, &["vet", "--sync"]);
    assert_success(&synced, "sync an older scaffold");
    let synced_text = text(&synced);
    assert!(
        synced_text.contains("synced plugins/drizzle-orm/adapter.js")
            && synced_text.contains("synced .noxid-plugins.json"),
        "`--sync` did not report both the file and the ledger:\n{synced_text}"
    );
    assert_eq!(
        fs::read(&adapter).expect("read the synced adapter"),
        fs::read(current.join("plugins/drizzle-orm/adapter.js")).expect("read this adapter"),
        "`--sync` did not bring the vendored file up to this compiler"
    );
    assert_eq!(
        fs::read_to_string(&ledger).expect("read the synced ledger"),
        fs::read_to_string(current.join(".noxid-plugins.json")).expect("read this ledger"),
        "`--sync` did not update the ledger's hashes and source commit"
    );
    assert_success(
        &fixture.noxid(&older, &["build", ".", "--out-dir", "synced-dist"]),
        "build the synced older scaffold",
    );
}

#[test]
fn vet_sync_on_a_current_scaffold_changes_nothing_and_says_so() {
    let fixture = Fixture::new("vet-noop");
    let project = fixture.scaffold_app("demo");
    let before = fs::read(project.join("plugins/drizzle-orm/adapter.js")).expect("read adapter");
    let ledger = fs::read(project.join(".noxid-plugins.json")).expect("read ledger");

    for arguments in [&["vet"][..], &["vet", "--sync"][..]] {
        let output = fixture.noxid(&project, arguments);
        assert_success(&output, "check a current scaffold");
        let report = text(&output);
        assert!(
            report.contains("nothing to sync") && !report.contains("synced "),
            "`noxid {}` on a current scaffold did not report a no-op:\n{report}",
            arguments.join(" ")
        );
        assert_eq!(
            fs::read(project.join("plugins/drizzle-orm/adapter.js")).expect("read adapter"),
            before,
            "a no-op rewrote a vendored file"
        );
        assert_eq!(
            fs::read(project.join(".noxid-plugins.json")).expect("read ledger"),
            ledger,
            "a no-op rewrote the ledger"
        );
    }
}

/// A directory that vendors nothing is told what `noxid vet` is for rather
/// than being handed a repository-shaped error.
#[test]
fn vet_outside_a_scaffolded_project_explains_itself() {
    let fixture = Fixture::new("vet-no-ledger");
    let refused = fixture.noxid(&fixture.root, &["vet"]);
    let report = text(&refused);
    assert!(
        !refused.status.success()
            && report.contains("PLUGIN_LEDGER_MISSING")
            && report.contains("noxid new --template app")
            && !report.contains("npm-vet.mjs"),
        "`noxid vet` outside a scaffolded project did not explain itself:\n{report}"
    );
}

// ---------------------------------------------------------------------------
// Checkpoint-3 security read, F2: the ledger is the only thing that vouches
// for the vendored adapter's contents, and deleting it used to disable the
// check silently. `validate_vendored_plugins` returned `Ok(())` whenever the
// ledger could not be read, so `rm .noxid-plugins.json` let a modified
// `plugins/drizzle-orm/adapter.js` — the project's sole principal authority,
// selected by position — build clean.

#[test]
fn a_missing_ledger_beside_vendored_plugin_files_refuses_the_build_and_names_the_remedy() {
    let fixture = Fixture::new("ledger-deleted");
    let project = fixture.scaffold_app("demo");
    let adapter = project.join("plugins/drizzle-orm/adapter.js");
    let vetted = fs::read(&adapter).expect("read the vendored adapter");
    let ledger = project.join(".noxid-plugins.json");
    assert!(ledger.is_file(), "the scaffold wrote no ledger");

    // The whole attack in two commands: edit the file the ledger protects,
    // then delete the ledger.
    fs::write(
        &adapter,
        format!(
            "{}\n// tamper\nglobalThis.__TAMPER__ = 1;\n",
            String::from_utf8_lossy(&vetted)
        ),
    )
    .expect("tamper with the vendored adapter");
    fs::remove_file(&ledger).expect("delete the ledger");

    let built = fixture.noxid(&project, &["build", ".", "--out-dir", "dist"]);
    let evidence = text(&built);
    assert!(
        !built.status.success(),
        "a tampered adapter with the ledger deleted still built:\n{evidence}"
    );
    assert!(
        evidence.contains("error[PLUGIN_LEDGER_MISSING]")
            && evidence.contains("plugins/drizzle-orm/adapter.js"),
        "the refusal does not name the missing ledger or the file it protects:\n{evidence}"
    );
    assert!(
        evidence.contains("noxid vet --sync"),
        "the refusal does not name a remedy the CLI can actually perform:\n{evidence}"
    );
    assert!(
        !project.join("dist").exists(),
        "the refused build still emitted output"
    );

    // The named remedy is real: `noxid vet --sync` re-vendors the files from
    // this compiler and writes the ledger back.
    let synced = fixture.noxid(&project, &["vet", "--sync"]);
    assert_success(&synced, "re-vendor the plugin files after a deleted ledger");
    assert!(
        ledger.is_file(),
        "`noxid vet --sync` did not restore the ledger:\n{}",
        text(&synced)
    );
    assert_eq!(
        fs::read(&adapter).expect("read the restored adapter"),
        vetted,
        "`noxid vet --sync` did not restore the tampered adapter byte-identically"
    );
    let rechecked = fixture.noxid(&project, &["vet"]);
    assert_success(&rechecked, "check the repaired scaffold");
    assert!(
        text(&rechecked).contains("nothing to sync"),
        "the repaired scaffold still reports drift:\n{}",
        text(&rechecked)
    );
}

/// The other half of failing closed: a project that never vendored a
/// compiler-owned plugin has nothing for a ledger to record, and must not be
/// asked for one.
#[test]
fn a_project_that_vendors_no_plugins_needs_no_ledger() {
    let fixture = Fixture::new("no-plugins");
    let project = fixture.root.join("plain");
    fs::create_dir_all(project.join("src/routes")).expect("create plain project");
    fs::write(
        project.join("Noxid.toml"),
        "[app]\ntitle = \"No plugins\"\nroutes = \"src/routes\"\n",
    )
    .expect("write manifest");
    fs::write(project.join("package.json"), "{\"type\":\"module\"}\n").expect("write package");
    fs::write(
        project.join("src/routes/+page.nox"),
        "component Home { route { title: \"Home\" } view { <main>plain</main> } }\n",
    )
    .expect("write route");
    assert!(
        !project.join(".noxid-plugins.json").exists() && !project.join("plugins").exists(),
        "the control project must vendor nothing"
    );

    let built = fixture.noxid(&project, &["build", ".", "--out-dir", "dist"]);
    assert_success(&built, "build a project that vendors no plugins");
    assert!(
        !text(&built).contains("PLUGIN_LEDGER_MISSING"),
        "a project with no vendored plugins was asked for a ledger:\n{}",
        text(&built)
    );
}