rto-graph 1.26.6

Provenance-tagged codebase knowledge graph store for Roteiro. Implementation detail of the roteiro CLI; no API stability guarantee.
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
//! Intent-debt detection: the deterministic scan that turns comment markers,
//! stub macros, and deferral notes into `marker` nodes (see ADR-0001's provenance
//! model — these are `derived`, a pure function of the blob bytes).
//!
//! "Intent debt" is the class of signals that say *something is missing* or
//! *left for later*: `TODO`/`FIXME`/`HACK` comments (in any case for the
//! unambiguous tags), `todo!()`/`unimplemented!()` stubs, and deferral phrases
//! (`for now`, `deferred`, `not implemented`, unchecked `- [ ]` items). The scan
//! is line-based so it works uniformly over code, docs, and ADRs; [`augment`]
//! attaches each finding to its innermost enclosing symbol (or the file) via a
//! `contains` edge.
//!
//! The unambiguous tags (`TODO`, `todo!(`, `BUG:`) match anywhere on a line. The
//! noisier prose phrases (`for now`, `deferred`, `not implemented`, …) are
//! restricted to *comment* context in code files — keyed off the file's
//! [`CommentSyntax`] — so in code they are detected only inside comments, never
//! in identifiers, string literals, or running code. Prose and unknown files
//! carry no such syntax and are scanned in full, since there every line is
//! effectively prose.
//!
//! A phrase must confess incompleteness, not merely *name* a concept. That is
//! why the bare word `placeholder` is not a needle — see [`RULES`].
//!
//! Opt-out: a source can suppress false positives with an inline directive —
//! `roteiro:ignore` on a line skips that line, and `roteiro:ignore-file`
//! anywhere in a blob skips the whole file. This module carries the file
//! directive below, because a file that only enumerates marker vocabulary would
//! otherwise catalogue itself.
//
// roteiro:ignore-file — this file defines the detection vocabulary.

use crate::{Edge, EdgeKind, FactSet, Node, NodeKind, Provenance, Span};

/// The category of an intent-debt [`Marker`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MarkerCategory {
    /// A `TODO`: planned work.
    Todo,
    /// A `FIXME`/`BUG`: a known defect to fix.
    Fixme,
    /// A `HACK`/`XXX`: a deliberate but unsatisfactory shortcut.
    Hack,
    /// A stub: something declared but not written (`todo!()`,
    /// `unimplemented!()`, "not implemented", "placeholder implementation").
    Stub,
    /// Work deliberately deferred ("for now", "deferred", "follow-up", "TBD",
    /// unchecked `- [ ]` items).
    Deferred,
}

impl MarkerCategory {
    /// Stable string token, as stored in a marker node's `meta.category`.
    #[must_use]
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Todo => "todo",
            Self::Fixme => "fixme",
            Self::Hack => "hack",
            Self::Stub => "stub",
            Self::Deferred => "deferred",
        }
    }
}

/// One detected intent-debt finding, located within a single source blob.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Marker {
    /// What kind of debt this is.
    pub category: MarkerCategory,
    /// The (trimmed, length-capped) text of the line the marker was found on.
    pub text: String,
    /// 1-based line number within the blob.
    pub line: u32,
    /// Byte span of the line within the blob.
    pub span: Span,
    /// Byte offset of the first non-whitespace byte on the line. Used to resolve
    /// the enclosing symbol: an indented declaration's tree-sitter span starts
    /// after its leading whitespace, so the line's *start* offset would fall
    /// before it and misattach a same-line marker to an outer container.
    pub anchor: u32,
}

/// How a rule's needle is matched against a line.
enum Mode {
    /// Case-sensitive substring (for the macro/call syntax `todo!(`).
    Substr,
    /// Case-sensitive whole word (for the uppercase `TODO`-style tags, matched
    /// anywhere on the line).
    Word,
    /// Case-insensitive whole word/phrase (for `todo`/`fixme`/`tbd` in any case
    /// and prose deferral notes). `needle` must be lowercase.
    Phrase,
    /// Case-insensitive whole word in *annotation form* — immediately followed
    /// by `:` or `(` (e.g. `Bug:`, `hack(x):`). Lets the noisier tags match in
    /// any case without flagging the bare English words `bug`/`hack`/`xxx` in
    /// prose. `needle` must be lowercase.
    Annotation,
}

