tix-tracker 0.12.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
#[allow(clippy::wildcard_imports)]
use super::*;

/// Lint every issue file in the store.
///
/// Returns `true` when at least one finding was reported (the CLI boundary
/// turns that into exit `1`; `false` stays exit `0`).
pub fn run(config: &Config, store: &Store) -> Result<bool> {
    run_with(config, store, config.lint_foreign_repositories)
}

/// As [`run`], with an explicit foreign-lint switch for the CLI's `--no-foreign`.
pub fn run_with(config: &Config, store: &Store, foreign: bool) -> Result<bool> {
    let findings = collect_with(config, store, foreign)?;
    if findings.is_empty() {
        println!("Lint OK: {} issues checked.", issue_files(store)?.len());
        return Ok(false);
    }
    for finding in &findings {
        eprintln!("{}", render_diagnostic(&store.issues_dir, finding));
    }
    // **Only errors set the exit code** (#1361). A warning is printed and
    // counted, and a file with warnings still exits 0: the unknown key it carries
    // is preserved on write, so the file is valid, and failing a build over it
    // would report a non-problem as a broken file.
    let errors = findings
        .iter()
        .filter(|f| f.severity == Severity::Error)
        .count();
    let warnings = findings.len() - errors;
    if errors == 0 {
        println!(
            "Lint OK with {warnings} warning{}: {} issues checked.",
            if warnings == 1 { "" } else { "s" },
            issue_files(store)?.len()
        );
    }
    Ok(errors > 0)
}

/// The lint core (WP03): walk every issue file and return the raw
/// [`TixIssueFinding`] vector — no rendering, no printing (C-005).
///
/// This is what MCP's `lint_issues` tool (WP04) calls, mapping findings to
/// `structuredContent`. The validation walk continues past malformed files
/// (`missing`/`parse` findings, FR-018) exactly as the CLI path did; the
/// `annotate-snippets` rendering and the exit-bool semantics stay in [`run`].
pub fn collect(config: &Config, store: &Store) -> Result<Vec<TixIssueFinding>> {
    collect_with(config, store, config.lint_foreign_repositories)
}

/// As [`collect`], but with an explicit foreign-lint switch — the CLI's
/// `--no-foreign` and the MCP `foreign` argument override the config default for
/// one run.
pub fn collect_with(config: &Config, store: &Store, foreign: bool) -> Result<Vec<TixIssueFinding>> {
    let mut findings = collect_local(config, store)?;
    if foreign {
        let mut visited = HashSet::new();
        // Seed with this store's own root so a reference back into it is skipped.
        if let Some(root) = Config::project_root(&store.issues_dir) {
            visited.insert(root);
        }
        collect_foreign(store, &mut visited, &mut findings);
    }
    Ok(findings)
}

pub(super) fn collect_local(config: &Config, store: &Store) -> Result<Vec<TixIssueFinding>> {
    let files = issue_files(store)?;
    let planner = planner_for(config);
    let milestone_names = planner.milestone_names().unwrap_or_default();
    let mut findings: Vec<TixIssueFinding> = Vec::new();
    let mut parsed: Vec<ParsedFile> = Vec::new();
    for rel in &files {
        if let Some(entry) = collect_for_file(
            config,
            store,
            &planner,
            &milestone_names,
            rel,
            &mut findings,
        )? {
            parsed.push(entry);
        }
    }
    // The number identity is an issue-level alias, so a collision is a fact about
    // the snapshot rather than one file: the sweep runs after the per-file pass,
    // beside the other whole-snapshot checks (tix #1591).
    collect_duplicate_numbers(&parsed, &mut findings);
    // One sweep over what that walk produced, after the per-file pass, so the
    // relationship findings are not interleaved with per-file ones (T026 step 2).
    // A file that failed to parse is already reported as `parse`/`missing` and is
    // simply absent from the graph — which is the correct scope for a snapshot
    // check, since the issue's relationships are unknowable when it cannot be read.
    collect_relationships(&parsed, store, &mut findings);
    // The same shape for the hierarchy: one whole-snapshot pass, after the
    // per-file one, so hierarchy findings are not interleaved with per-file ones.
    collect_hierarchy(&parsed, &mut findings);
    // WP09 item A: the project-reference family — one whole-snapshot pass over the
    // service's `validate_references`, so a dangling association the registry no
    // longer holds is reported by name (C-005, `contracts/cli-surface.md` §4). Like
    // the hierarchy pass beside it, this is observational and reports nothing for a
    // file that failed to parse (already reported as `parse`/`missing`).
    collect_project_references(config, &parsed, &mut findings);
    // And the epic-reference family (WP09 item A): the same whole-snapshot shape,
    // from the epic service's own `resolve_references` (C-005, contracts
    // `cli-surface.md` §4 / `epic-registry.md` §5). It covers **every** reference
    // kind the service diagnoses — issue-held epic ids and an epic's own
    // project/milestone/cycle/parent references — with the service's own reason
    // wording, so lint and the epic surfaces cannot describe one dangling reference
    // two ways.
    collect_epic_references(config, &planner, &parsed, &mut findings);
    // Per-entry references need the store to resolve local selectors, so this is
    // a snapshot pass like the two above rather than a per-file rule.
    collect_related_issues(&parsed, store, &mut findings);
    collect_layout_mismatch(config, &parsed, &mut findings);
    // And the same for the two workflow families (WP06 T026/T027): the
    // configuration check, which needs no issue at all, and the direct-edit check,
    // which needs the parsed issues and their History. Both reuse the service's
    // diagnostics rather than re-deriving the rules, so `tix lint` and
    // `tix workflow show` cannot report one condition two ways (C-003).
    collect_workflow_config(config, &mut findings);
    collect_workflow_direct_edits(config, &parsed, &mut findings);
    // And the WIP family (WP07 T032, mission `configurable-wip-limits-01M3C6WX`):
    // one whole-snapshot pass, from the same shared aggregate the board renders, so
    // lint and the board cannot disagree about how full a column is. Lint **reports**
    // and never repairs (C-007): the findings below are the whole of its
    // contribution, and no file is rewritten.
    collect_wip(config, &parsed, &mut findings);
    Ok(findings)
}

