spec-spine-cli 0.21.0

The `spec-spine` command-line tool: compile a markdown spec corpus into a deterministic authority registry and query it. A thin wrapper over spec-spine-core.
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
//! `spec-spine`: the multi-call CLI. A thin wrapper over `spec-spine-core`:
//! it parses args, loads config, calls the engine, prints results, and maps the
//! typed `Error` to a stable process exit code. All `process::exit`, stdout, and
//! `git`/clock side effects live here, never in the library.

/// `println!` for stdout that does not panic when the reader goes away.
///
/// Defined before the `mod` items below so every submodule sees it (textual
/// macro scoping). See `out.rs` for why this exists (spec 035).
macro_rules! outln {
    () => { $crate::out::line(format_args!("")) };
    ($($arg:tt)*) => { $crate::out::line(format_args!($($arg)*)) };
}

/// `print!` for stdout that does not panic when the reader goes away. For
/// pre-formatted blocks that already carry their own trailing newline.
macro_rules! out {
    ($($arg:tt)*) => { $crate::out::block(format_args!($($arg)*)) };
}

mod cmd_attest;
mod cmd_check;
mod cmd_compile;
mod cmd_config;
mod cmd_couple;
mod cmd_delta;
mod cmd_index;
mod cmd_init;
mod cmd_lint;
mod cmd_registry;
mod cmd_verify;
mod out;
mod seal;
mod verify_attestation;

use std::path::{Path, PathBuf};
use std::process::ExitCode;

use clap::{Parser, Subcommand};
use spec_spine_types::{Config, Error, Verdict, verdict::verb};

#[derive(Parser)]
#[command(
    name = "spec-spine",
    version,
    about = "A typed, hash-verifiable authority ledger over a markdown spec corpus."
)]
struct Cli {
    /// Repository root (defaults to the current directory).
    #[arg(long, global = true, value_name = "DIR")]
    repo: Option<PathBuf>,
    #[command(subcommand)]
    command: Command,
}

