spec-spine-cli 0.17.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
//! `spec-spine index`: write the per-spec/per-package index shards (spec 024)
//! under `<derived>/codebase-index/{by-spec,by-package}/`; `spec-spine index
//! check`: per-shard staleness; `spec-spine index render` / `index orphans`:
//! read-side projections of the committed shard set (spec 011; never recompute,
//! never check freshness); `spec-spine index coverage`: file-granular
//! ownership coverage of the tree against the committed shards (spec 032;
//! freshness-guarded like `couple`). The single monolithic `index.json` is no
//! longer emitted, so PRs touching different specs/packages write disjoint files.

use std::fs;
use std::path::Path;

use clap::Subcommand;
use spec_spine_core::shard::{self, BY_PACKAGE_DIR, BY_SPEC_DIR};
use spec_spine_core::{
    DiagnosticCounts, Freshness, IndexCheckReport, UnwitnessedCounts, check_index_freshness,
    check_slice_freshness, committed_counts, committed_diagnostics, coverage, empty_universe,
    index, index_dir, index_shard_files, load_committed_index, load_committed_registry,
    partition_orphans, render_markdown, slices_path,
};
use spec_spine_types::{Config, CoverageReport, Error, Verdict, verdict::verb};

use crate::load_repo_config;
use crate::out;

#[derive(Subcommand)]
pub enum IndexAction {
    /// Check the committed index against current inputs (the staleness gate).
    Check {
        /// Gate one named [index.slices] slice instead of the shard set.
        #[arg(long, value_name = "NAME")]
        slice: Option<String>,
        /// Fail (exit 1) if the committed index records any unresolved unit
        /// (`W-001` / `W-002`). Opt-in: specs 025 and 044 exist to let a corpus
        /// that ratifies before it builds carry these while work is under way.
        #[arg(long)]
        fail_on_unresolved: bool,
        /// Emit the verdict as a JSON envelope on stdout (spec 037).
        #[arg(long)]
        json: bool,
    },
    /// Render the committed index as markdown (a projection; never recomputes).
    Render,
    /// List orphaned specs from the committed index.
    Orphans {
        #[arg(long)]
        json: bool,
    },
    /// List the diagnostics the committed index records (spec 050).
    ///
    /// A read verb beside `orphans` and `coverage`: it recomputes nothing and
    /// never refuses, so a consumer reaches a structured fact without parsing
    /// `index render`'s markdown. The refusal lives on `check`.
    Diagnostics {
        #[arg(long)]
        json: bool,
    },
    /// Report which specs own one path, and how (spec 055).
    ///
    /// Calls the coupling gate's own owner derivation, so this answer and a
    /// `C-001` decision cannot disagree. The path need not exist on disk:
    /// asking who *would* own a file before creating it is a legitimate
    /// question, computed the same way.
    Owner {
        /// A repo-relative POSIX path.
        path: String,
        #[arg(long)]
        json: bool,
    },
    /// Report which source files no spec specifically claims (spec 032).
    Coverage {
        #[arg(long)]
        json: bool,
        /// Fail (exit 1) unless every source file has a specific owning spec.
        #[arg(long)]
        fail_on_untraced: bool,
    },
}