/// The detection table, checked top to bottom; the first hit on a line wins, so
/// more specific rules precede more general ones. Kept intentionally small and
/// high-signal — this is a report, not a gate, but noise still erodes trust.
///
/// Case handling: `todo`/`fixme`/`tbd` match in any case anywhere (the words are
/// unambiguous). `BUG`/`HACK`/`XXX` match uppercase anywhere, but in other cases
/// only as an annotation (`Bug:`, `hack(`), since bare lowercase `bug`/`hack` are
/// ordinary English. The prose phrases were already case-insensitive.
///
/// The final `bool` is `comment_only`: when set, the rule fires only within a
/// *comment* (in a code file with a known [`CommentSyntax`]). This restricts the
/// noisy prose phrases — `for now`, `deferred`, … — to comments, so they no
/// longer flag identifiers, string literals, or ordinary code. The unambiguous
/// tags (`TODO`, `todo!(`, `BUG:`) still match anywhere, and prose files
/// (Markdown, plain text, unknown types) are scanned in full — see
/// [`comment_syntax`].
///
/// # Why the bare word `placeholder` is not a needle
///
/// It was one, and it was wrong every single time. Measured on this repository:
/// **36 of 36** `stub` markers came from it and **not one** was a stub. They were
/// the *external-ref placeholder node* ([`crate::links`], [`crate::Workspace`] —
/// an implemented ADR-0009 concept), the redaction placeholder a secret value is
/// replaced with ([`crate::config_keys`], ADR-0015), the security lens's own
/// sentence about not being able to tell a secret from a placeholder, a `{tag}`
/// ref template, and the CSS `::placeholder` pseudo-element. Restricting it to
/// comments (which is right, and which it already was) does not help, because
/// every one of those is *prose about the architecture*.
///
/// The distinction the table encodes is between a phrase that **confesses
/// incompleteness** and a word that **names a thing**. `placeholder` is a common
/// noun: a placeholder node, a placeholder value, placeholder text. Only when it
/// predicates something of an implementation — "placeholder implementation",
/// "returns a placeholder" — does it mean *unfinished*, and those are the forms
/// kept below. The narrower `is a placeholder` was measured too and rejected: its
/// single hit on this repository is [`crate::config_keys`] documenting the
/// redaction value, so it is the same false positive with a longer needle.
///
/// Deleting the word costs no coverage that can be demonstrated: on this
/// repository it removed 36 findings and reclassified none, and it is the reason
/// `stub` now reports 0 — which is the true count, since `todo!(`,
/// `unimplemented!(` and `not implemented` have no hits here either.
const RULES: &[(&str, MarkerCategory, Mode, bool)] = &[
    ("todo!(", MarkerCategory::Stub, Mode::Substr, false),
    ("unimplemented!(", MarkerCategory::Stub, Mode::Substr, false),
    ("todo", MarkerCategory::Todo, Mode::Phrase, false),
    ("fixme", MarkerCategory::Fixme, Mode::Phrase, false),
    ("BUG", MarkerCategory::Fixme, Mode::Word, false),
    ("HACK", MarkerCategory::Hack, Mode::Word, false),
    ("XXX", MarkerCategory::Hack, Mode::Word, false),
    ("bug", MarkerCategory::Fixme, Mode::Annotation, false),
    ("hack", MarkerCategory::Hack, Mode::Annotation, false),
    ("xxx", MarkerCategory::Hack, Mode::Annotation, false),
    (
        "not yet implemented",
        MarkerCategory::Stub,
        Mode::Phrase,
        true,
    ),
    ("not implemented", MarkerCategory::Stub, Mode::Phrase, true),
    (
        "placeholder implementation",
        MarkerCategory::Stub,
        Mode::Phrase,
        true,
    ),
    (
        "returns a placeholder",
        MarkerCategory::Stub,
        Mode::Phrase,
        true,
    ),
    ("for now", MarkerCategory::Deferred, Mode::Phrase, true),
    ("deferred", MarkerCategory::Deferred, Mode::Phrase, true),
    ("follow-up", MarkerCategory::Deferred, Mode::Phrase, true),
    ("followup", MarkerCategory::Deferred, Mode::Phrase, true),
    ("tbd", MarkerCategory::Deferred, Mode::Phrase, true),
];