#[derive(Subcommand)]
enum Command {
    /// Compile specs/*/spec.md into a deterministic registry.
    Compile {
        /// Verify the committed shards match the corpus without writing
        /// anything (exit 2 if stale). The registry counterpart of
        /// `index check`.
        #[arg(long)]
        check: bool,
        /// Emit the verdict as a JSON envelope on stdout (spec 037). Requires
        /// `--check` or `--spec`: the writing form mutates `.derived`, and its
        /// verdict is deliberately not machine-readable (spec 037 4).
        #[arg(long)]
        json: bool,
        /// Validate exactly one spec and write nothing (spec 056). Accepts the
        /// short id (`056`). Incompatible with `--check`.
        #[arg(long, value_name = "ID")]
        spec: Option<String>,
        /// Fail (exit 1) when the compile produces any warning-tier violation
        /// (spec 077). Accepted on every form of the verb: it changes the exit
        /// code only, never `validation.passed` and never an emitted byte, and
        /// an exit code is not written output, so spec 037 §4's withholding of
        /// `--json` from the writing form does not reach it.
        #[arg(long)]
        fail_on_warn: bool,
    },
    /// Both freshness reads in one verb: are the committed registry shards and
    /// the committed index shards current (spec 075)?
    ///
    /// Additive over `compile --check` and `index check`, which keep their
    /// flags and their contracts. Reads only; it never repairs the tree it is
    /// judging. The exit code is the most severe of the two: 3, then 1, then 2,
    /// then 0.
    Check {
        /// Fail (exit 1) when the committed index records any unresolved-unit
        /// diagnostic. Forwarded to the index half, so the composite gate can
        /// call this verb where it called `index check --fail-on-unresolved`.
        #[arg(long)]
        fail_on_unresolved: bool,
        /// Fail (exit 1) when the compile produces any warning-tier violation
        /// (spec 077 §3.3). Forwarded to the **compile** half, the mirror of
        /// `--fail-on-unresolved` above. This is the only form CI can call:
        /// the self-governance job runs `check` in place of `compile` and
        /// `index`, so a `--fail-on-warn` that existed only on the primitive
        /// would be unreachable from the chain that runs on a pull request.
        ///
        /// Independent of `--fail-on-unresolved`: neither implies the other,
        /// and either may be passed alone.
        #[arg(long)]
        fail_on_warn: bool,
        /// Emit the verdict as a JSON envelope on stdout (spec 037).
        #[arg(long)]
        json: bool,
    },
    /// Read the effective configuration: every default resolved, and the
    /// built-in bypass floor merged with the adopter's list and attributed.
    Config {
        #[command(subcommand)]
        action: cmd_config::ConfigAction,
    },
    /// Read-only queries over the compiled registry.
    Registry {
        #[command(subcommand)]
        query: cmd_registry::RegistryQuery,
    },
    /// Build the codebase index, or check it for staleness.
    Index {
        #[command(subcommand)]
        action: Option<cmd_index::IndexAction>,
    },
    /// Run the corpus conformance lint.
    Lint {
        /// Fail (exit 1) if any warning-tier diagnostic is present.
        #[arg(long)]
        fail_on_warn: bool,
        /// Fail (exit 1) if any info-tier diagnostic is present.
        #[arg(long)]
        fail_on_info: bool,
        /// Emit the verdict as a JSON envelope on stdout (spec 037).
        #[arg(long)]
        json: bool,
    },
    /// Run a spec's declared acceptance: the `verify:cli` commands under its
    /// `## Verification` heading, in order, stopping at the first failure.
    ///
    /// Runs code the corpus declares (spec 049), so it is deliberately not part
    /// of the gate chain. `<id>` accepts the short form (`049`).
    Verify {
        /// Spec id, full (`049-slug`) or short (`049`).
        id: String,
        /// Print the commands that would run, one per line, and run none of
        /// them. Reading the plan before executing it is the safety affordance
        /// for the one verb that runs what the corpus declares.
        #[arg(long)]
        plan: bool,
        /// Emit the verdict as a JSON envelope on stdout (spec 037).
        #[arg(long)]
        json: bool,
    },
    /// The PR-time coupling gate: refuse code that drifts from its owning spec.
    Couple {
        /// Base ref for the diff (merge-base of `base...head`).
        #[arg(long, default_value = "origin/main")]
        base: String,
        /// Head ref for the diff.
        #[arg(long, default_value = "HEAD")]
        head: String,
        /// PR body (waiver source); a file path. Falls back to $SPEC_SPINE_PR_BODY.
        #[arg(long)]
        pr_body: Option<PathBuf>,
        /// Override the diff: read newline-delimited changed paths from this file
        /// (whole-file authority; no hunk data).
        #[arg(long)]
        paths_from: Option<PathBuf>,
        /// Also judge the index and working tree, so a pre-commit run sees the
        /// change being committed (spec 102). Only valid when `--head` resolves
        /// to `HEAD`, and never with `--paths-from`.
        #[arg(long)]
        include_uncommitted: bool,
        /// Emit the verdict as a JSON envelope on stdout (spec 037).
        #[arg(long)]
        json: bool,
    },
    /// Classify every path a change touches under the merge base's rules
    /// (spec 088). A report, not a gate: exit 0 whenever a report was produced.
    ///
    /// Each changed path carries every class that applies: implementation,
    /// requirement, verification, authority, lifecycle, constitutional, policy,
    /// derived, bypassed, unowned or unknown. The configuration and the index
    /// that classify are the merge base's, so a change cannot reclassify itself
    /// by editing `spec-spine.toml`. `priorPolicy.required` is true when any
    /// path carries requirement, verification, authority, lifecycle,
    /// constitutional, policy or unknown, and those classes are to be judged
    /// under the base revision's policy.
    ///
    /// `required: false` means only that no structural class above changed. It
    /// does not mean the change is safe, correct or approved: spec-spine decides
    /// nothing here and refuses nothing.
    Delta {
        /// Base ref; the change is `merge-base(base, head)...head`.
        #[arg(long, default_value = "origin/main")]
        base: String,
        /// Head ref.
        #[arg(long, default_value = "HEAD")]
        head: String,
        /// Emit the report as a JSON envelope on stdout (spec 037).
        #[arg(long)]
        json: bool,
    },
    /// Scaffold a new adopter: config, standards, a bootstrap spec, agent rules.
    Init {
        /// Overwrite existing files instead of skipping them.
        #[arg(long)]
        force: bool,
        /// Also install the session harness: settings, agents, skills, the
        /// Makefile and a CI workflow, at the adopter's own paths (spec 065).
        #[arg(long)]
        with_kit: bool,
    },
    /// Emit a reproducible corpus attestation; optionally seal it (spec 023).
    Attest {
        /// Scope the attestation to one spec (spec 042), writing
        /// `<derived>/attestation/by-spec/<id>.json`. Accepts the short id
        /// (`042`); the file is named by the resolved id. Records the verdicts;
        /// it is not a gate, and exit 0 means only that an attestation was
        /// written.
        #[arg(long, value_name = "ID")]
        spec: Option<String>,
        /// Also record the coupling (specs-and-code-in-sync) verdict.
        #[arg(long)]
        with_coupling: bool,
        /// Emit an authority snapshot instead (spec 087), writing
        /// `<derived>/attestation/snapshot.json`: which inputs were read, what
        /// they hashed to, whether the committed ledger matches the recompute,
        /// and every spec's territory digest. Cannot combine with `--spec` or
        /// `--with-coupling`.
        #[arg(long)]
        snapshot: bool,
        /// Produce a detached Ed25519 seal over the attestation hash.
        #[arg(long)]
        sign: bool,
        /// The ed25519 signing key (32-byte seed; raw or hex). Required with --sign.
        #[arg(long, value_name = "PATH")]
        key: Option<PathBuf>,
        /// Override the seal's key id (defaults to the hex public key).
        #[arg(long, value_name = "ID")]
        key_id: Option<String>,
        /// Emit the verdict as a JSON envelope on stdout (spec 037).
        #[arg(long)]
        json: bool,
    },
    /// Verify a corpus attestation by recompute and/or detached signature.
    VerifyAttestation {
        /// Verify the per-spec attestation for this id (spec 042). Accepts
        /// the short id (`042`), resolved against the attestation files.
        #[arg(long, value_name = "ID")]
        spec: Option<String>,
        /// Verify the authority snapshot (spec 087) instead of an attestation.
        #[arg(long)]
        snapshot: bool,
        /// Re-read the corpus and check it reproduces the attestation (no key).
        #[arg(long)]
        recompute: bool,
        /// Check the detached seal against a supplied public key.
        #[arg(long)]
        signature: bool,
        /// The attestation file (defaults to <derived>/attestation/attestation.json).
        #[arg(long, value_name = "PATH")]
        attestation: Option<PathBuf>,
        /// The ed25519 public key (32 bytes; raw or hex). Required with --signature.
        #[arg(long, value_name = "PATH")]
        public_key: Option<PathBuf>,
        /// The detached seal file (defaults to the attestation's sibling .sig).
        #[arg(long, value_name = "PATH")]
        seal: Option<PathBuf>,
        /// Emit the verdict as a JSON envelope on stdout (spec 037).
        #[arg(long)]
        json: bool,
    },
}