/// Report every issue number shared by more than one file (tix #1591).
///
/// `number` is the branch-local display alias: ids give every issue a
/// collision-proof identity, but a merge can still leave two files claiming the
/// same number. Every selector, every `parent:` and every relationship edge then
/// resolves to whichever file the scan reaches first and silently re-targets the
/// rest — so this is an **error**, not a warning, and the exit code says so.
///
/// One finding per involved file, each pointing at its own `number:` line, and
/// every message names **all** files in the group. The remedy is deliberately
/// manual: lint is observational and never rewrites an issue, a parent or an
/// edge, so renumbering (or refusing to) stays the author's decision.
pub(super) fn collect_duplicate_numbers(
    parsed: &[ParsedFile],
    findings: &mut Vec<TixIssueFinding>,
) {
    let mut by_number: BTreeMap<u32, Vec<&ParsedFile>> = BTreeMap::new();
    for entry in parsed {
        by_number.entry(entry.issue.number).or_default().push(entry);
    }
    for (number, mut entries) in by_number {
        if entries.len() < 2 {
            continue;
        }
        entries.sort_by(|a, b| a.rel.cmp(&b.rel));
        let files = entries
            .iter()
            .map(|entry| entry.rel.as_str())
            .collect::<Vec<_>>()
            .join(", ");
        for entry in entries {
            findings.push(TixIssueFinding {
                severity: Severity::Error,
                file: entry.rel.clone(),
                line: locate_key_line(&entry.text, "number"),
                column: 1,
                value: number.to_string(),
                kind: "duplicate_number".to_string(),
                message: format!("number {number} is shared by {files}"),
            });
        }
    }
}

/// The WIP findings: a column over its limit, and an estimate nobody can count
/// (WP07 T032/T033).
///
/// Two shapes, deliberately different:
///
/// - an **over-limit column** is one finding with no source file — the condition is
///   about a column, and pointing it at one of the cards in it would blame a file
///   that is fine. `line`/`column` are `0` and `file` is empty, which is what the
///   renderer treats as a configuration finding;
/// - an **uncountable estimate** names the issue's own file and value, because that
///   is a fact about that file and the reader can fix it there.
///
/// Both are warnings: a column over its limit is a policy violation worth knowing
/// about, not a broken file, and failing a build over it would make every
/// over-quota repository red.
pub(super) fn collect_wip(
    config: &Config,
    parsed: &[ParsedFile],
    findings: &mut Vec<TixIssueFinding>,
) {
    if parsed.is_empty() {
        return;
    }
    let issues: Vec<Issue> = parsed.iter().map(|p| p.issue.clone()).collect();
    let snapshot = crate::wip::WipSnapshot::build(config.default_config(), &issues);
    if !snapshot.is_configured() {
        return;
    }
    for usage in snapshot.violations() {
        findings.push(TixIssueFinding {
            severity: Severity::Warning,
            file: String::new(),
            line: 0,
            column: 0,
            value: usage.cell(),
            kind: crate::wip::WipDiagnosticKind::OverLimit
                .as_str()
                .to_string(),
            message: format!(
                "`{}` holds {} against {}",
                usage.column,
                usage.cell(),
                usage
                    .limit
                    .as_ref()
                    .map_or_else(String::new, describe_wip_limit)
            ),
        });
    }
    // An uncountable estimate makes the column's total unknown, which is the reason
    // a limit cannot be judged there — so it is reported per file, with the value
    // that could not be read.
    for diagnostic in snapshot.diagnostics() {
        let Some(entry) = parsed.iter().find(|parsed| {
            diagnostic
                .message
                .contains(&format!("#{}", parsed.issue.number))
        }) else {
            continue;
        };
        findings.push(TixIssueFinding {
            severity: Severity::Warning,
            file: entry.rel.clone(),
            line: 0,
            column: 0,
            value: entry.issue.estimate.clone().unwrap_or_default(),
            kind: diagnostic.kind.as_str().to_string(),
            message: diagnostic.message.clone(),
        });
    }
}