/// Line- and block-comment syntax for a language, used to restrict the noisy
/// prose phrases to comment context in code files.
struct CommentSyntax {
    /// Line-comment leaders; the rest of the line after one is comment text.
    line: &'static [&'static str],
    /// The block-comment open/close pair, if the language has one.
    block: Option<(&'static str, &'static str)>,
}

/// `//` line comments plus `/* … */` blocks — the C family and its descendants.
const SLASH_STAR: CommentSyntax = CommentSyntax {
    line: &["//"],
    block: Some(("/*", "*/")),
};
/// `#` line comments, no block form — scripting languages.
const HASH: CommentSyntax = CommentSyntax {
    line: &["#"],
    block: None,
};
/// `--` line comments plus `/* … */` blocks — SQL.
const DASH_STAR: CommentSyntax = CommentSyntax {
    line: &["--"],
    block: Some(("/*", "*/")),
};
/// `--` line comments, no block form — Lua/Haskell/Elm.
const DASH: CommentSyntax = CommentSyntax {
    line: &["--"],
    block: None,
};
/// `;` line comments, no block form — Lisps.
const SEMI: CommentSyntax = CommentSyntax {
    line: &[";"],
    block: None,
};

/// The comment syntax for a path's language, keyed by extension, or `None` for a
/// prose or unknown file — where every line is treated as comment context, so
/// the prose phrases are scanned in full (the previous behaviour). Restricting
/// the phrases to comments only applies where we can reliably tell code from
/// comment.
fn comment_syntax(path: &str) -> Option<&'static CommentSyntax> {
    let ext = path
        .rsplit('.')
        .next()
        .unwrap_or_default()
        .to_ascii_lowercase();
    let syntax = match ext.as_str() {
        "rs" | "c" | "h" | "cc" | "cpp" | "cxx" | "hpp" | "hh" | "js" | "jsx" | "mjs" | "cjs"
        | "ts" | "tsx" | "go" | "java" | "kt" | "kts" | "swift" | "scala" | "cs" | "php" | "m"
        | "mm" | "rust" | "dart" | "zig" | "v" => &SLASH_STAR,
        "py" | "rb" | "sh" | "bash" | "zsh" | "pl" | "pm" | "tcl" | "r" | "nim" => &HASH,
        "sql" => &DASH_STAR,
        "lua" | "hs" | "elm" | "adb" | "ads" => &DASH,
        "el" | "lisp" | "clj" | "cljs" | "cljc" | "scm" | "ss" | "rkt" => &SEMI,
        _ => return None,
    };
    Some(syntax)
}

/// The comment text of a single `line` under `syn`, dropping code. Tracks block
/// comments across lines via `in_block`. It is a lightweight approximation of a
/// real lexer: it does not model string literals, so a comment delimiter *inside*
/// a string is treated as opening a comment. That bias is deliberate — it only
/// ever *widens* what counts as a comment, so a genuine in-comment phrase is
/// never missed; the cost is that a phrase after a `//`-in-a-string could still
/// be reported, which is no worse than the old scan-everything behaviour.
fn comment_portion(line: &str, syn: &CommentSyntax, in_block: &mut bool) -> String {
    let mut out = String::new();
    let mut i = 0usize;
    while i < line.len() {
        if *in_block {
            let Some((_, close)) = syn.block else {
                *in_block = false;
                continue;
            };
            if let Some(rel) = line[i..].find(close) {
                out.push_str(&line[i..i + rel]);
                i += rel + close.len();
                *in_block = false;
                continue;
            }
            out.push_str(&line[i..]);
            break;
        }
        // Earliest line-leader or block opener from the current offset.
        let mut best: Option<(usize, Option<usize>)> = None;
        for lead in syn.line {
            if let Some(rel) = line[i..].find(lead) {
                let pos = i + rel;
                if best.is_none_or(|(bp, _)| pos < bp) {
                    best = Some((pos, None));
                }
            }
        }
        if let Some((open, _)) = syn.block
            && let Some(rel) = line[i..].find(open)
        {
            let pos = i + rel;
            if best.is_none_or(|(bp, _)| pos < bp) {
                best = Some((pos, Some(open.len())));
            }
        }
        match best {
            None => break,
            Some((pos, None)) => {
                out.push_str(&line[pos..]);
                break;
            }
            Some((pos, Some(open_len))) => {
                *in_block = true;
                i = pos + open_len;
            }
        }
    }
    out
}