fn main() -> ExitCode {
    // Spec 063 §3.1: a command line clap cannot parse is a usage error, and a
    // usage error is exit 3. Clap's own default is 2, which this tool spends on
    // staleness, so an unknown flag was indistinguishable from a stale ledger
    // except by matching clap's English on stderr. After this, exit 2 from any
    // verb means staleness and nothing else.
    let cli = match Cli::try_parse() {
        Ok(cli) => cli,
        Err(e) => return exit_for_clap_error(e),
    };
    let repo = match cli.repo {
        Some(p) => p,
        None => std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
    };

    let json_verb = cli.command.json_verb();
    // Spec 062 §3.2: the version pin is checked before any work. A read from a
    // mismatched binary is the quiet failure this exists to prevent: `registry
    // plan` from an old binary answers a question about a corpus it may
    // misunderstand, and answers it confidently.
    if let Err(e) = check_version_pin(&repo, &cli.command) {
        eprintln!("spec-spine: {e}");
        return ExitCode::from(e.exit_code());
    }

    let result = match &cli.command {
        Command::Compile {
            check,
            json,
            spec,
            fail_on_warn,
        } => cmd_compile::run(&repo, *check, *json, spec.as_deref(), *fail_on_warn),
        Command::Check {
            fail_on_unresolved,
            fail_on_warn,
            json,
        } => cmd_check::run(&repo, *fail_on_unresolved, *fail_on_warn, *json),
        Command::Config { action } => cmd_config::run(&repo, action),
        Command::Registry { query } => cmd_registry::run(&repo, query),
        Command::Index { action } => cmd_index::run(&repo, action.as_ref()),
        Command::Lint {
            fail_on_warn,
            fail_on_info,
            json,
        } => cmd_lint::run(&repo, *fail_on_warn, *fail_on_info, *json),
        Command::Verify { id, plan, json } => cmd_verify::run(&repo, id, *json, *plan),
        Command::Couple {
            base,
            head,
            pr_body,
            paths_from,
            include_uncommitted,
            json,
        } => cmd_couple::run(
            &repo,
            &cmd_couple::CoupleArgs {
                base: base.clone(),
                head: head.clone(),
                pr_body: pr_body.clone(),
                paths_from: paths_from.clone(),
                include_uncommitted: *include_uncommitted,
                json: *json,
            },
        ),
        Command::Delta { base, head, json } => cmd_delta::run(
            &repo,
            &cmd_delta::DeltaArgs {
                base: base.clone(),
                head: head.clone(),
                json: *json,
            },
        ),
        Command::Init { force, with_kit } => cmd_init::run(&repo, *force, *with_kit),
        Command::Attest {
            spec,
            with_coupling,
            snapshot,
            sign,
            key,
            key_id,
            json,
        } => cmd_attest::run(
            &repo,
            &cmd_attest::AttestArgs {
                spec: spec.clone(),
                with_coupling: *with_coupling,
                snapshot: *snapshot,
                sign: *sign,
                key: key.clone(),
                key_id: key_id.clone(),
                json: *json,
            },
        ),
        Command::VerifyAttestation {
            spec,
            snapshot,
            recompute,
            signature,
            attestation,
            public_key,
            seal,
            json,
        } => verify_attestation::run(
            &repo,
            &verify_attestation::VerifyArgs {
                spec: spec.clone(),
                snapshot: *snapshot,
                recompute: *recompute,
                signature: *signature,
                attestation: attestation.clone(),
                public_key: public_key.clone(),
                seal: seal.clone(),
                json: *json,
            },
        ),
    };

    match result {
        Ok(code) => ExitCode::from(code),
        Err(e) => {
            // Spec 037 3.3: under `--json` a failure is an envelope on stdout,
            // not bare prose on stderr, so a consumer's happy path and error
            // path have the same shape. Handled once here rather than in six
            // commands: every `run` returning `Err` lands in this arm.
            let code = e.exit_code();
            match json_verb {
                Some(v) => emit_error_envelope(v, &e),
                None => eprintln!("spec-spine: {e}"),
            }
            ExitCode::from(code)
        }
    }
}

