spec-spine-cli 0.25.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
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
//! `spec-spine index`: write the per-spec/per-package index shards (spec 022)
//! 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 010; never recompute,
//! never check freshness); `spec-spine index coverage`: file-granular
//! ownership coverage of the tree against the committed shards (spec 029;
//! 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, Versioning,
    annotate_unreadable, check_slice_freshness, committed_diagnostics, coverage_with_inventory,
    empty_universe, index, index_dir, index_freshness_report, index_shard_files,
    load_committed_index, load_committed_registry, partition_orphans, read_document,
    render_markdown, slices_path, verdict_tally,
};
use spec_spine_types::{
    Config, CoverageReport, Enumeration, Error, Inventory, InventoryProvenance, 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 023 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 034).
        #[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 044).
    ///
    /// 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 048).
    ///
    /// 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 029).
    Coverage {
        #[arg(long)]
        json: bool,
        /// Fail (exit 1) unless every source file has a specific owning spec.
        #[arg(long)]
        fail_on_untraced: bool,
        /// The files `[coverage] governed_scope` may match, one repo-relative
        /// path per line, instead of asking git (spec 078). The git-free route.
        #[arg(long, value_name = "FILE")]
        paths_from: Option<std::path::PathBuf>,
    },
}

/// `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 052 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 052, 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 {
                out!("{}", read_document(&report, Versioning::Stamp)?);
            } 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 052: 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 {
                // Spec 074 §3.6: the listing sits under `items` in a versioned
                // object; the emitter wraps the array.
                out!("{}", read_document(&diags, Versioning::Stamp)?);
            } 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 {
                out!("{}", read_document(&report, Versioning::Stamp)?);
            } 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,
            paths_from,
        }) => {
            // Spec 078 §3.6: a declared scope is matched against an inventory
            // the CLI supplies, because the core has no git. Nothing is
            // enumerated while the scope is empty, so a corpus that never sets
            // the key never needs git here.
            let inventory = if cfg.coverage.governed_scope.is_empty() {
                None
            } else {
                Some(match paths_from {
                    Some(file) => supplied_inventory(file)?,
                    None => tracked_inventory(repo)?,
                })
            };
            // Freshness-guarded inside `coverage`: a stale index is `Error::Stale`
            // (exit 2), so the report never describes the wrong ledger.
            let report = coverage_with_inventory(&cfg, repo, inventory.as_ref())?;
            if *json {
                out!("{}", read_document(&report, Versioning::Stamp)?);
            } else {
                out!("{}", render_coverage(&report));
            }
            // Spec 052 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,
        }) => {
            // Spec 079 §3.2: for the index subject the two refusals arrive
            // apart, from one read. A `--slice` check has only one (the sidecar
            // hashes it compares carry no diagnostics), so it carries `None` and
            // keeps every word it printed before.
            let (freshness, partition, subject) = match slice {
                Some(name) => (
                    check_slice_freshness(&cfg, repo, name)?,
                    None,
                    format!("slice '{name}'"),
                ),
                None => {
                    let report = index_freshness_report(&cfg, repo)?;
                    (report.freshness(), Some(report), "index".to_string())
                }
            };
            // Spec 076 §3.1: the verdict above is already decided, and the tally
            // only adorns it. The same function the facades call (§3.4).
            let counts = verdict_tally(&cfg, repo);
            // Spec 050 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 044 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.
            // `partition` is `None` only on the `--slice` arm bound above, which
            // compares sidecar hashes and carries no diagnostics at all, so
            // there is no blocking set for `is_some_and` to skip: the `false`
            // it yields there is the right answer and not a fall-through.
            let code = if partition.as_ref().is_some_and(|p| !p.blocking.is_empty()) {
                // Spec 080 §3.2, amending spec 069 §3.1: an unresolved claim is
                // a validation failure, not staleness. `check` composes this
                // verb, so the two must spend the same code on the same fact;
                // a caller must not have to know which one it invoked to know
                // what a code means. Checked first, so a tree holding a
                // blocking claim AND drift exits 1 (spec 062 §3.3's order).
                1
            } else 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 034 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 034), 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);
                }
                // Spec 079 §3.3: an unresolved claim is reported as itself. The
                // remedy `STALE` carries is regeneration, and regeneration
                // provably does not clear a claim on a unit that does not
                // exist: `index` exits 0, writes the same bytes, and the next
                // read refuses identically.
                Freshness::Stale { .. }
                    if partition.as_ref().is_some_and(|p| !p.blocking.is_empty()) =>
                {
                    let p = partition.as_ref().expect("guarded above");
                    if !p.stale.is_empty() {
                        eprintln!("{subject} is STALE (run `spec-spine index` to refresh)");
                        if let Freshness::Stale { actual, .. } = p.stale_verdict() {
                            eprintln!("{}", annotate_unreadable(&actual, &counts.unreadable));
                        }
                    }
                    eprintln!(
                        "{subject}: UNRESOLVED CLAIM: {}",
                        p.unresolved_claim_summary()
                    );
                    for line in p.unresolved_claim_lines() {
                        eprintln!("{line}");
                    }
                }
                Freshness::Stale { expected, actual } => {
                    eprintln!("{subject} is STALE (run `spec-spine index` to refresh)");
                    // Spec 069 3.2: for the index, `actual` is already the count
                    // line plus one line per drifted shard with its class, so it
                    // is printed as it stands, the way `compile --check` prints
                    // the registry's. The paired `expected` stays on the typed
                    // verdict for JSON consumers; an operator's next action does
                    // not depend on it.
                    //
                    // A `--slice` check is the other shape on this arm: its two
                    // values are the sidecar hashes (spec 011 3.3), where the
                    // expected/actual pair is the whole report and dropping half
                    // of it would say nothing.
                    if slice.is_some() {
                        eprintln!("  expected: {expected}");
                        eprintln!("  actual:   {actual}");
                    } else {
                        eprintln!("{}", annotate_unreadable(&actual, &counts.unreadable));
                    }
                    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);

            // Every output of the run is checked before any is written (spec
            // 127 3.3): the per-spec and per-package shards, whose syncs prune
            // a removed unit's shard so the shard set always equals the
            // current corpus; `slices.json`, written or removed; and the
            // pre-024 monolithic index.json dropped on upgrade. Both batches'
            // names (spec 126 3.1) and every path's links are checked before
            // either batch is written, so a refusal leaves no artifact root
            // and no half-written tree (spec 126 D-5).
            let (by_spec, by_package) = index_shard_files(&outcome.shards)?;
            let run = shard::DerivedWrites::new(repo)
                .sync_dir(&dir.join(BY_SPEC_DIR), by_spec)
                .sync_dir(&dir.join(BY_PACKAGE_DIR), by_package);
            slices_output(run, &cfg, repo, &outcome.index.build.slice_hashes)?
                .remove(&dir, "index.json")
                .apply()?;

            // Print both tiers. Spec 023 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 044 (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 enumeration_label(e: Enumeration) -> &'static str {
    match e {
        Enumeration::Tracked => "tracked",
        Enumeration::Supplied => "supplied",
        Enumeration::Walk => "walk",
    }
}

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
    )
}