/// A limit as one phrase, for a lint message.
pub(super) fn describe_wip_limit(limit: &crate::wip::ColumnLimit) -> String {
    let mut parts = Vec::new();
    if let Some(max) = limit.max_items {
        parts.push(format!("max_items {max}"));
    }
    if let Some(max) = limit.max_estimate {
        match limit.unit.as_deref() {
            Some(unit) => parts.push(format!("max_estimate {max}{unit}")),
            None => parts.push(format!("max_estimate {max}")),
        }
    }
    match limit.policy {
        Some(policy) => parts.push(policy.as_str().to_string()),
        None => parts.push("no policy".to_string()),
    }
    parts.join(", ")
}

/// Lint the foreign repositories reachable from this store's `related_issues`
/// path references, merging their findings (prefixed and de-duplicated).
///
/// Bounded: each repository is linted once per run (a `visited` set), one hop
/// deep (a foreign repo's own foreign repositories are not entered — the config
/// flag governs the *local* run only), and a hard cap. Best-effort: an
/// unreachable reference, a URL, or an unparsable foreign `tix.toml` becomes one
/// non-fatal note, never an error that hides the local results.
pub(super) fn collect_foreign(
    store: &Store,
    visited: &mut HashSet<PathBuf>,
    findings: &mut Vec<TixIssueFinding>,
) {
    const MAX_REPOS: usize = 64;
    let Ok(issues) = store.list_all() else {
        return;
    };
    let mut candidates: Vec<PathBuf> = Vec::new();
    let mut remote: Vec<String> = Vec::new();
    let mut missing: Vec<String> = Vec::new();
    for issue in &issues {
        let base = related::base_dir(&store.issues_dir, &issue.filename);
        for r in related::classify_all(&issue.related_issues) {
            match &r {
                RelatedRef::Path { .. } => {
                    if let Some(target) = related::resolve_path(&base, &r) {
                        if !target.exists() {
                            // A path that points nowhere is worth a note; a path
                            // that exists but is not a tix repository (e.g. the
                            // chalkboard) is intentionally foreign and stays
                            // silent.
                            missing.push(r.display());
                            continue;
                        }
                        let start = if target.is_dir() {
                            target
                        } else {
                            target
                                .parent()
                                .map_or_else(|| target.clone(), Path::to_path_buf)
                        };
                        if let Some(root) = Config::project_root(&start) {
                            candidates.push(root);
                        }
                    }
                }
                RelatedRef::Url { .. } => remote.push(r.display()),
                RelatedRef::Local(_) => {}
            }
        }
    }
    candidates.sort();
    candidates.dedup();

    let mut linted = 0usize;
    for root in candidates {
        if linted >= MAX_REPOS {
            break;
        }
        if visited.contains(&root) {
            continue;
        }
        visited.insert(root.clone());
        match lint_foreign_repo(&root) {
            Ok((label, mut foreign)) => {
                for f in &mut foreign {
                    // A config-level finding has an empty file; prefix it with the
                    // repo alone so it does not render as `[repo] `.
                    f.file = if f.file.is_empty() {
                        format!("[{label}]")
                    } else {
                        format!("[{label}] {}", f.file)
                    };
                }
                findings.extend(foreign);
                linted += 1;
            }
            Err(reason) => findings.push(foreign_note(root.to_string_lossy().to_string(), reason)),
        }
    }
    remote.sort();
    remote.dedup();
    for url in remote {
        findings.push(foreign_note(
            url.clone(),
            format!("{url}: not linted (remote)"),
        ));
    }
    missing.sort();
    missing.dedup();
    for path in missing {
        findings.push(foreign_note(
            path.clone(),
            format!("{path}: related path does not exist"),
        ));
    }
}