/// Render an error envelope, falling back to prose if the envelope itself
/// cannot be serialized.
///
/// The fallback is unreachable in practice (the envelope is two strings and two
/// scalars), but silently emitting nothing on stdout would leave a consumer
/// waiting on a document that never comes, which is worse than a prose line on
/// a stream it was not reading.
pub(crate) fn emit_error_envelope(verb: &str, error: &Error) {
    let verdict = Verdict::failure(verb, error);
    if let Err(e) = out::verdict(&verdict) {
        eprintln!("spec-spine: {error}");
        eprintln!("spec-spine: (could not render the JSON verdict: {e})");
    }
}

impl Command {
    /// The verdict envelope's `verb` when this invocation passed `--json`, and
    /// `None` otherwise. Drives only the failure path in `main`; the success
    /// path is each command's own, because only it holds the report.
    fn json_verb(&self) -> Option<&'static str> {
        match self {
            // `check: true` as well as `json: true`: `compile --json` without
            // `--check` is refused inside `cmd_compile`, which writes its own
            // envelope, so no envelope from this generic path ever carries a
            // verb the invocation did not qualify for.
            Command::Compile {
                json: true,
                check: true,
                ..
            } => Some(verb::COMPILE_CHECK),
            // Spec 056: `--spec` is its own verb. A consumer that branched on
            // `compile.check` must not silently receive a single-spec verdict.
            Command::Compile {
                json: true,
                spec: Some(_),
                ..
            } => Some(verb::COMPILE_SPEC),
            Command::Check { json: true, .. } => Some(verb::CHECK),
            Command::Lint { json: true, .. } => Some(verb::LINT),
            Command::Couple { json: true, .. } => Some(verb::COUPLE),
            Command::Delta { json: true, .. } => Some(verb::DELTA),
            Command::Verify { json: true, .. } => Some(verb::VERIFY),
            Command::Attest { json: true, .. } => Some(verb::ATTEST),
            Command::VerifyAttestation { json: true, .. } => Some(verb::VERIFY_ATTESTATION),
            Command::Index {
                action: Some(cmd_index::IndexAction::Check { json: true, .. }),
            } => Some(verb::INDEX_CHECK),
            _ => None,
        }
    }
}

/// Load `<repo>/spec-spine.toml`, or the working default if it is absent.
pub(crate) fn load_repo_config(repo: &Path) -> Result<Config, Error> {
    let path = repo.join("spec-spine.toml");
    match std::fs::read_to_string(&path) {
        Ok(src) => spec_spine_types::load_config(&src),
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Config::default()),
        Err(e) => Err(Error::Io(format!("read {}: {e}", path.display()))),
    }
}

