mind-cli 0.48.0

A manager for agent tooling (skills, agents, rules, tools) that melds arbitrary git repos and links items into your agent directories.
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
//! Spec-coverage gate.
//!
//! Every normative spec ID defined in `spec/*.md` (as a `- `ID` ...` list item)
//! must either be cited by a test (`// spec: ID` comments in `src/` or `tests/`)
//! or appear in the ALLOWLIST below. This fails the build when a new spec
//! requirement is added without a coverage decision, so coverage cannot silently
//! regress. See spec/README.md.

use std::collections::BTreeSet;
use std::path::{Path, PathBuf};

/// Spec IDs intentionally not cited by a dedicated test: structural invariants
/// and schema facts exercised indirectly by many tests, or secondary behaviors
/// not yet given their own test. To add a NEW spec ID, either cite it from a
/// test or add it here with a reason.
const ALLOWLIST: &[&str] = &[
    // Storage layout and JSON schema invariants, exercised by every test that
    // reads/writes the registry or manifest, or installs an item.
    "STO-1", "STO-3", "STO-10", "STO-12", "STO-20", "STO-21", "STO-22", "STO-23", "STO-30",
    // An accepted risk, not a behavior: it states that `mind` does NOT cap the
    // size of the ITEM CONTENT it reads from a source tree ({{ns:}} expansion,
    // the reference scan, `review`, the TUI preview, hashing). Metadata reads
    // are no longer in scope here: they are capped by DSC-91, which has real
    // tests. Asserting the absence of a cap would mean allocating a file large
    // enough to matter, which is exactly the cost the decision declines to pay.
    // Carries an ID so the decision is findable and so a future cap is
    // recognizable as a behavior change.
    "DSC-90",
    // Lifecycle invariants covered indirectly: swap mechanics, idempotent
    // reinstall, removing an absent path. (LIFE-15, the source-content hash
    // basis, is now cited by example_drift_upgrade.)
    "LIFE-3", "LIFE-6", "LIFE-21",
    // Namespacing: install-time application is definitional, exercised by the
    // expansion tests. (NS-10, the token's written form, is now cited directly
    // by the `sibling_reference_tokens` tests in namespace.rs, which assert the
    // scanner recognizes exactly the literal `{{ns:` spelling `expand` does.)
    "NS-3",
    // A curated super-source adopting an un-onboarded nested source (DSC-59/60/61)
    // is implemented and cited from tests/cli.rs (the apply, gate-with-warning,
    // and consumer-pin-override tests); no longer allowlisted.
    // Retired (never implemented): INIT-8 proposed an `init-source` scaffold of a
    // `[source].install` stub; dropped when that field was deprecated (HOOK-90).
    // The statement is kept (marked removed) so the number is not reused; it has
    // no behavior and so no citing test.
    "INIT-8",
    // Tombstone: the `private = true` flag was dropped before implementation in
    // favor of the DSC-68 `on-auth-failure` inline-table form. The statement is
    // kept (marked removed) so the number is not reused; it has no behavior and
    // so no citing test.
    "DSC-67",
    // Planned features (see spec/README.md feature status = planned): documented
    // with stable IDs ahead of implementation. Each must move to a citing test
    // when built, at which point it is removed from this allowlist.
    //   items hooks (spec/install-hooks.md, HOOK-190..198) are now implemented
    //   and cited from tests/cli_items_hooks.rs and unit tests; no longer
    //   allowlisted.
    //   ignored files (spec/ignore.md, IGN-1..21) are now implemented and cited
    //   from src/ignore.rs and tests/cli_ignore.rs.
    //   unmanaged lobe items (see spec/unmanaged.md): the scan + recall + probe
    //   listing + forget (UNM-1..5) are implemented and cited from src/unmanaged.rs
    //   and tests/cli.rs; the interactive TUI group node (UNM-6) is implemented
    //   and cited from src/tui/tree.rs and src/tui/app.rs. Bulk-forget of unmanaged
    //   items via `forget --unmanaged [glob]` (UNM-7/UNM-8) is now implemented and
    //   cited from src/unmanaged.rs and tests/cli.rs; removed from the allowlist.
    //   absorb (see spec/absorb.md): claim an unmanaged lobe item into a
    //   version-controlled source and install it managed (ABS-1..10) is now
    //   implemented and cited from src/commands.rs, src/git.rs, and
    //   tests/cli_absorb.rs; no longer allowlisted. `dump` (spec/dump.md,
    //   DUMP-1..8), the nested-source `install_items` subset directive
    //   (DSC-62/63/64, discovery.md) that dump emits and the install flow honors,
    //   and the authoritative nested-entry pin (DSC-65) dump relies on are now
    //   implemented and cited from src/dump.rs, src/mindfile.rs, src/commands.rs,
    //   tests/cli_install_items.rs, tests/cli_dump.rs, and tests/cli.rs; no longer
    //   allowlisted.
    //   explicit item dependencies (see spec/dependencies.md): an optional
    //   `requires:` frontmatter key unioned with the `{{ns:}}`-derived edges
    //   (DEP-4/5/6) are now implemented and cited from src/catalog.rs, src/deps.rs,
    //   src/install.rs, src/review.rs, and tests/cli.rs. Dependency-graph operations
    //   across the verbs (DEP-60/61/62) are now implemented and cited from
    //   tests/cli.rs; no longer allowlisted. TUI dependency navigation (TUI-50/51)
    //   is now implemented and cited from src/tui/tree.rs and src/tui/app.rs;
    //   no longer allowlisted.
    //   super-source install gating + discovery (DSC-54..57, see spec/discovery.md)
    //   is implemented and cited from tests/cli.rs: the default gating (DSC-54),
    //   `meld --install-super-sources` (DSC-55), the post-meld probe hint (DSC-56),
    //   and the `sync` re-walk of the discover chain (DSC-57).
    //   version pinning: now implemented; IDs removed from allowlist and cited in tests.
    //   review verb: now implemented; IDs removed from allowlist and cited in tests.
    //   meld no-arg defaults to `.` (CLI-25, cited in tests/cli.rs) and the
    //   maintainer `init-source` scaffolder (INIT-1..6; src/namespace.rs +
    //   tests/cli.rs) are now implemented and cited; no IDs remain allowlisted.
    //   Claude plugin marketplaces (spec/marketplace.md, MKT-1..16): now
    //   implemented and cited from tests/cli.rs and unit tests in src/catalog.rs
    //   and src/mindfile.rs; no MKT IDs remain allowlisted. This includes the
    //   marketplace + curator compose (MKT-15/16): a co-present mind.toml's
    //   own-item directives (roots/flat-skills/[[items]]/[discover] globs) suppress
    //   the manifest's own-item layer, while [discover].sources composes a curator
    //   on top.
    //   self-update `evolve` verb: in-place upgrade of the mind binary using the
    //   same native curl/wget downloader as resources/install.sh (no external
    //   crate). The pure logic (platform triple, version compare/decision, the
    //   --check report) is cited from src/selfupdate.rs and tests/cli.rs
    //   (CLI-140, CLI-141). CLI-142 is artifact *selection*, which is pure and
    //   is now cited from the asset-name and unsupported-platform tests. The
    //   binary swap (CLI-143) needs a real release and a writable install path,
    //   so it cannot run headlessly and stays allowlisted.
    "CLI-143",
    // The `workflow` item kind (spec/workflows.md, WF-1..60) is cited by tests
    // throughout: its discovery, storage, linking, namespacing, token expansion,
    // the plugin mapping (WF-40..42), and the reporting it does instead of
    // enforcing (WF-24/WF-29/WF-30..32).
    //
    // Two stay allowlisted permanently: WF-11 (the harness loader admits a
    // symlinked workflow file) and WF-20 (the harness keys a workflow by
    // `meta.name`, not its file name) are observations about Claude Code, not
    // mind behavior. Nothing in mind can assert either; what mind does about
    // WF-20 is WF-21 and WF-24, which are cited.
    "WF-11", "WF-20",
    // `evolve`'s download, verification, extraction, and in-place swap are the
    // `self_update` crate's, so the statements describing them have no mind-side
    // code path a test here could drive without a real release and a real
    // network. Each is tested in that crate instead.
    //   STO-45: the atomic same-filesystem swap and the pre-download
    //   writability probe.
    //   STO-57: reading GH_TOKEN/GITHUB_TOKEN and gating the header to the API
    //   host.
    "STO-45", "STO-57",
    // Tombstones: STO-61 (the curl `--config` file that kept the bearer token
    // off argv) and STO-62 (its fail-closed/fail-open token handling) described
    // a downloader `evolve` no longer has. The statements are kept, marked
    // removed and superseded by STO-57, so the numbers are not reused; they have
    // no behavior and so no citing test.
    "STO-61", "STO-62",
    //   evolve locking (STO-46): the serialization behavior needs two concurrent
    //   processes and a real binary swap, so it has no headless test. The lock
    //   acquisition site in src/selfupdate.rs carries a `// spec:` comment for
    //   the reader, but a production-code comment is not coverage, so the
    //   decision is recorded here.
    "STO-46",
    //   install hooks (source-declared or user-supplied build command gated by a
    //   safety prompt; see spec/install-hooks.md) is fully cited: the core
    //   (parse/resolve/disclosure/run) from src/hook.rs, the data/error/parse
    //   pieces from src/source.rs, src/error.rs, src/mindfile.rs, the `review`
    //   advisory from src/review.rs, the meld/upgrade wiring (run/skip/abort,
    //   re-run gating, recording) from tests/cli.rs and src/commands.rs. No HOOK
    //   IDs remain allowlisted.
    //   enterprise managed policy (see spec/policy.md) is fully cited: the core
    //   (parse/locate/allow_matches/validate) from src/policy.rs, the enforcement
    //   (lock/pinned refusal, learn/sync/evolve gating, auto-meld provisioning,
    //   lobe lock) from tests/cli.rs and src/paths.rs, and `mind review --policy`
    //   from src/review.rs. No POL IDs remain allowlisted.
    //   within-source dependency resolution (a partial `learn` pulls in the
    //   siblings its items reference; see spec/dependencies.md) is fully cited:
    //   the resolution core (DEP-1..23, DEP-31 interaction) from tests in
    //   src/deps.rs and src/namespace.rs, the `learn` wiring (DEP-30/31/32) and
    //   the explicit non-goal (DEP-50) from tests/cli.rs, and the interactive TUI
    //   confirm-and-install of the closure (DEP-40/41) from tests in src/tui/*.rs.
    //   No DEP IDs remain allowlisted.
    //   interactive TUI: IDs with automatable logic are now cited from tests
    //   in src/tui/*.rs. Only the following remains allowlisted because it
    //   requires a real TTY to observe and cannot be verified in a headless CI:
    //     TUI-1:  interactive launch requires a physical TTY - untestable headlessly.
    //   TUI-40 (terminal restore on panic) is now cited: the poison-recovery path
    //   is exercised by a unit test in src/tui/term.rs.
    "TUI-1",
    // TUI-3: tombstone - the `-n` short form of `--no-tui` was removed (TUI-54,
    // CLI-163). The spec entry is kept so the ID is not reused; it has no
    // current behavior and so no citing test.
    "TUI-3",
    // Resource and helper tooling (spec/tooling.md) is cited: the `tool` kind and
    // discovery (TOOL-1/2/5/7) from src/catalog.rs, the path-token expander
    // (TOOL-10/11/12/14) from src/namespace.rs, and the end-to-end install
    // behavior (TOOL-3/4/6/13/15) from tests/cli.rs. Item build hooks: the
    // declaration (HOOK-70), the non-TTY skip (HOOK-72), the run-in-staging +
    // rollback (HOOK-71), the re-run on reinstall/upgrade (HOOK-73), and the
    // headless flag (HOOK-74) are all cited from src/install.rs and
    // tests/cli_build_hooks.rs.
    //   cross-harness lobes (spec/harness-lobes.md) are implemented and cited:
    //   HARN-1 (kinds filter) from src/config.rs + src/paths.rs + tests/cli_lobes.rs,
    //   HARN-2/HARN-3 (link filtering / rules Claude-only) from tests/cli_lobes.rs,
    //   HARN-4 (presets) from src/paths.rs + tests/cli_lobes.rs, HARN-5
    //   (auto-detect-and-prompt) from src/paths.rs + tests/cli_lobes.rs, and
    //   HARN-6 (verbatim link, no frontmatter rewrite) from tests/cli_lobes.rs.
    //   HARN-7 (backfill on lobe-add), HARN-8 (introspect --fix missing-lobe
    //   coverage), and HARN-9 are cited from tests/cli_lobes.rs. HARN-15 (create
    //   the resolved lobe path immediately), HARN-16 (one-time unreachable-lobe
    //   note), HARN-17 (unconditional fan-out backfill, with the foreign-target
    //   guard), and HARN-18 (a repaired vanished-lobe finding is dropped from
    //   introspect's exit summary) are also cited from tests/cli_lobes.rs.
    //   HARN-19: umbrella statement for the selective/local lobe mode; its
    //   sub-behaviors HARN-20 (`--local` scoping) and HARN-21 (project-lobe
    //   detection) carry the citations (src/paths.rs, src/main.rs,
    //   tests/cli_lobes.rs). HARN-19 itself names no independently testable
    //   behavior beyond what HARN-20/HARN-21 already assert, so it stays
    //   allowlisted rather than duplicating a citation.
    "HARN-19",
    //   cross-source skill/rule/tool collision detection: NS-43 (detection) and
    //   NS-45 (non-interactive error) are now implemented and cited from
    //   src/error.rs and src/commands.rs. NS-44 (interactive TTY prompt, prompt
    //   parsing, and abort sentinel) is now cited from unit tests in
    //   src/commands.rs.
    //   rename mindfile `as` key to `namespace` in [discover].sources entries;
    //   `as` stays as a backwards-compatible alias (DSC-78): implemented and cited.
    // Namespace ergonomics: CLI-159 (--namespace flag), NS-30, and CLI-161
    // (namespace mutability lock) are now implemented and cited from tests/cli.rs.
    // TUI-53 (TUI namespace edit) is now implemented and cited from src/tui/app.rs.
    // Polished output: CLI-150 (global flags) is cited from unit tests in
    // src/main.rs; the capability gate (CLI-151), glyph/color semantics and the
    // ASCII fallback (CLI-152), the structured JSON result for mutating verbs
    // (CLI-153), and the NO_COLOR/non-UTF-8/--ascii gate-off conditions (CLI-154)
    // are now cited from integration tests in tests/cli.rs. The rich (TTY) branch
    // of the gate is unit-tested in src/render.rs (it needs a real PTY headlessly).
    // CLI-162 (--verbose global flag) is cited from unit tests in src/main.rs
    // and integration tests in tests/cli.rs.
    //   `mind hooks run` / `hooks list` (HOOK-100..104, CLI-194..196) are now
    //   implemented and cited from tests/cli_hooks.rs and unit tests in
    //   src/main.rs, src/resolve.rs, and src/hooks_cmd.rs.
    // Release versions (spec/versions.md): VER-45 states a limitation (a moved
    // non-resolved tag is not detected), which no test can observe.
    "VER-45",
];