/// Load, open, and lint one foreign repository rooted at `root`.
pub(super) fn lint_foreign_repo(
    root: &Path,
) -> std::result::Result<(String, Vec<TixIssueFinding>), String> {
    let cfg_path = root.join("tix.toml");
    let mut cfg = Config::load_with_warning(&cfg_path, |_| {})
        .map_err(|e| format!("{}: not linted ({e})", root.display()))?;
    if cfg.issues_dir.is_relative() {
        cfg.issues_dir = root.join(&cfg.issues_dir);
    }
    if !cfg.issues_dir.is_dir() {
        return Err(format!(
            "{}: not linted (issues dir {} is not a directory)",
            root.display(),
            cfg.issues_dir.display()
        ));
    }
    let store = Store::open(&cfg).map_err(|e| format!("{}: not linted ({e})", root.display()))?;
    // `collect_local`, not `collect`: one hop, never re-entering the foreign
    // repo's own foreign repositories.
    let foreign =
        collect_local(&cfg, &store).map_err(|e| format!("{}: not linted ({e})", root.display()))?;
    let label = root
        .file_name()
        .and_then(|n| n.to_str())
        .unwrap_or("foreign")
        .to_string();
    Ok((label, foreign))
}

/// A non-fatal, file-level note about a foreign repository that could not be
/// linted (or was skipped as remote).
pub(super) fn foreign_note(value: String, message: String) -> TixIssueFinding {
    TixIssueFinding {
        severity: Severity::Warning,
        file: String::new(),
        line: 0,
        column: 0,
        value,
        kind: "foreign_repo_not_linted".to_string(),
        message,
    }
}

/// The configuration findings (`direct_edit_policy_violation`'s sibling family),
/// from the service — WP06 T026.
///
/// **Mostly unreachable, and that is not a gap in this function.** `validate_config`
/// reports seven kinds, and three of them — `missing_completion_category`,
/// `unknown_category`, `mapping_for_unknown_column` — are refused at **config load**.
/// `tix lint` cannot run on a config that will not load, so the only finding that can
/// actually reach here is `unmapped_column_default`, plus `direct_edit_policy_violation`
/// once an issue exists to contradict. T029's configuration scenarios say otherwise,
/// and they are unreachable for the same reason WP05's `workflow show` test was: the
/// command refuses first. Recorded rather than worked around with a test that asserts
/// a success the tool never reaches.
///
/// `unmapped_column_default` is a [`Severity::Warning`]: it is **not** an error and
/// does not set the exit code. A column relying on the documented safe default is a
/// perfectly valid configuration — it is what every repository in the world has — and
/// failing a build over it would be reporting a non-problem as a broken one. The
/// decision is recorded here because it is the kind of thing that gets silently
/// changed by a later "make lint stricter" edit.
///
/// The finding is reported against the config, not a file: `file` is empty, which the
/// renderer already handles as a file-level finding.
pub(super) fn collect_workflow_config(config: &Config, findings: &mut Vec<TixIssueFinding>) {
    // WP06, revised after the change's blast radius was measured: the safe-default
    // finding is aggregated to **one per board**, naming every column.
    //
    // T026 step 3 says "surface `unmapped_column_default` for every column relying on
    // the safe default", and the service does return one diagnostic per column — that
    // is the right granularity for a diagnostic, and `tix workflow show` prints them
    // per column in its table. But lint is a *summary* surface, and no repository in
    // the world declares a `category_map`, so per-column reporting means every
    // `tix lint` in existence goes from silent to three or four warnings for a
    // perfectly valid configuration. A linter that always has something to say is a
    // linter whose output stops being read.
    //
    // Aggregating keeps the contract's substance — every column relying on the
    // default is named, in the message — and keeps the service's granularity intact
    // for the surfaces that render per column.
    let mut by_board: BTreeMap<String, Vec<String>> = BTreeMap::new();
    for diagnostic in validate_config(config) {
        match diagnostic.kind {
            DiagnosticKind::UnmappedColumnDefault => {
                // `subject` is `{board}.{column}`; split on the last dot so a board
                // name containing a dot still works.
                let (board, column) = match diagnostic.subject.rsplit_once('.') {
                    Some((board, column)) => (board.to_string(), column.to_string()),
                    None => (diagnostic.subject.clone(), String::new()),
                };
                by_board.entry(board).or_default().push(column);
            }
            // Every other kind is load-fatal and cannot arrive; if one ever does it is
            // a real problem with the config and must be loud.
            _ => findings.push(TixIssueFinding {
                severity: Severity::Error,
                file: String::new(),
                line: 0,
                column: 0,
                value: diagnostic.subject.clone(),
                kind: diagnostic.kind.as_str().to_string(),
                message: diagnostic.message,
            }),
        }
    }
    for (board, columns) in by_board {
        let count = columns.len();
        findings.push(TixIssueFinding {
            severity: Severity::Warning,
            file: String::new(),
            line: 0,
            column: 0,
            value: board.clone(),
            kind: DiagnosticKind::UnmappedColumnDefault.as_str().to_string(),
            message: format!(
                "{count} of {count} column(s) on board `{board}` have no `category_map` \
                 entry and resolve to the safe default `{}`: {}. This is valid \
                 configuration — it is what every repository has — and it does not \
                 affect the exit code. To map them, add a \
                 [kanban.{board}.category_map] table.",
                config.categories.default,
                columns.join(", ")
            ),
        });
    }
}