/// Characters of marker text kept, to keep nodes small. On truncation an ellipsis
/// is appended, so a stored value is at most this many characters plus the `…`.
const MAX_TEXT: usize = 200;

/// Characters of the marker *node name* kept — a compact label. As with
/// [`MAX_TEXT`], a truncated name carries a trailing `…`, so it is at most this
/// many characters plus one.
const MAX_NAME: usize = 80;

/// Inline opt-out placed on a line to skip *that line* during detection.
const IGNORE_LINE: &str = "roteiro:ignore";
/// Inline opt-out placed anywhere in a blob to skip the *whole file* — for
/// sources that only enumerate marker vocabulary (like this one) and would
/// otherwise report themselves. `IGNORE_LINE` is a prefix of this, so a file
/// directive also satisfies the per-line check.
const IGNORE_FILE: &str = "roteiro:ignore-file";

/// Scan `bytes` for intent-debt markers, one (highest-priority) per line, in
/// ascending line order. Deterministic: identical `(path, bytes)` always yield
/// identical markers. `path` selects the language's [`CommentSyntax`], which
/// restricts the noisy prose phrases to comment context in code files; prose and
/// unknown files are scanned in full.
#[must_use]
pub fn scan_markers(path: &str, bytes: &[u8]) -> Vec<Marker> {
    // Whole-file opt-out: a blob carrying the file directive is skipped entirely.
    if contains_bytes(bytes, IGNORE_FILE.as_bytes()) {
        return Vec::new();
    }
    let syntax = comment_syntax(path);
    let mut in_block = false;
    let mut out = Vec::new();
    let mut offset: u32 = 0;
    for (idx, raw) in bytes.split(|&b| b == b'\n').enumerate() {
        let raw_len = u32::try_from(raw.len()).unwrap_or(u32::MAX);
        let decoded = String::from_utf8_lossy(raw);
        let line = decoded.trim_end_matches('\r');
        // The comment-only portion of the line (block state carried across
        // lines). For a prose/unknown file the whole line counts as comment.
        let comment = match syntax {
            Some(syn) => comment_portion(line, syn, &mut in_block),
            None => line.to_owned(),
        };
        // Per-line opt-out.
        if line.contains(IGNORE_LINE) {
            offset = offset.saturating_add(raw_len).saturating_add(1);
            continue;
        }
        if let Some(category) = classify(line, &comment) {
            let lead = u32::try_from(raw.iter().take_while(|b| b.is_ascii_whitespace()).count())
                .unwrap_or(0);
            out.push(Marker {
                category,
                text: cap_chars(line, MAX_TEXT, true),
                line: u32::try_from(idx + 1).unwrap_or(u32::MAX),
                span: Span::new(offset, offset.saturating_add(raw_len)),
                anchor: offset.saturating_add(lead),
            });
        }
        // +1 for the '\n' that `split` consumed; the trailing empty element for a
        // file ending in a newline classifies to `None`, so the overrun is inert.
        offset = offset.saturating_add(raw_len).saturating_add(1);
    }
    out
}

/// Classify a `line` into a marker category, or `None`. `comment` is the line's
/// comment-only text (equal to `line` for prose/unknown files); a `comment_only`
/// rule is matched against it instead of the full line, so prose phrases fire
/// only inside comments.
fn classify(line: &str, comment: &str) -> Option<MarkerCategory> {
    for (needle, category, mode, comment_only) in RULES {
        let hay = if *comment_only { comment } else { line };
        let hit = match mode {
            Mode::Substr => hay.contains(needle),
            Mode::Word => find_bounded(hay, needle, false).is_some(),
            Mode::Phrase => find_bounded(hay, needle, true).is_some(),
            Mode::Annotation => find_annotation(hay, needle).is_some(),
        };
        if hit {
            return Some(*category);
        }
    }
    // A markdown unchecked task item is a deferred-work signal on its own.
    let t = line.trim_start();
    if t.starts_with("- [ ]") || t.starts_with("* [ ]") || t.starts_with("+ [ ]") {
        return Some(MarkerCategory::Deferred);
    }
    None
}