/// `index` (no action) writes the shard tree; `index check` verifies freshness.
pub fn run(repo: &Path, action: Option<&IndexAction>) -> Result<u8, Error> {
    let cfg = load_repo_config(repo)?;

    match action {
        Some(IndexAction::Render) => {
            let idx = load_committed_index(&cfg, repo)?;
            out!("{}", render_markdown(&cfg, &idx));
            Ok(0)
        }
        Some(IndexAction::Orphans { json }) => {
            let idx = load_committed_index(&cfg, repo)?;
            // Spec 059 3.1: the lifecycle half comes from the registry, since
            // the index shard records `spec_status` but not `implementation`
            // and adding it would restamp every shard for a read verb.
            //
            // An absent registry is an empty one, not an error. This verb
            // answered from the index alone before spec 059, and a read verb
            // that started failing because a *different* artifact is missing
            // would be a regression dressed as a feature. With no records
            // every orphan reads as in flight, which is the same "cannot say
            // otherwise" rule 3.1 applies per spec.
            let records = load_committed_registry(&cfg, repo)
                .map(|r| r.specs)
                .unwrap_or_default();
            let report = partition_orphans(&idx, &records);
            if *json {
                let s = serde_json::to_string_pretty(&report)
                    .map_err(|e| Error::Schema(e.to_string()))?;
                outln!("{s}");
            } else if !report.orphaned.is_empty() || !report.in_flight.is_empty() {
                // Both groups are printed whenever either has members, so a
                // reader always sees which side of the partition an id fell on.
                // A corpus with no orphans at all stays silent, as it did
                // before spec 059: two headers and two "(none)" lines would be
                // noise on the answer "nothing to report".
                outln!("orphaned (claims nothing that resolves, and is not in flight):");
                if report.orphaned.is_empty() {
                    outln!("  (none)");
                }
                for id in &report.orphaned {
                    outln!("  {id}");
                }
                outln!();
                outln!("in flight (claims nothing that resolves yet; draft or pending):");
                if report.in_flight.is_empty() {
                    outln!("  (none)");
                }
                for id in &report.in_flight {
                    outln!("  {id}");
                }
            }
            Ok(0)
        }
        Some(IndexAction::Diagnostics { json }) => {
            let diags = committed_diagnostics(&cfg, repo)?;
            if *json {
                let s = serde_json::to_string_pretty(&diags)
                    .map_err(|e| Error::Schema(e.to_string()))?;
                outln!("{s}");
            } else {
                for d in &diags {
                    let at = d.path.as_deref().unwrap_or("-");
                    outln!("  {} [{}] [{}] {}", d.code, d.spec_id, at, d.message);
                }
            }
            Ok(0)
        }
        Some(IndexAction::Owner { path, json }) => {
            // Freshness-guarded inside `owner`: an owner answer read off a
            // stale ledger is the one wrong answer this verb must never give,
            // because its caller is deciding what to edit.
            let report = spec_spine_core::owner(&cfg, repo, path)?;
            if *json {
                let s = serde_json::to_string_pretty(&report)
                    .map_err(|e| Error::Schema(e.to_string()))?;
                outln!("{s}");
            } else {
                outln!("{}", report.path);
                if report.owners.is_empty() {
                    // A true and common answer on a specify-first corpus, and
                    // not a `NotFound`: nothing was asked for by name.
                    outln!("  (no spec owns this path)");
                }
                let width = report
                    .owners
                    .iter()
                    .map(|o| o.spec_id.chars().count())
                    .max()
                    .unwrap_or(0);
                for o in &report.owners {
                    outln!(
                        "  {:<width$}  {:<9}  {}",
                        o.spec_id,
                        owner_kind_label(o.kind),
                        o.claim,
                        width = width
                    );
                }
            }
            Ok(0)
        }
        Some(IndexAction::Coverage {
            json,
            fail_on_untraced,
        }) => {
            // Freshness-guarded inside `coverage`: a stale index is `Error::Stale`
            // (exit 2), so the report never describes the wrong ledger.
            let report = coverage(&cfg, repo)?;
            if *json {
                let s = serde_json::to_string_pretty(&report)
                    .map_err(|e| Error::Schema(e.to_string()))?;
                outln!("{s}");
            } else {
                out!("{}", render_coverage(&report));
            }
            // Spec 059 3.2: an assertion over an empty set is vacuously true,
            // which is the wrong answer for a CI step whose whole purpose is to
            // assert. Reporting is unaffected: without the flag an empty
            // universe is still a true and useful thing to say.
            if let (true, Some(reason)) = (*fail_on_untraced, empty_universe(&report)) {
                eprintln!(
                    "coverage: {}.\n--fail-on-untraced asserts that every source file has a \
                     specific owning spec, and there are none to assert about. Nothing was \
                     verified.",
                    reason.explain()
                );
                return Ok(1);
            }
            Ok(if *fail_on_untraced && !report.is_fully_claimed() {
                1
            } else {
                0
            })
        }
        Some(IndexAction::Check {
            slice,
            fail_on_unresolved,
            json,
        }) => {
            let (freshness, subject) = match slice {
                Some(name) => (
                    check_slice_freshness(&cfg, repo, name)?,
                    format!("slice '{name}'"),
                ),
                None => (check_index_freshness(&cfg, repo)?, "index".to_string()),
            };
            let counts = committed_counts(&cfg, repo)?;
            // Spec 057 3.3: the count of claimed paths no content hash covers.
            // Reporting only, never an exit code: `index check` is where a
            // person reads the word "fresh", and "fresh" is the word this
            // qualifies. Computed by the same core function the JSON facade
            // uses, so the two payloads cannot diverge.
            let unwitnessed = spec_spine_core::unwitnessed_counts(&cfg, repo);

            // Spec 050 3.3: staleness outranks unresolution. A stale index's
            // diagnostics describe a tree that no longer exists, so refusing
            // for them would name the wrong problem. The counts are still
            // reported either way; suppressing them would hide the number the
            // operator ran the command for.
            let code = if matches!(freshness, Freshness::Fresh) {
                if *fail_on_unresolved && counts.has_unresolved() {
                    1
                } else {
                    0
                }
            } else {
                2
            };

            if *json {
                // One shape, built in core (`IndexCheckReport`), so the facade
                // and this arm cannot drift; spec 037 pins them against each
                // other. `compile --check` keeps the bare freshness object:
                // index diagnostics are meaningless for the registry (3.1).
                let report = serde_json::to_value(IndexCheckReport::with_unwitnessed(
                    &freshness,
                    counts.clone(),
                    unwitnessed,
                ))
                .map_err(|e| Error::Schema(e.to_string()))?;
                out::verdict(&Verdict::report(verb::INDEX_CHECK, code, report))?;
                return Ok(code);
            }

            match freshness {
                // Say the refusal on stdout too. `is fresh` is true on its own
                // axis even when the run refuses, and a reader seeing only that
                // line would take it for a pass. Prose is not the machine
                // surface (that is the exit code and `--json`, spec 037), but a
                // line that reads as a pass while the process exits 1 is worth
                // one clause to avoid.
                // One line for one fact. An earlier round printed this on
                // stdout *and* repeated it on stderr, which said the same thing
                // twice on two streams. `coverage --fail-on-untraced` reports
                // on stdout and lets the exit code carry the refusal; this
                // matches it.
                Freshness::Fresh if code == 1 => {
                    outln!(
                        "{subject} is fresh; --fail-on-unresolved refuses{}",
                        counts_suffix(&counts)
                    );
                    report_unwitnessed(&unwitnessed);
                }
                Freshness::Fresh => {
                    outln!("{subject} is fresh{}", counts_suffix(&counts));
                    report_unwitnessed(&unwitnessed);
                }
                Freshness::Stale { expected, actual } => {
                    eprintln!("{subject} is STALE (run `spec-spine index` to refresh)");
                    eprintln!("  expected: {expected}");
                    eprintln!("  actual:   {actual}");
                    if !counts.is_empty() {
                        eprintln!(
                            "  the stale ledger also records {}",
                            counts_summary(&counts)
                        );
                    }
                }
            }
            Ok(code)
        }
        None => {
            let outcome = index(&cfg, repo)?;
            let dir = index_dir(&cfg, repo);
            fs::create_dir_all(&dir)
                .map_err(|e| Error::Io(format!("create {}: {e}", dir.display())))?;

            // Per-spec + per-package shards; `sync_dir` prunes a removed unit's
            // shard so the shard set always equals the current corpus.
            let (by_spec, by_package) = index_shard_files(&outcome.shards)?;
            shard::sync_dir(&dir.join(BY_SPEC_DIR), &by_spec)?;
            shard::sync_dir(&dir.join(BY_PACKAGE_DIR), &by_package)?;
            write_slices(&cfg, repo, &outcome.index.build.slice_hashes)?;

            // Drop a pre-024 monolithic index.json on upgrade.
            let legacy = dir.join("index.json");
            if legacy.exists() {
                fs::remove_file(&legacy)
                    .map_err(|e| Error::Io(format!("remove {}: {e}", legacy.display())))?;
            }

            // Print both tiers. Spec 025 downgrades an unresolved unit on an
            // in-flight spec (or a `references` edge) to a counted `W-001` /
            // `W-002`; those land in the shard either way, but a warning the
            // operator never sees is a unit that quietly went unresolved.
            let idx = &outcome.index;
            for diag in idx
                .diagnostics
                .errors
                .iter()
                .chain(idx.diagnostics.warnings.iter())
            {
                let at = diag.path.as_deref().unwrap_or("-");
                eprintln!("  {} [{}] {}", diag.code, at, diag.message);
            }
            outln!(
                "indexed {} package(s), {} mapping(s) -> {} ({} error diagnostic(s), {} warning(s))",
                idx.packages.len(),
                idx.traceability.mappings.len(),
                dir.display(),
                idx.diagnostics.errors.len(),
                idx.diagnostics.warnings.len()
            );
            Ok(0)
        }
    }
}