#[test]
fn every_spec_id_is_cited_or_allowlisted() {
    let defined = defined_ids();
    assert!(
        defined.len() > 50,
        "found only {} spec IDs; the parser or spec layout likely changed",
        defined.len()
    );
    let cited = cited_ids();
    let allow: BTreeSet<String> = ALLOWLIST.iter().map(|s| s.to_string()).collect();

    // Every ID a test cites must be defined in the spec (catches typos and
    // behavior added without a spec entry).
    let undefined: Vec<_> = cited.difference(&defined).cloned().collect();
    assert!(
        undefined.is_empty(),
        "tests cite spec IDs not defined in spec/ (document them): {undefined:?}"
    );

    // The allowlist must not rot: every entry must be a real defined ID.
    let stale: Vec<_> = allow.difference(&defined).cloned().collect();
    assert!(
        stale.is_empty(),
        "ALLOWLIST references unknown spec IDs: {stale:?}"
    );

    // Keep the allowlist tight: a now-cited ID should be removed from it.
    let redundant: Vec<_> = allow.intersection(&cited).cloned().collect();
    assert!(
        redundant.is_empty(),
        "these IDs are now cited by tests; remove them from ALLOWLIST: {redundant:?}"
    );

    let uncovered: Vec<_> = defined
        .iter()
        .filter(|id| !cited.contains(*id) && !allow.contains(*id))
        .cloned()
        .collect();
    assert!(
        uncovered.is_empty(),
        "spec IDs with no test citation (add a test that cites them, or ALLOWLIST them): {uncovered:?}"
    );
}