/// Find `needle` in `hay` at word boundaries (the byte before and after the
/// match must not be alphanumeric or `_`). When `ci`, the match is
/// ASCII-case-insensitive; `needle` must already be lowercase in that case.
/// `to_ascii_lowercase` preserves byte length, so match offsets stay aligned.
fn find_bounded(hay: &str, needle: &str, ci: bool) -> Option<usize> {
    let lowered;
    let h: &str = if ci {
        lowered = hay.to_ascii_lowercase();
        &lowered
    } else {
        hay
    };
    let bytes = h.as_bytes();
    let mut from = 0;
    while let Some(rel) = h[from..].find(needle) {
        let start = from + rel;
        let end = start + needle.len();
        let before_ok = start == 0 || !is_word_byte(bytes[start - 1]);
        let after_ok = end >= bytes.len() || !is_word_byte(bytes[end]);
        if before_ok && after_ok {
            return Some(start);
        }
        from = start + 1;
    }
    None
}

/// Find `needle` (given lowercase) as an ASCII-case-insensitive whole word in
/// *annotation form*: preceded by a non-word boundary and immediately followed
/// by `:` or `(`. Used for the noisier tags so `Bug:` matches but bare `bug`
/// does not.
fn find_annotation(hay: &str, needle: &str) -> Option<usize> {
    let lowered = hay.to_ascii_lowercase();
    let bytes = lowered.as_bytes();
    let mut from = 0;
    while let Some(rel) = lowered[from..].find(needle) {
        let start = from + rel;
        let end = start + needle.len();
        let before_ok = start == 0 || !is_word_byte(bytes[start - 1]);
        let after_ok = end < bytes.len() && matches!(bytes[end], b':' | b'(');
        if before_ok && after_ok {
            return Some(start);
        }
        from = start + 1;
    }
    None
}

/// Whether `b` continues an identifier word (so a match touching it is not a
/// standalone token).
fn is_word_byte(b: u8) -> bool {
    b.is_ascii_alphanumeric() || b == b'_'
}

/// Whether `hay` contains the byte sequence `needle` (a small ASCII directive).
fn contains_bytes(hay: &[u8], needle: &[u8]) -> bool {
    needle.len() <= hay.len() && hay.windows(needle.len()).any(|w| w == needle)
}

/// Keep at most `max` characters of `s`, appending an ellipsis when truncated
/// (so a truncated result is `max` characters plus the `…`), optionally trimming
/// surrounding whitespace first. Character-based so it never splits a multi-byte
/// codepoint.
fn cap_chars(s: &str, max: usize, trim: bool) -> String {
    let s = if trim { s.trim() } else { s };
    if s.chars().count() > max {
        let mut out: String = s.chars().take(max).collect();
        out.push('');
        out
    } else {
        s.to_owned()
    }
}

/// Scan `bytes` and append a `marker` node and a `contains` edge per finding to
/// `facts`. Each marker is attached to the innermost node in `facts` whose span
/// encloses it (a symbol when the extractor produced one, otherwise the file).
///
/// Called by [`crate::Registry`] after the language extractor runs, so markers
/// are cached alongside the rest of a blob's facts.
pub fn augment(facts: &mut FactSet, path: &str, blob_id: &str, bytes: &[u8]) {
    for m in scan_markers(path, bytes) {
        let key = format!("marker:{path}#{}", m.line);
        // Resolve against the first non-whitespace byte, not the line start: an
        // indented symbol's tree-sitter span begins after its leading
        // whitespace, so the line start would fall before it.
        let container =
            innermost_container(&facts.nodes, m.anchor).unwrap_or_else(|| format!("file:{path}"));
        facts.nodes.push(Node {
            key: key.clone(),
            kind: NodeKind::Marker,
            name: cap_chars(&m.text, MAX_NAME, false),
            path: Some(path.to_owned()),
            lang: None,
            blob_hash: Some(blob_id.to_owned()),
            span: Some(m.span),
            provenance: Provenance::Derived,
            meta: serde_json::json!({
                "category": m.category.as_str(),
                "text": m.text,
                "line": m.line,
            }),
        });
        facts
            .edges
            .push(Edge::derived(container, key, EdgeKind::Contains));
    }
}