/// `direct_edit_policy_violation`: files whose `status` contradicts their History —
/// WP06 T027.
///
/// **This function writes nothing, and neither does anything it calls.** It does not
/// repair the file, does not append a History entry, does not rewrite existing
/// History, and offers no "fix" affordance (C-005, decision
/// `01M3C3XBMTR5WFV7059NXM04AJ`). A hand-edited file is left exactly as the user left
/// it and stays readable — lint *reports*, and the contradiction is theirs to resolve.
///
/// That note is here, next to the code, because "helpfully" repairing a contradiction
/// is the single most likely way for a future contributor to turn this into a scope
/// violation: a lint that fixes user data is no longer observational, and the user
/// loses the ability to see what they changed.
///
/// One pass over the parsed set, and the service reports each file **once** — a file
/// that both diverged and whose decision now contradicts policy is one finding, not
/// two.
pub(super) fn collect_workflow_direct_edits(
    config: &Config,
    parsed: &[ParsedFile],
    findings: &mut Vec<TixIssueFinding>,
) {
    if parsed.is_empty() {
        return;
    }
    let (_, resolution) = crate::workflow::active_resolution(config);
    let issues: Vec<Issue> = parsed.iter().map(|p| p.issue.clone()).collect();
    for finding in crate::workflow::lint_direct_edits(&issues, &resolution, config) {
        let Some(entry) = parsed.iter().find(|p| p.issue.number == finding.number) else {
            continue;
        };
        // The `kind` is the contract's own `as_str()` (contract §6). Both columns go
        // into `value` so a diagnostic names the disagreement rather than saying
        // "something changed", and the `status:` line is the span so the rendered
        // diagnostic points at the value in question.
        let value = match &finding.recorded {
            Some(recorded) => format!("{recorded} -> {}", finding.current),
            None => finding.current.clone(),
        };
        findings.push(finding_at(
            &entry.rel,
            &entry.text,
            finding.kind.as_str(),
            "status",
            &value,
            finding.message,
        ));
    }
}

/// The hierarchy check family (WP05 T022), as **one** pass over the snapshot.
///
/// `unresolved_parent` only, and nothing else: the other hierarchy rules —
/// `self_parent`, `cycle` — are **write-time** rules the service refuses a
/// mutation on (contract §3), so a file that contains one was either hand-edited or
/// written by a build that predates the field. They are still worth reporting, and
/// `hierarchy::validate_hierarchy` produces all three kinds; taking the whole result
/// rather than filtering keeps this function from becoming a place where a rule is
/// silently dropped.
///
/// Observational only. Nothing here writes, repairs, or clears: lint reports, and
/// `tix edit --parent` repairs (contract §5).
pub(super) fn collect_hierarchy(parsed: &[ParsedFile], findings: &mut Vec<TixIssueFinding>) {
    let by_number: BTreeMap<u32, &ParsedFile> =
        parsed.iter().map(|p| (p.issue.number, p)).collect();
    let by_file: BTreeMap<&str, &ParsedFile> = parsed.iter().map(|p| (p.rel.as_str(), p)).collect();
    let snapshot: Vec<Issue> = parsed.iter().map(|p| p.issue.clone()).collect();
    let result = crate::hierarchy::validate_hierarchy(&snapshot);
    for diagnostic in &result.errors {
        // The finding's `kind` is the diagnostic's own `as_str()`. Inventing a
        // different spelling would be a contract violation, exactly as it is for the
        // relationship findings above.
        let kind = diagnostic.kind.as_str();
        // A diagnostic about an issue that is not in the readable set cannot be
        // attributed to a file, and is skipped: a file that failed to parse is
        // already reported as `parse`/`missing`, and re-reporting it here would
        // double-count one problem as two.
        let source = diagnostic
            .source_file
            .as_deref()
            .and_then(|filename| by_file.get(filename).copied())
            .or_else(|| by_number.get(&diagnostic.issue).copied());
        if let Some(entry) = source {
            findings.push(finding_at(
                &entry.rel,
                &entry.text,
                kind,
                &diagnostic.field,
                // The offending value is the recorded reference, read from the
                // issue's own field. The first version passed `""`, and the
                // renderer printed `invalid unresolved_parent value ''` — a
                // diagnostic saying the value is empty when the file plainly
                // contains `parent: 0`. Read from the field rather than parsed out
                // of the message, so the two cannot disagree.
                &entry
                    .issue
                    .parent
                    .map(|p| p.to_string())
                    .unwrap_or_default(),
                diagnostic.message.clone(),
            ));
        }
    }
}