fn root() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
}

/// True for tokens shaped like a spec ID: 2-4 uppercase letters, `-`, digits.
fn is_id(tok: &str) -> bool {
    match tok.split_once('-') {
        Some((alpha, num)) => {
            (2..=4).contains(&alpha.len())
                && alpha.bytes().all(|b| b.is_ascii_uppercase())
                && !num.is_empty()
                && num.bytes().all(|b| b.is_ascii_digit())
        }
        None => false,
    }
}

/// IDs defined in the spec: the backticked token leading a `- ` list item.
fn defined_ids() -> BTreeSet<String> {
    let mut out = BTreeSet::new();
    for md in files_with_ext(&root().join("spec"), "md") {
        let text = std::fs::read_to_string(&md).unwrap();
        for line in text.lines() {
            if let Some(rest) = line.trim_start().strip_prefix("- `")
                && let Some(end) = rest.find('`')
            {
                let tok = &rest[..end];
                if is_id(tok) {
                    out.insert(tok.to_string());
                }
            }
        }
    }
    out
}

/// IDs cited from TEST code via `// spec:` comments. Only text after a
/// `// spec:` marker is scanned, so incidental tokens like "UTF-8" in prose are
/// not mistaken for IDs.
///
/// A citation only counts when it sits in test code: everything under `tests/`,
/// and the `#[cfg(test)]` regions of `src/`. A `// spec:` comment on production
/// code documents the implementation site, which is useful, but it asserts
/// nothing, so counting it would let an ID satisfy the gate with no test behind
/// it and without the ALLOWLIST decision this gate exists to force.
fn cited_ids() -> BTreeSet<String> {
    const MARKER: &str = "// spec:";
    let mut out = BTreeSet::new();
    let mut sources: Vec<(std::path::PathBuf, bool)> = files_with_ext(&root().join("src"), "rs")
        .into_iter()
        .map(|f| (f, true))
        .collect();
    sources.extend(
        files_with_ext(&root().join("tests"), "rs")
            .into_iter()
            .map(|f| (f, false)),
    );
    for (f, tests_only) in sources {
        if f.file_name().is_some_and(|n| n == "spec_coverage.rs") {
            continue; // don't count the ALLOWLIST literals as citations
        }
        let text = std::fs::read_to_string(&f).unwrap();
        // In `src/`, a citation counts only from the first `#[cfg(test)]` that
        // gates a test MODULE. A `#[cfg(test)]` on a non-module item (e.g. a
        // test-only accessor method mid-file) must NOT start the region, or the
        // production `// spec:` comments after it would be miscounted as
        // citations. A file with no test module contributes nothing -- and that
        // is often a SILENT loss, not a loud one: most `src/` modules have the
        // same IDs also cited from a `tests/*.rs` integration test (e.g.
        // `plugin_manifest.rs`'s DSC-91/MKT-9 also live in `tests/cli.rs`), so
        // missing the module here does not flip the gate to failing. Only an ID
        // cited *exclusively* from an undetected module, with no allowlist entry,
        // fails loudly. Do not rely on "the gate will tell me" -- verify
        // `find_test_module` itself against every mod-declaration form the repo
        // uses (see `find_test_module_recognizes_every_mod_form` below).
        let scanned = if tests_only {
            match find_test_module(&text) {
                Some(i) => &text[i..],
                None => continue,
            }
        } else {
            text.as_str()
        };
        for line in scanned.lines() {
            if let Some(idx) = line.find(MARKER) {
                for tok in id_tokens(&line[idx + MARKER.len()..]) {
                    out.insert(tok);
                }
            }
        }
    }
    out
}