/// An explicit path list for `index coverage --paths-from` (spec 078 §3.6):
/// one path per line, blank lines ignored. Supplied as given, so a list that
/// is empty is an answer ("nothing to govern"), not a request to enumerate.
fn supplied_inventory(file: &Path) -> Result<Inventory, Error> {
    let text = fs::read_to_string(file)
        .map_err(|e| Error::Io(format!("read --paths-from {}: {e}", file.display())))?;
    Ok(Inventory {
        provenance: InventoryProvenance::Supplied,
        paths: text
            .lines()
            .map(str::trim)
            .filter(|l| !l.is_empty())
            .map(str::to_string)
            .collect(),
    })
}

/// The tracked-file inventory (spec 078 §3.6): `git ls-files -z --cached
/// --others --exclude-standard`, minus paths the working tree no longer holds.
///
/// `--cached` lists every tracked file, whether or not an ignore rule matches
/// it, and `--exclude-standard` filters only `--others`, so an untracked
/// ignored file is left out while a new unstaged file is in. A git failure is
/// an error naming the remedy, never a silent fall back to a walk: that would
/// change the denominator without changing any message.
fn tracked_inventory(repo: &Path) -> Result<Inventory, Error> {
    let out = std::process::Command::new("git")
        .arg("-C")
        .arg(repo)
        .args([
            "ls-files",
            "-z",
            "--cached",
            "--others",
            "--exclude-standard",
        ])
        .output()
        .map_err(|e| {
            Error::Io(format!(
                "[coverage] governed_scope needs the tracked-file list, and git could not be \
                 run ({e}); run inside a git repository, or pass `--paths-from FILE`"
            ))
        })?;
    if !out.status.success() {
        return Err(Error::Io(format!(
            "[coverage] governed_scope needs the tracked-file list, and `git ls-files` exited \
             {:?}: {}; run inside a git repository, or pass `--paths-from FILE`",
            out.status.code(),
            String::from_utf8_lossy(&out.stderr).trim()
        )));
    }
    let mut paths: Vec<String> = out
        .stdout
        .split(|b| *b == 0)
        .filter(|p| !p.is_empty())
        .map(|p| String::from_utf8_lossy(p).into_owned())
        .filter(|p| fs::symlink_metadata(repo.join(p)).is_ok())
        .collect();
    paths.sort();
    paths.dedup();
    Ok(Inventory {
        provenance: InventoryProvenance::Tracked,
        paths,
    })
}