/// Enforce `[meta] required_version` (spec 062 §3.2), except for the verbs that
/// must stay available when the pin is unsatisfiable.
///
/// `--version` and `--help` are clap's, and never reach here. `init` is exempt
/// because it scaffolds a repository that has no configuration yet, and in one
/// that does it is the verb an operator reaches for when things are wrong.
///
/// A configuration that cannot be read at all is left to the verb: this returns
/// `Ok` rather than pre-empting the real error with a worse one.
fn check_version_pin(repo: &Path, command: &Command) -> Result<(), Error> {
    if matches!(command, Command::Init { .. }) {
        return Ok(());
    }
    match load_repo_config(repo) {
        Ok(cfg) => cfg.check_required_version(env!("CARGO_PKG_VERSION")),
        Err(_) => Ok(()),
    }
}

/// Render a clap error and map it to this tool's exit-code contract
/// (spec 063 §3.1).
///
/// Help and version are successful requests for information: stdout, exit 0.
/// Everything else is the invocation failing to parse, which belongs in the
/// same cell as I/O, parse, schema and config failures. `3` rather than a new
/// code, because the contract is documented by four repositories and two
/// package shims, and a fourth code would extend it to distinguish a case none
/// of them needs distinguished.
fn exit_for_clap_error(e: clap::Error) -> ExitCode {
    use clap::error::ErrorKind;
    match e.kind() {
        ErrorKind::DisplayHelp | ErrorKind::DisplayVersion => {
            // Asked for explicitly, and clap writes them to stdout itself.
            let _ = e.print();
            ExitCode::from(0)
        }
        // `spec-spine` with no subcommand prints help, but nobody asked for
        // help: the invocation was incomplete. It exits 3 with its siblings
        // rather than 0, so a script that dropped the verb still fails. See
        // spec 063 §3.1's decision entry.
        _ => {
            // Clap's message, unchanged: it names the offending argument better
            // than a paraphrase would.
            let _ = e.print();
            ExitCode::from(3)
        }
    }
}

#[cfg(test)]
mod tests {
    use clap::CommandFactory;

    /// Spec 084 §3.5: the six arguments that take a spec id, and no seventh.
    ///
    /// 049 §3.2 and 056 §3.1 each asserted the cross-verb rule in prose and
    /// nothing held it, which is how `registry show`, `registry relationships`,
    /// `attest --spec` and `verify-attestation --spec` kept refusing the short
    /// form through two ratifications. This census is the structural half of
    /// the fix: a seventh verb that takes a spec id cannot land without the
    /// resolver by accident, because this test fails when the set moves.
    ///
    /// It keys on the argument ids `id` and `spec`, the two spellings all six
    /// use today. A future argument spelled differently (`--target <ID>`) would
    /// evade it; keying on `value_name = "ID"` instead would catch
    /// `attest --key-id`, which takes a key id, and renaming value names to
    /// tell the two apart was rejected as help-text churn to close a gap a
    /// reviewer can see (084 D-5).
    ///
    /// The census and the matrix in `tests/spec_id.rs` are two lists, kept
    /// equal by the failure message below rather than by linkage: an
    /// integration test cannot import from a binary crate.
    #[test]
    fn spec_id_census() {
        fn walk(cmd: &clap::Command, path: &str, found: &mut Vec<String>) {
            for arg in cmd.get_arguments() {
                let name = arg.get_id().as_str();
                if name == "id" || name == "spec" {
                    found.push(format!("{path} <{name}>"));
                }
            }
            for sub in cmd.get_subcommands() {
                let child = if path.is_empty() {
                    sub.get_name().to_string()
                } else {
                    format!("{path} {}", sub.get_name())
                };
                walk(sub, &child, found);
            }
        }

        let cmd = super::Cli::command();
        let mut found: Vec<String> = Vec::new();
        walk(&cmd, "", &mut found);
        found.sort();

        let mut expected: Vec<String> = vec![
            "attest <spec>".to_string(),
            "compile <spec>".to_string(),
            "registry relationships <id>".to_string(),
            "registry show <id>".to_string(),
            "verify <id>".to_string(),
            "verify-attestation <spec>".to_string(),
        ];
        expected.sort();

        assert_eq!(
            found, expected,
            "the set of spec-id arguments moved (spec 084 §3.5). Every one of \
             them must resolve through `spec_spine_core::spec_id` (084 §3.4), \
             and must be driven by the matrix in \
             `crates/spec-spine-cli/tests/spec_id.rs`. Add it to both lists, or \
             to neither."
        );
    }
}