/// Byte offset of the first `#[cfg(test)]` attribute that gates a `mod` (a test
/// module), or `None` if the file has none. A `#[cfg(test)]` on a non-module
/// item (e.g. a test-only accessor method) is skipped so a mid-file test helper
/// does not pull the production `// spec:` comments after it into the citation
/// region.
///
/// Between the attribute and the `mod` keyword, further attributes (e.g.
/// `#[allow(..)]`) and `//`/`///` line comments are skipped, and any
/// visibility (`pub`, `pub(crate)`, `pub(in ...)`, ...) before `mod` is
/// accepted. The `mod` keyword itself may be followed by any whitespace,
/// including a newline (`mod\ntests`).
///
/// This is a textual scan, not a real parser: an occurrence of the literal
/// `#[cfg(test)]` text inside a `//`/`///` comment is explicitly excluded (by
/// checking whether the line up to the attribute is itself a comment line), but
/// an occurrence inside a block comment (`/* ... */`) or a string literal is
/// not detected and could still be misread as a real attribute. That is an
/// accepted limitation given the repo's style (block comments and string
/// literals containing this exact attribute text are not in use); if one is
/// ever added, this function must be hardened further rather than trusted
/// blindly.
///
/// If a real test module is ever hidden behind a form this function does not
/// recognize, this returns `None` for that file and its citations stop
/// counting. That is loud ONLY when every ID cited from that module has no
/// other citation and no allowlist entry -- see the caller's note on why a
/// missed module is usually a silent narrowing, not a build failure.
fn find_test_module(text: &str) -> Option<usize> {
    const ATTR: &str = "#[cfg(test)]";

    fn line_start(text: &str, at: usize) -> usize {
        text[..at].rfind('\n').map(|i| i + 1).unwrap_or(0)
    }

    // Skip a `#[...]` attribute (balanced on `[`/`]`) starting at `s[0..]`,
    // returning the byte length consumed, or `None` if `s` doesn't start with
    // one or it's unterminated.
    fn skip_attr(s: &str) -> Option<usize> {
        if !s.starts_with("#[") {
            return None;
        }
        let bytes = s.as_bytes();
        let mut depth = 0i32;
        let mut j = 0;
        while j < bytes.len() {
            match bytes[j] {
                b'[' => depth += 1,
                b']' => {
                    depth -= 1;
                    if depth == 0 {
                        return Some(j + 1);
                    }
                }
                _ => {}
            }
            j += 1;
        }
        None
    }

    // Skip a `//` or `///` line comment starting at `s[0..]`, returning the
    // byte length consumed (up to and including the newline, if any), or
    // `None` if `s` doesn't start with one.
    fn skip_line_comment(s: &str) -> Option<usize> {
        if !s.starts_with("//") {
            return None;
        }
        Some(match s.find('\n') {
            Some(nl) => nl + 1,
            None => s.len(),
        })
    }

    let mut from = 0;
    'outer: while let Some(rel) = text[from..].find(ATTR) {
        let at = from + rel;

        // An occurrence whose own line is itself a comment line (e.g. a doc
        // comment reading "Test-only (`#[cfg(test)]`): ...") is prose, not a
        // real attribute -- skip it entirely rather than risk it gating a
        // `mod` that follows later in the same comment block.
        let prefix = text[line_start(text, at)..at].trim_start();
        if prefix.starts_with("//") {
            from = at + ATTR.len();
            continue;
        }

        // Walk past whitespace, further attributes, and line comments until
        // we hit real code.
        let mut i = at + ATTR.len();
        loop {
            let rest = &text[i..];
            let trimmed = rest.trim_start();
            i += rest.len() - trimmed.len();
            let rest = &text[i..];
            if let Some(n) = skip_attr(rest) {
                i += n;
                continue;
            }
            if let Some(n) = skip_line_comment(rest) {
                i += n;
                continue;
            }
            break;
        }

        let mut after = text[i..].trim_start();
        if let Some(rest) = after.strip_prefix("pub") {
            after = match rest.strip_prefix('(') {
                Some(rest) => match rest.find(')') {
                    Some(p) => rest[p + 1..].trim_start(),
                    None => {
                        // Unterminated `pub(...`: not a real visibility
                        // modifier we understand. Try the next occurrence.
                        from = at + ATTR.len();
                        continue 'outer;
                    }
                },
                None => rest.trim_start(),
            };
        }
        if after
            .strip_prefix("mod")
            .is_some_and(|r| r.starts_with(char::is_whitespace))
        {
            return Some(at);
        }
        from = at + ATTR.len();
    }
    None
}