/// 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()
    );
    // Spec 063 §3.6: a file nothing claims and a file something has planned are
    // different states, and the report could not tell them apart before. Listed
    // beside the counts rather than inside them: these paths are not on disk,
    // so counting a declared intention as coverage would let a spec satisfy
    // `--fail-on-untraced` by promising.
    // Spec 078 §3.5: the files only the declared scope brought in, on their own
    // line, because their denominator is not a package and a reader comparing
    // two runs must be able to see where the new files came from.
    if let (Some(declared), Some(enumeration)) = (&report.declared_scope_files, report.enumeration)
    {
        let unclaimed = declared
            .iter()
            .filter(|f| report.unclaimed_files.contains(f) || report.floor_only_files.contains(f))
            .count();
        let _ = writeln!(
            out,
            "  declared scope ({}): {} file(s) outside the package totals, {} claimed, {} unclaimed",
            enumeration_label(enumeration),
            declared.len(),
            declared.len() - unclaimed,
            unclaimed
        );
    }
    if !report.planned_territory.is_empty() {
        let _ = writeln!(
            out,
            "  planned (declared, not yet written): {}",
            report.planned_territory.len()
        );
        for entry in &report.planned_territory {
            let _ = writeln!(out, "    {entry}");
        }
    }
    // Spec 075 §3.4: a file that tried to claim itself and failed, told apart
    // from one that never tried. Explanation only; the counts above stand.
    if !report.near_miss_headers.is_empty() {
        let _ = writeln!(
            out,
            "  near-miss comment headers (claimed nothing): {}",
            report.near_miss_headers.len()
        );
        for m in &report.near_miss_headers {
            let spec = m
                .spec_id
                .as_deref()
                .map(|s| format!(" (names {s})"))
                .unwrap_or_default();
            let _ = writeln!(out, "    {}:{} {}{spec}", m.path, m.line, m.reason.as_str());
        }
    }
    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
}

/// Add the per-slice sidecar `slices.json` (spec 011/024) to `run`: written
/// when `[index.slices]` is configured, removed when it is not, so a corpus
/// with no slices commits no such file. Canonical (`BTreeMap` ⇒ sorted keys,
/// 2-space, trailing LF).
fn slices_output(
    run: shard::DerivedWrites,
    cfg: &Config,
    repo: &Path,
    slice_hashes: &std::collections::BTreeMap<String, String>,
) -> Result<shard::DerivedWrites, Error> {
    let path = slices_path(cfg, repo);
    let dir = path.parent().unwrap_or(repo);
    let name = "slices.json";
    if slice_hashes.is_empty() {
        return Ok(run.remove(dir, name));
    }
    let json = serde_json::to_string_pretty(slice_hashes)
        .map_err(|e| Error::Schema(e.to_string()))?
        + "\n";
    Ok(run.write(dir, name, json))
}

/// The token the prose form prints for a linkage kind (spec 048 §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 050 §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
        );
    }
}