/// The key of the node with the smallest span that encloses byte `offset`
/// (ties broken by key for determinism), if any.
fn innermost_container(nodes: &[Node], offset: u32) -> Option<String> {
    nodes
        .iter()
        .filter_map(|n| n.span.map(|s| (n, s)))
        .filter(|(_, s)| s.start <= offset && offset < s.end)
        .min_by(|(a, sa), (b, sb)| (sa.end - sa.start, &a.key).cmp(&(sb.end - sb.start, &b.key)))
        .map(|(n, _)| n.key.clone())
}

#[cfg(test)]
mod tests {
    use super::{MarkerCategory, augment, scan_markers};
    use crate::{EdgeKind, FactSet, Node, NodeKind, Span};

    // Scan as a prose/unknown file (no comment syntax → every line scanned).
    fn categories(src: &str) -> Vec<(u32, MarkerCategory)> {
        categories_in("", src)
    }

    // Scan as `path`, so a code language's comment syntax gates the prose phrases.
    fn categories_in(path: &str, src: &str) -> Vec<(u32, MarkerCategory)> {
        scan_markers(path, src.as_bytes())
            .into_iter()
            .map(|m| (m.line, m.category))
            .collect()
    }

    #[test]
    fn detects_each_category() {
        let src = "\
// TODO wire this up
let x = todo!();
// FIXME off-by-one
// HACK relies on ordering
// this is not implemented for now
- [ ] finish the docs
plain line, nothing here
";
        let got = categories(src);
        assert_eq!(got[0], (1, MarkerCategory::Todo));
        assert_eq!(got[1], (2, MarkerCategory::Stub)); // todo!(
        assert_eq!(got[2], (3, MarkerCategory::Fixme));
        assert_eq!(got[3], (4, MarkerCategory::Hack));
        assert_eq!(got[4], (5, MarkerCategory::Stub)); // "not implemented" wins over "for now"
        assert_eq!(got[5], (6, MarkerCategory::Deferred)); // checkbox
        assert_eq!(got.len(), 6, "the plain line is not a marker");
    }

    #[test]
    fn word_boundaries_avoid_false_positives() {
        // Tags embedded in larger words must not match — using uppercase forms
        // that WOULD match if `find_bounded` regressed to substring search.
        assert!(categories("mastodon Todos BUGFIX fixmelike").is_empty());
        // But a real standalone tag does.
        assert_eq!(categories("x // BUG here")[0].1, MarkerCategory::Fixme);
    }

    #[test]
    fn tags_match_mixed_case() {
        // todo / fixme / tbd match in any case, anywhere on the line.
        assert_eq!(categories("// Todo: wire it")[0].1, MarkerCategory::Todo);
        assert_eq!(categories("// fixme this path")[0].1, MarkerCategory::Fixme);
        assert_eq!(categories("decision TBD")[0].1, MarkerCategory::Deferred);
        // BUG / HACK / XXX match uppercase anywhere...
        assert_eq!(categories("// HACK ordering")[0].1, MarkerCategory::Hack);
        // ...or in any case only as an annotation (`Bug:`, `hack(...)`).
        assert_eq!(categories("note a Bug: crash")[0].1, MarkerCategory::Fixme);
        assert_eq!(
            categories("// hack(perf): fast path")[0].1,
            MarkerCategory::Hack
        );
        // But bare lowercase bug/hack in prose is not a marker.
        assert!(categories("we fixed a bug in the hack layer").is_empty());
    }