#[test]
fn find_test_module_recognizes_every_mod_form() {
    // Each case: (label, source text, expected TAIL of the source starting at
    // the `#[cfg(test)]` that should be matched -- `None` if no test module
    // should be found. Comparing the tail (rather than a hand-computed byte
    // offset) pins down *which* occurrence matched without brittle counting.
    let cases: &[(&str, &str, Option<&str>)] = &[
        (
            "plain: #[cfg(test)] mod tests",
            "// spec: FOO-1\nfn helper() {}\n#[cfg(test)]\nmod tests {\n    // spec: BAR-1\n}\n",
            Some("#[cfg(test)]\nmod tests {\n    // spec: BAR-1\n}\n"),
        ),
        (
            "pub mod tests",
            "fn helper() {}\n#[cfg(test)]\npub mod tests {\n    // spec: BAR-1\n}\n",
            Some("#[cfg(test)]\npub mod tests {\n    // spec: BAR-1\n}\n"),
        ),
        (
            "pub(crate) mod tests",
            "fn helper() {}\n#[cfg(test)]\npub(crate) mod tests {\n    // spec: BAR-1\n}\n",
            Some("#[cfg(test)]\npub(crate) mod tests {\n    // spec: BAR-1\n}\n"),
        ),
        (
            "pub(in crate::foo) mod tests",
            "fn helper() {}\n#[cfg(test)]\npub(in crate::foo) mod tests {\n    // spec: BAR-1\n}\n",
            Some("#[cfg(test)]\npub(in crate::foo) mod tests {\n    // spec: BAR-1\n}\n"),
        ),
        (
            "an intervening attribute: #[cfg(test)] #[allow(dead_code)] mod tests",
            "fn helper() {}\n#[cfg(test)]\n#[allow(dead_code)]\nmod tests {\n    // spec: BAR-1\n}\n",
            Some("#[cfg(test)]\n#[allow(dead_code)]\nmod tests {\n    // spec: BAR-1\n}\n"),
        ),
        (
            "an intervening comment between the attribute and mod",
            "fn helper() {}\n#[cfg(test)]\n// why this module exists\nmod tests {\n    // spec: BAR-1\n}\n",
            Some("#[cfg(test)]\n// why this module exists\nmod tests {\n    // spec: BAR-1\n}\n"),
        ),
        (
            "mod on its own line (newline form)",
            "fn helper() {}\n#[cfg(test)]\nmod\ntests {\n    // spec: BAR-1\n}\n",
            Some("#[cfg(test)]\nmod\ntests {\n    // spec: BAR-1\n}\n"),
        ),
        (
            "a #[cfg(test)] gating a plain fn, not a module -- must be skipped",
            "fn helper() {}\n#[cfg(test)]\nfn only_in_tests() {}\n// spec: FOO-2\n",
            None,
        ),
        (
            "a #[cfg(test)] gating a pub fn, not a module -- must be skipped",
            "fn helper() {}\n#[cfg(test)]\npub fn only_in_tests() {}\n// spec: FOO-2\n",
            None,
        ),
        (
            "a non-module #[cfg(test)] followed later by a real test module",
            "#[cfg(test)]\npub fn accessor_for_tests_only() {}\n\n#[cfg(test)]\nmod tests {\n    // spec: BAR-1\n}\n",
            Some("#[cfg(test)]\nmod tests {\n    // spec: BAR-1\n}\n"),
        ),
        (
            "no #[cfg(test)] at all",
            "fn helper() {}\n// spec: FOO-3\n",
            None,
        ),
        (
            "the literal text appears only inside a doc comment, never gating real code",
            "/// Test-only (`#[cfg(test)]`): it must never be reachable from prod.\nfn helper() {}\n// spec: FOO-4\n",
            None,
        ),
    ];

    for (label, src, expected_tail) in cases {
        let got = find_test_module(src);
        match expected_tail {
            Some(tail) => {
                let idx = got.unwrap_or_else(|| {
                    panic!("case {label:?}: expected a match, got None for input:\n{src}")
                });
                assert_eq!(
                    &src[idx..],
                    *tail,
                    "case {label:?}: matched at the wrong occurrence"
                );
            }
            None => {
                assert_eq!(
                    got, None,
                    "case {label:?}: expected no match, got {got:?} for input:\n{src}"
                );
            }
        }
    }
}