/// The project-reference check family (WP09 item A), as **one** pass over the
/// snapshot.
///
/// [`crate::projects::validate_references`] performs the pass — one walk of the
/// issue set, not one lookup per project — and returns one entry per association
/// whose project the registry does not hold, ordered by `(issue number, project
/// id)` so the output is stable. Lint reports each as the
/// [`crate::projects::MISSING_REFERENCE_KIND`] the service names, carrying the
/// service's own reason string: a second wording here would let `tix lint` and the
/// `tix project delete` notice describe one dangling reference two ways (C-005).
///
/// Observational, like every other lint family: nothing is written, repaired, or
/// cleared. A reference whose project is still declared is not a finding, and a
/// clean store produces none.
pub(super) fn collect_project_references(
    config: &Config,
    parsed: &[ParsedFile],
    findings: &mut Vec<TixIssueFinding>,
) {
    if parsed.is_empty() {
        return;
    }
    let snapshot: Vec<Issue> = parsed.iter().map(|p| p.issue.clone()).collect();
    let by_number: BTreeMap<u32, &ParsedFile> =
        parsed.iter().map(|p| (p.issue.number, p)).collect();
    for missing in crate::projects::validate_references(config, &snapshot) {
        // A diagnostic about an issue absent from the readable set cannot be
        // attributed to a file; a file that failed to parse is already reported as
        // `parse`/`missing`, and re-reporting it here would double-count one problem
        // as two.
        let Some(entry) = by_number.get(&missing.issue.number()) else {
            continue;
        };
        findings.push(finding_at(
            &entry.rel,
            &entry.text,
            crate::projects::MISSING_REFERENCE_KIND,
            // The `projects:` key line is where the association lives; the offending
            // value is the recorded id, carried so the annotation names it.
            crate::projects::PROJECTS_KEY,
            &missing.project_id,
            missing.reason.clone(),
        ));
    }
}

/// The epic-reference check family (WP09 item A), as **one** pass over the
/// snapshot.
///
/// [`crate::epics::resolve_references`] is the single owner: it returns one entry
/// per reference that does not resolve, covering all five kinds the service
/// diagnoses — an issue-held `epics` entry that names no live epic or is
/// malformed, and an epic record's own parent, project, milestone and cycle
/// references. Lint reports each as the kind's own
/// [`crate::epics::EpicReferenceKind::diagnostic_kind`], carrying the service's
/// reason verbatim: a second wording here would let `tix lint` and the epic
/// surfaces describe one dangling reference two ways (C-005).
///
/// Two shapes, because the holder decides where the reference lives. An
/// **issue-held** reference points at the issue's own `epics:` block, so the
/// finding is located there. An **epic-held** reference lives in `tix.toml`, not
/// in any issue file, so the finding is a file-level one (`file` empty) whose note
/// names the epic and the reference — the same shape the configuration findings
/// use.
///
/// Observational, like every other lint family: nothing is written, repaired, or
/// cleared. The epic-held half needs the planner's project/milestone/cycle
/// lookups; if the milestone registry cannot be read the per-file milestone check
/// already reports it, so this family is skipped rather than emitting false
/// positives.
pub(super) fn collect_epic_references(
    config: &Config,
    planner: &Planner,
    parsed: &[ParsedFile],
    findings: &mut Vec<TixIssueFinding>,
) {
    let Ok(targets) = crate::epics::ReferenceTargets::from_planner(planner) else {
        return;
    };
    let issues: Vec<Issue> = parsed.iter().map(|p| p.issue.clone()).collect();
    let by_number: BTreeMap<u32, &ParsedFile> =
        parsed.iter().map(|p| (p.issue.number, p)).collect();
    for missing in crate::epics::resolve_references(&config.epics, &issues, &targets) {
        let kind = missing.target_kind.diagnostic_kind();
        match &missing.holder {
            crate::epics::ReferenceHolder::Issue(key) => {
                // A diagnostic about an issue absent from the readable set cannot be
                // attributed to a file; a file that failed to parse is already
                // reported as `parse`/`missing`, and re-reporting it here would
                // double-count one problem as two.
                let Some(entry) = by_number.get(&key.number()) else {
                    continue;
                };
                findings.push(finding_at(
                    &entry.rel,
                    &entry.text,
                    kind,
                    crate::epics::EPICS_KEY,
                    &missing.target,
                    missing.reason.clone(),
                ));
            }
            crate::epics::ReferenceHolder::Epic(_) => findings.push(TixIssueFinding {
                severity: Severity::Error,
                file: String::new(),
                line: 0,
                column: 0,
                value: missing.target.clone(),
                kind: kind.to_string(),
                message: missing.reason.clone(),
            }),
        }
    }
}