    // The scanner must tell a comment that *names* a placeholder — an implemented,
    // load-bearing concept in this codebase — from one that *confesses* to being
    // one. The bare word `placeholder` was a needle and could not: all 36 `stub`
    // markers it produced on this repository were architectural prose (see
    // `RULES`). The fixture below pairs the two senses line by line, so a rule
    // that drifts back to the bare word fails here rather than in the debt count.
    #[test]
    fn naming_a_placeholder_is_not_confessing_to_one() {
        let src = "\
//! an **external-ref node** — a local placeholder standing in for the hub's node
/// The placeholder a redacted config value is replaced with, before anything
/// cannot distinguish a real secret from a placeholder. An empty report means
/// value is a ref template with a `{tag}` placeholder, e.g. `app:{tag}`
// placeholder implementation, returns empty
// returns a placeholder until the resolver lands
";
        let got = categories_in("a.rs", src);
        assert_eq!(
            got,
            vec![(5, MarkerCategory::Stub), (6, MarkerCategory::Stub)],
            "only the two confessions are debt; lines 1-4 name a concept"
        );

        // The same four architectural lines in a prose file, where every line is
        // comment context, are equally not debt — the fix is in the vocabulary,
        // not in the comment gating that happens to hide it in code.
        assert!(
            categories_in(
                "NOTES.md",
                &src.lines().take(4).collect::<Vec<_>>().join("\n")
            )
            .is_empty()
        );
    }

    #[test]
    fn prose_phrases_are_comment_only_in_code_files() {
        // In a code file the deferral phrases fire only inside a comment…
        assert_eq!(
            categories_in("a.rs", "    // just not implemented for now\n")[0].1,
            MarkerCategory::Stub
        );
        assert_eq!(
            categories_in("a.rs", "let msg = format!(\"loaded {n} for now\");\n")
                .first()
                .map(|m| m.1),
            None,
            "a phrase in a string literal is not a marker"
        );
        // …including trailing comments after code, and C-style block comments.
        assert_eq!(
            categories_in("a.rs", "do_thing(); // deferred until v2\n")[0].1,
            MarkerCategory::Deferred
        );
        assert_eq!(
            categories_in("a.rs", "let x = 1; /* not implemented */ let y = 2;\n")[0].1,
            MarkerCategory::Stub
        );
        // An identifier that merely contains a phrase is not a marker.
        assert!(categories_in("a.rs", "let deferred_tasks = vec![];\n").is_empty());
        // The unambiguous tags still fire anywhere, comment or not.
        assert_eq!(
            categories_in("a.rs", "let s = \"TODO: not a comment\";\n")[0].1,
            MarkerCategory::Todo
        );
    }

    #[test]
    fn block_comments_span_lines_in_code_files() {
        // A phrase inside a multi-line `/* … */` block is comment context on the
        // interior line; code after the close is not.
        let got = categories_in(
            "a.rs",
            "/* a note\n   deferred to later\n*/ let s = \"deferred to v2\";\n",
        );
        assert_eq!(got.len(), 1);
        assert_eq!(got[0], (2, MarkerCategory::Deferred));
    }

    #[test]
    fn prose_files_scan_phrases_everywhere() {
        // Markdown/plain-text carry no comment syntax, so the phrases still match
        // as before — a spec that says "deferred" is intent debt.
        assert_eq!(
            categories_in("PLAN.md", "We will defer the audit; deferred to v2.\n")[0].1,
            MarkerCategory::Deferred
        );
    }

    #[test]
    fn non_slash_comment_syntaxes_gate_phrases() {
        // `#` line comments (Python/shell): phrase in a comment fires, in a string
        // does not.
        assert_eq!(
            categories_in("m.py", "x = 1  # not implemented for now\n")[0].1,
            MarkerCategory::Stub
        );
        assert!(categories_in("m.py", "msg = \"deferred until later\"\n").is_empty());
        // `#` also covers shell.
        assert_eq!(
            categories_in("run.sh", "# deferred to a follow-up\n")[0].1,
            MarkerCategory::Deferred
        );
        // `--` line comments (SQL): comment fires, a quoted string does not.
        assert_eq!(
            categories_in("q.sql", "SELECT 1; -- not implemented yet\n")[0].1,
            MarkerCategory::Stub
        );
        assert!(categories_in("q.sql", "SELECT 'deferred' AS status;\n").is_empty());
    }