/// A regression guard for the specific bug class this gate exists to prevent:
/// once `find_test_module` locates the region, citations inside it must still
/// be collected. This is a smoke test on the region-slicing behavior, not a
/// re-test of `find_test_module` itself.
#[test]
fn find_test_module_region_includes_citations_after_the_match() {
    let src = "fn helper() {}\n#[cfg(test)]\npub(crate) mod tests {\n    // spec: QUX-9\n}\n";
    let at = find_test_module(src).expect("expected a test module to be found");
    assert!(
        src[at..].contains("QUX-9"),
        "the sliced region must still contain the citation after the match point"
    );
}

/// Extract the LEADING run of spec IDs after the `// spec:` marker.
///
/// Only the ids at the head of the comment count, stopping at the first token
/// that is not one. A citation line reads `// spec: ID[, ID...] optional prose`,
/// and the prose routinely mentions other IDs for context ("HOOK-51 (HOOK-3
/// generalized)"). Crediting those would let an ID satisfy the coverage gate on
/// the strength of a passing mention in a test that asserts something else,
/// which is how HOOK-3 came to have a citation and no test.
///
/// Tokens are whitespace-separated and may carry a trailing `,` or `;`. A token
/// wrapped in anything else (a parenthesis, a backtick) is prose, so it stops
/// the run.
fn id_tokens(text: &str) -> Vec<String> {
    let mut out = Vec::new();
    for raw in text.split_whitespace() {
        let tok = raw.trim_end_matches([',', ';']);
        if is_id(tok) {
            out.push(tok.to_string());
        } else {
            break;
        }
    }
    out
}