/// The relationship check family (WP06 T025/T026), as **one** pass over the
/// snapshot.
///
/// It calls the service's `validate_snapshot` and converts each diagnostic. It
/// does not re-derive any graph state locally — no adjacency walk, no cycle
/// search, no per-issue rebuild. That is a parity requirement
/// (`contracts/surface-parity.md` §1) and a cost one: `validate_snapshot` is a
/// single `O(V + E)` pass, whereas rebuilding the graph per issue would make lint
/// quadratic on a large tree, and a linter that takes minutes on a big repo gets
/// stopped rather than fixed.
///
/// The per-issue pass above is left alone. This sweep is whole-snapshot by
/// nature — a cycle has no owning file — so there is no per-file form to add and
/// no way to express it inside `collect_for_file`.
pub(super) fn collect_relationships(
    parsed: &[ParsedFile],
    store: &Store,
    findings: &mut Vec<TixIssueFinding>,
) {
    let done_column = store.relationship_done_column();
    // The graph reports issue *numbers*; a finding needs a *file*. Built once,
    // from the same walk, so the two cannot disagree about what exists.
    let by_number: BTreeMap<u32, &ParsedFile> =
        parsed.iter().map(|p| (p.issue.number, p)).collect();
    let by_file: BTreeMap<&str, &ParsedFile> = parsed.iter().map(|p| (p.rel.as_str(), p)).collect();

    let views: Vec<IssueView<'_>> = parsed.iter().map(|p| p.issue.issue_view()).collect();
    let graph = RelationshipGraph::new(&views, &done_column);
    let result = graph.validate_snapshot();

    for diagnostic in &result.errors {
        // The finding's `kind` is the diagnostic's own `as_str()` — the seven
        // names `relationship-service.md` §3 fixes. Inventing a different
        // spelling here would be a contract violation.
        let kind = diagnostic.kind.as_str();
        let source = diagnostic
            .source_file
            .as_deref()
            .and_then(|filename| by_file.get(filename).copied())
            .or_else(|| diagnostic.issue.and_then(|n| by_number.get(&n).copied()));
        match source {
            Some(entry) => findings.push(finding_at(
                &entry.rel,
                &entry.text,
                kind,
                &diagnostic.field,
                &diagnostic.field,
                diagnostic.message.clone(),
            )),
            // A diagnostic with no single subject. It still has to surface, so it
            // is reported against the store root rather than dropped: a linter
            // that silently discards a contract diagnostic is worse than one that
            // reports it in an awkward place.
            None => findings.push(TixIssueFinding {
                severity: Severity::Error,
                file: String::new(),
                line: 0,
                column: 0,
                value: diagnostic.field.clone(),
                kind: kind.to_string(),
                message: diagnostic.message.clone(),
            }),
        }
    }
}

/// Per-issue `related_issues` checks: a blank entry, or a local selector that
/// resolves to nothing.
///
/// Only `Local` entries are resolved — a path or URL may be outside the store or
/// need the network, so lint accepts it verbatim and stays offline. Warnings, not
/// errors: an unresolved reference is a dangling pointer to be repaired, not a
/// broken build.
pub(super) fn collect_related_issues(
    parsed: &[ParsedFile],
    store: &Store,
    findings: &mut Vec<TixIssueFinding>,
) {
    for entry in parsed {
        for raw in &entry.issue.related_issues {
            let kind = if raw.trim().is_empty() {
                Some("related_issue_empty")
            } else {
                let r = crate::related::classify(raw);
                match r.local_selector() {
                    Some(_) if crate::related::resolve_local(store, raw).is_err() => {
                        Some("related_issue_unresolved")
                    }
                    _ => None,
                }
            };
            let Some(kind) = kind else { continue };
            let (line, column) =
                locate_value(&entry.text, "related_issues").map_or((0, 0), |l| (l.line, l.column));
            let message = match kind {
                "related_issue_empty" => {
                    "empty related-issue entry; remove it or name an issue".to_string()
                }
                _ => format!(
                    "related issue `{raw}` matches no issue in this store; it may be a \
                     path or URL (which lint does not resolve) or a typo"
                ),
            };
            findings.push(TixIssueFinding {
                severity: Severity::Warning,
                file: entry.rel.clone(),
                line,
                column,
                value: raw.clone(),
                kind: kind.to_string(),
                message,
            });
        }
    }
}