/// The counts appended to `index check`'s verdict line, or `""` when the
/// committed index records nothing.
///
/// A clean corpus keeps printing the bare `index is fresh`, so a tree with no
/// diagnostics reads exactly as it did before spec 050 (3.1).
fn counts_suffix(counts: &DiagnosticCounts) -> String {
    if counts.is_empty() {
        return String::new();
    }
    format!(" ({})", counts_summary(counts))
}

/// `"1 warning(s), 0 error(s): 1 W-001"`. Bare, so a caller can place it in a
/// sentence as well as in parentheses.
fn counts_summary(counts: &DiagnosticCounts) -> String {
    let by_code = counts
        .by_code
        .iter()
        .map(|(code, n)| format!("{n} {code}"))
        .collect::<Vec<_>>()
        .join(", ");
    format!(
        "{} warning(s), {} error(s): {by_code}",
        counts.warnings, counts.errors
    )
}

/// The human form of the coverage report: one headline, one line per package,
/// then the two debt lists (omitted when empty).
fn render_coverage(report: &CoverageReport) -> String {
    use std::fmt::Write as _;
    let mut out = String::new();
    let total = report.source_files;
    if total == 0 {
        out.push_str("coverage: no source files under any discovered package\n");
        return out;
    }
    let pct = (report.claimed_files as f64) * 100.0 / (total as f64);
    let _ = writeln!(
        out,
        "coverage: {}/{total} source files specifically claimed ({pct:.1}%); {} floor-only, {} unclaimed",
        report.claimed_files,
        report.floor_only_files.len(),
        report.unclaimed_files.len()
    );
    for p in &report.packages {
        let path = if p.path.is_empty() {
            "."
        } else {
            p.path.as_str()
        };
        let floor = p
            .floor_spec
            .as_deref()
            .map(|s| format!("floor {s}"))
            .unwrap_or_else(|| "no floor".to_string());
        let _ = writeln!(
            out,
            "  {path} ({floor}): {}/{} claimed, {} floor-only, {} unclaimed",
            p.claimed_files, p.source_files, p.floor_only, p.unclaimed
        );
    }
    if !report.floor_only_files.is_empty() {
        out.push_str("\nfloor-only (owned only by a package floor; claim in a spec):\n");
        for f in &report.floor_only_files {
            let _ = writeln!(out, "  {f}");
        }
    }
    if !report.unclaimed_files.is_empty() {
        out.push_str("\nunclaimed (no owning spec):\n");
        for f in &report.unclaimed_files {
            let _ = writeln!(out, "  {f}");
        }
    }
    out
}