#[test]
fn id_tokens_takes_the_leading_run_only() {
    let ids = |s: &str| id_tokens(s);
    // The ordinary forms: one id, several, with or without commas.
    assert_eq!(ids(" CLI-10"), ["CLI-10"]);
    assert_eq!(ids(" CLI-10 CLI-72"), ["CLI-10", "CLI-72"]);
    assert_eq!(ids(" CLI-10, CLI-72"), ["CLI-10", "CLI-72"]);
    // Prose after the ids is not scanned, so an id named for context does not
    // become a citation. This is the HOOK-3 case: the test asserts HOOK-51 and
    // merely mentions HOOK-3.
    assert_eq!(
        ids(" HOOK-51 (HOOK-3 generalized): blank run entries are skipped"),
        ["HOOK-51"]
    );
    assert_eq!(
        ids(" CLI-11 (repo spec parsing), CLI-61 (compare url)"),
        ["CLI-11"]
    );
    // A leading non-id stops the run immediately.
    assert_eq!(ids(" see CLI-10").len(), 0);
    assert_eq!(ids("").len(), 0);
}

fn files_with_ext(dir: &Path, ext: &str) -> Vec<PathBuf> {
    let mut out = Vec::new();
    let Ok(rd) = std::fs::read_dir(dir) else {
        return out;
    };
    for entry in rd.flatten() {
        let path = entry.path();
        if path.is_dir() {
            out.extend(files_with_ext(&path, ext));
        } else if path.extension().is_some_and(|e| e == ext) {
            out.push(path);
        }
    }
    out
}