/// Warn about an issue whose current path does not match an explicitly
/// configured `[layout]`.
///
/// Gated on a **non-default** layout: with no `[layout]` section the default
/// template is not "expected" of a pre-existing store, and warning on every
/// legacy file would be noise, not signal. Once a project opts in, the warning
/// is the discovery path for files that still need `tix layout migrate`.
pub(super) fn collect_layout_mismatch(
    config: &Config,
    parsed: &[ParsedFile],
    findings: &mut Vec<TixIssueFinding>,
) {
    // Gate on the *templates*, not on `LayoutConfig::is_default()`: a repo that
    // only turned `move_on_change` off still uses the default layout, and every
    // pre-existing file matching it must not be flagged.
    let default = crate::config::LayoutConfig::default();
    if config.layout.directory == default.directory && config.layout.filename == default.filename {
        return;
    }
    for entry in parsed {
        // Compare the *directory*, not the filename: the filename template
        // governs newly created issues, and a hand-kept name is not a mismatch.
        let expected = crate::layout::render_directory(&config.layout, &entry.issue);
        let current = entry
            .issue
            .filename
            .rsplit_once('/')
            .map_or("", |(dir, _)| dir);
        if current != expected {
            findings.push(TixIssueFinding {
                severity: Severity::Warning,
                file: entry.rel.clone(),
                line: 0,
                column: 0,
                value: entry.issue.filename.clone(),
                kind: "layout_mismatch".to_string(),
                message: format!(
                    "directory does not match `[layout]`: expected `{expected}`; run `tix layout migrate`"
                ),
            });
        }
    }
}

/// Lint a single issue file by relative path.
pub fn collect_file(config: &Config, store: &Store, rel: &str) -> Result<Vec<TixIssueFinding>> {
    let planner = planner_for(config);
    let milestone_names = planner.milestone_names().unwrap_or_default();
    let mut findings = Vec::new();
    collect_for_file(
        config,
        store,
        &planner,
        &milestone_names,
        rel,
        &mut findings,
    )?;
    // Single-file lint is the same command with a narrower scope, so it reports
    // the same findings for that file and no relationship findings — those are a
    // property of the whole snapshot, and a caller asking about one file is not
    // asking about the snapshot. Stated rather than implied, because the obvious
    // alternative (graph just this file) would report a cycle as if it were the
    // only cycle in the repo, which is a different and wrong claim.
    Ok(findings)
}

/// Every issue file in the store, mirroring the storage layout exactly:
/// `.md` files at the flat root plus one level of segment dirs, skipping
/// `.tix-tmp-*` marker dirs.
pub(super) fn issue_files(store: &Store) -> Result<Vec<String>> {
    let mut files: Vec<String> = Vec::new();
    collect_issue_files(&store.issues_dir, "", &mut files)?;
    files.sort();
    Ok(files)
}

/// Recursive walk mirroring `Store::scan`: any depth, skipping hidden dirs,
/// `.tix-tmp-*` temp dirs, and `<slug>-assets/` dirs.
pub(super) fn collect_issue_files(dir: &Path, prefix: &str, files: &mut Vec<String>) -> Result<()> {
    if !dir.exists() {
        return Ok(());
    }
    for entry in fs::read_dir(dir)? {
        let entry = entry?;
        let name = entry.file_name().to_string_lossy().to_string();
        let path = entry.path();
        if path.is_dir() {
            if name.starts_with('.') || name.starts_with(".tix-tmp-") || name.ends_with("-assets") {
                continue;
            }
            let child = if prefix.is_empty() {
                name
            } else {
                format!("{prefix}/{name}")
            };
            collect_issue_files(&path, &child, files)?;
        } else if path.is_file() && name.ends_with(".md") {
            files.push(if prefix.is_empty() {
                name
            } else {
                format!("{prefix}/{name}")
            });
        }
    }
    Ok(())
}