/// Write (or remove) the per-slice sidecar `slices.json` (spec 012/024). The
/// slices live in their own small file emitted only when `[index.slices]` is
/// configured, so a corpus with no slices commits no such file. Canonical
/// (`BTreeMap` ⇒ sorted keys, 2-space, trailing LF).
fn write_slices(
    cfg: &Config,
    repo: &Path,
    slice_hashes: &std::collections::BTreeMap<String, String>,
) -> Result<(), Error> {
    let path = slices_path(cfg, repo);
    if slice_hashes.is_empty() {
        if path.exists() {
            fs::remove_file(&path)
                .map_err(|e| Error::Io(format!("remove {}: {e}", path.display())))?;
        }
        return Ok(());
    }
    let json = serde_json::to_string_pretty(slice_hashes)
        .map_err(|e| Error::Schema(e.to_string()))?
        + "\n";
    fs::write(&path, json).map_err(|e| Error::Io(format!("write {}: {e}", path.display())))?;
    Ok(())
}

/// The token the prose form prints for a linkage kind (spec 055 §3.1).
fn owner_kind_label(kind: spec_spine_core::OwnerKind) -> &'static str {
    match kind {
        spec_spine_core::OwnerKind::Unit => "unit",
        spec_spine_core::OwnerKind::Floor => "floor",
        spec_spine_core::OwnerKind::Header => "header",
        spec_spine_core::OwnerKind::Inherited => "inherited",
    }
}

/// The spec 057 §3.3 line: how many claimed paths no content hash witnesses,
/// and how many of those this corpus has declared deliberate.
///
/// Silent at zero. A corpus with no gap does not need to be told it has none,
/// and the line exists to qualify the word "fresh" only where the
/// qualification bites.
fn report_unwitnessed(u: &UnwitnessedCounts) {
    if u.total == 0 {
        return;
    }
    if u.allowed == 0 {
        outln!("  unwitnessed claims: {}", u.total);
    } else {
        outln!(
            "  unwitnessed claims: {} ({} allowed by [lint] unwitnessed_allowed)",
            u.total,
            u.allowed
        );
    }
}