    #[test]
    fn scanning_is_deterministic() {
        let src = b"// TODO one\ncode\n// FIXME two\n";
        assert_eq!(scan_markers("a.rs", src), scan_markers("a.rs", src));
    }

    #[test]
    fn ignore_directives_suppress_line_and_file() {
        // A per-line directive skips only that line.
        let got = categories("// TODO real\n// TODO shush  roteiro:ignore\n// FIXME real\n");
        assert_eq!(got.len(), 2);
        assert_eq!(got[0].0, 1);
        assert_eq!(got[1].0, 3); // line 2 suppressed

        // A file directive suppresses everything in the blob.
        assert!(
            scan_markers(
                "a.rs",
                b"// TODO x\n// note: roteiro:ignore-file\n// FIXME y\n"
            )
            .is_empty()
        );
    }

    #[test]
    fn augment_attaches_to_innermost_symbol_then_file() {
        // A file node spanning everything, and a symbol node spanning bytes 10..40.
        let mut facts = FactSet::new()
            .with_node(Node {
                span: Some(Span::new(0, 100)),
                ..Node::new("file:a.rs", NodeKind::File, "a.rs")
            })
            .with_node(Node {
                span: Some(Span::new(10, 40)),
                ..Node::new("sym:rust:a.rs#f", NodeKind::Fn, "f")
            });
        // Line 1 (offset 0) is a plain line outside the symbol; line 2 starts at
        // offset 20, inside the symbol's 10..40 span.
        let bytes = b"aaaaaaaaaaaaaaaaaaa\n// FIXME inside fn f\n";
        augment(&mut facts, "a.rs", "blob", bytes);

        let marker = facts
            .nodes
            .iter()
            .find(|n| n.kind == NodeKind::Marker)
            .expect("a marker node");
        assert_eq!(marker.meta["category"], "fixme");
        // The contains edge comes from the enclosing symbol, not the file.
        let edge = facts
            .edges
            .iter()
            .find(|e| e.kind == EdgeKind::Contains && e.dst == marker.key)
            .expect("a contains edge");
        assert_eq!(edge.src, "sym:rust:a.rs#f");
    }

    #[test]
    fn augment_attaches_to_symbol_on_its_own_indented_line() {
        // A symbol whose span starts after leading indentation (as tree-sitter
        // reports), with the marker as a trailing comment on that same line.
        let mut facts = FactSet::new()
            .with_node(Node {
                span: Some(Span::new(0, 80)),
                ..Node::new("file:a.rs", NodeKind::File, "a.rs")
            })
            .with_node(Node {
                // `fn f` begins at byte 4, after four spaces of indentation.
                span: Some(Span::new(4, 40)),
                ..Node::new("sym:rust:a.rs#f", NodeKind::Fn, "f")
            });
        augment(&mut facts, "a.rs", "blob", b"    fn f() { // TODO soon }\n");

        let marker = facts
            .nodes
            .iter()
            .find(|n| n.kind == NodeKind::Marker)
            .unwrap();
        let edge = facts
            .edges
            .iter()
            .find(|e| e.kind == EdgeKind::Contains && e.dst == marker.key)
            .unwrap();
        // The anchor (byte 4) is inside the symbol; the line start (byte 0) is
        // not — resolving from the line start would misattach to the file.
        assert_eq!(edge.src, "sym:rust:a.rs#f");
    }

    #[test]
    fn augment_falls_back_to_file_when_no_symbol_encloses() {
        let mut facts = FactSet::new().with_node(Node {
            span: Some(Span::new(0, 100)),
            ..Node::new("file:a.rs", NodeKind::File, "a.rs")
        });
        augment(&mut facts, "a.rs", "blob", b"// TODO top of file\n");
        let marker = facts
            .nodes
            .iter()
            .find(|n| n.kind == NodeKind::Marker)
            .unwrap();
        let edge = facts
            .edges
            .iter()
            .find(|e| e.kind == EdgeKind::Contains && e.dst == marker.key)
            .unwrap();
        assert_eq!(edge.src, "file:a.rs");
    }
}