From a3f7634899cfe9ea9271b0994df9f5bb4620052c Mon Sep 17 00:00:00 2001 From: lab Date: Sun, 6 Sep 2026 01:32:28 +0700 Subject: [PATCH 1/4] skill: enforce the spool with a guard that a branch diff can see Refs #3236 Pass 117 added `tri skill add` / `fold` so two branches cannot choose the same section number. It shipped as a tool and a habit, and a habit is what already failed here -- the next pass reads what is convenient and reaches for `cat >> SKILL.md`. The collision itself is invisible on a branch: `tri skill check` passes on both sides and fails only on the merge result. The PRACTICE that causes it is not invisible. `tri skill fold` deletes one spool file for every section it appends; a direct append deletes nothing. That difference is in the diff. `tri skill spooled --gate` compares the section TITLES on base against the titles now, and allows as many new ones as there are spool files this branch deleted. By title rather than by number, because `tri skill renumber` rewrites every number and keeps every title -- a number comparison would report the whole file as new and fire hardest on the command whose job is avoiding collisions. By parsed section rather than by diff line, because a `+## N. ` line cannot be told from a heading QUOTED inside a fence, and 3 of the 518 such lines on master are quotations; miscounting one of those has already destroyed a real section here. A branch that runs `add` AND `fold` together still chooses its number against its own base, so it collides exactly like a direct append -- and it fails this guard, correctly, because the spool file it deleted was never on base. The flow the guard leaves open is: `add` on the branch, `fold` after the merge. Also writes the rule at the top of SKILL.md, where a pass looking for how to add a lesson will read it before reaching for the shell. 6 tests. Three mutants of the decision line killed (5, 3 and 3 failures). --- .claude/skills/ci-gates/SKILL.md | 28 ++++ cli/tri/src/main.rs | 19 +++ cli/tri/src/skillnum.rs | 219 +++++++++++++++++++++++++++++++ 3 files changed, 266 insertions(+) diff --git a/.claude/skills/ci-gates/SKILL.md b/.claude/skills/ci-gates/SKILL.md index 16031a32b1..25b75a6989 100644 --- a/.claude/skills/ci-gates/SKILL.md +++ b/.claude/skills/ci-gates/SKILL.md @@ -14,6 +14,34 @@ the innermost one was right. --- +## Adding a lesson to this file + +**Do not append to this file directly.** Use the spool: + +``` +tri skill add "" # writes incoming/-.md, unnumbered +``` + +then write the lesson into that file. `tri skill fold` appends every spooled lesson to +SKILL.md and assigns the numbers **at that moment**, against the file as it then stands. + +The reason is measured, not stylistic. Appending `## N.` from a branch chooses the number +against **that branch's base**. Two branches doing it merge into a duplicate number, and +that has happened twice in two passes here — after which the two *repairs* collided with +each other as well. No check on the branch can catch it: `tri skill check` passes on both +sides and fails only on the merged result, so there is nothing for a hook to look at, and +a local `renumber` before pushing still races anything that merges in between. + +A spooled lesson has a unique path and no number, so two branches write two paths and +there is nothing to conflict on. This is exactly what `docs/now/` does, and for the same +reason — its gate script records that entries used to be prepended to one file and "the +races were resolved by hand". 548 entry files later they do not conflict. + +`cat >> SKILL.md` is the habit this replaces. It is easy, it works on one branch, and it +is how every collision so far was made. + +--- + ## 1. A gate that cannot fail reads as coverage, and is worse than none `docs/BRANCH-PROTECTION.md` named five required checks. Two had a body of exactly one diff --git a/cli/tri/src/main.rs b/cli/tri/src/main.rs index 75421514f4..18d78a39a4 100644 --- a/cli/tri/src/main.rs +++ b/cli/tri/src/main.rs @@ -383,6 +383,19 @@ enum SkillAction { #[arg(long)] gate: bool, }, + /// Whether every section this branch adds came through the spool. + /// + /// The practice check for `tri skill add`. The collision it prevents is + /// invisible on a branch, but a direct append is not: `fold` deletes one + /// spool file per section it writes, and `cat >>` deletes nothing. + Spooled { + /// The branch this one is measured against. + #[arg(long, default_value = "origin/master")] + base: String, + /// Exit 1 when a section arrived without a spool file. + #[arg(long)] + gate: bool, + }, /// Every cross-reference in the skills, and whether it resolves. Refs { /// Print every reference counted, not only the ones that dangle. @@ -1001,6 +1014,12 @@ fn main() -> Result<()> { gate: *gate, })? } + SkillAction::Spooled { base, gate } => { + skillnum::run(&skillnum::SkillCmd::Spooled { + base: base.clone(), + gate: *gate, + })? + } SkillAction::Refs { list } => { skillnum::run(&skillnum::SkillCmd::Refs { list: *list })? } diff --git a/cli/tri/src/skillnum.rs b/cli/tri/src/skillnum.rs index 091bfe90ca..e55f1ca818 100644 --- a/cli/tri/src/skillnum.rs +++ b/cli/tri/src/skillnum.rs @@ -94,6 +94,21 @@ pub enum SkillCmd { #[arg(long)] gate: bool, }, + /// Whether every section this branch adds came through the spool. + /// + /// The guard `tri skill add` needs in order to survive a change of context. + /// The rule it enforces is written at the top of SKILL.md, and a rule that + /// lives only in prose is the same class of thing that already failed here: + /// the next pass reads what is convenient and reaches for `cat >>`. + Spooled { + /// The branch this one is measured against. + #[arg(long, default_value = "origin/master")] + base: String, + /// Exit 1 when a section arrived without a spool file. Off by default so + /// the command can be read before it is enforced. + #[arg(long)] + gate: bool, + }, /// Every cross-reference in the skills, and whether it resolves. Refs { /// Print every reference counted, not only the ones that dangle. @@ -171,6 +186,38 @@ pub fn sections(text: &str) -> Vec<(usize, String)> { out } +/// The sections a branch adds to SKILL.md that did NOT come through the spool. +/// +/// `tri skill add` removes a collision that no branch-side check can see: two +/// branches each append `## N.` numbered from their OWN base, both merge, and +/// the number appears twice. The COLLISION is invisible here -- but the practice +/// that causes it is not. `tri skill fold` deletes one spool file for every +/// section it appends; `cat >> SKILL.md` deletes nothing. +/// +/// Compared by TITLE, and not by number and not by diff line: +/// - `tri skill renumber` rewrites every number and keeps every title, so a +/// number-set comparison would report the whole file as new. +/// - a `+## N. ` diff line cannot be told from a heading QUOTED inside a fence. +/// 3 of the 518 `## N. ` lines on master are quotations, and miscounting one +/// of those has already cost a real section here. +/// +/// Returns the new titles and whether the branch is clean. A fold of K lessons +/// deletes K spool files, so K new titles are allowed; a direct append allows 0. +pub fn unspooled( + base: &[(usize, String)], + head: &[(usize, String)], + folded: usize, +) -> (Vec, bool) { + let was: std::collections::BTreeSet<&str> = base.iter().map(|(_, t)| t.as_str()).collect(); + let new: Vec = head + .iter() + .filter(|(_, t)| !was.contains(t.as_str())) + .map(|(_, t)| t.clone()) + .collect(); + let ok = new.len() <= folded; + (new, ok) +} + /// Every complaint about one file. Empty means the numbering holds. pub fn problems(secs: &[(usize, String)]) -> Vec { let mut bad = Vec::new(); @@ -736,8 +783,104 @@ fn fold(check: bool, skill: &str) -> Result<()> { Ok(()) } +/// Read a file at a rev, distinguishing "absent there" from "git could not run". +fn at_opt(rev: &str, path: &str, root: &std::path::Path) -> Option { + let out = std::process::Command::new("git") + .args(["show", &format!("{rev}:{path}")]) + .current_dir(root) + .output() + .ok()?; + out.status + .success() + .then(|| String::from_utf8_lossy(&out.stdout).to_string()) +} + +/// Spool files this branch deleted -- which is what folding one looks like. +fn folded_here(base: &str, dir: &str, root: &std::path::Path) -> usize { + let out = std::process::Command::new("git") + .args([ + "diff", + "--name-status", + "--diff-filter=D", + base, + "--", + &format!("{dir}/incoming/"), + ]) + .current_dir(root) + .output(); + match out { + Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).lines().count(), + _ => 0, + } +} + +fn spooled(base: &str, gate: bool) -> Result<()> { + let root = repo_root()?; + let files = skill_files(&root); + println!("base: {base} files: {}", files.len()); + let mut offenders = 0usize; + let mut checked = 0usize; + for f in &files { + let path = rel(&root, f); + let Some(before) = at_opt(base, &path, &root) else { + // A SKILL.md this branch CREATES cannot collide with a base that has + // no such file, so this is not an offence -- but say so, because a + // population that quietly shrinks is how a clean bill of health gets + // printed over nothing. + println!(" NEW {path} -- absent on {base}, nothing to collide with"); + continue; + }; + let head = std::fs::read_to_string(f).unwrap_or_default(); + let dir = std::path::Path::new(&path) + .parent() + .map(|d| d.display().to_string()) + .unwrap_or_default(); + let folded = folded_here(base, &dir, &root); + let (new, ok) = unspooled(§ions(&before), §ions(&head), folded); + checked += 1; + if new.is_empty() { + println!(" ok {path} -- adds no section"); + continue; + } + if ok { + println!( + " ok {path} -- {} new section(s), {folded} spool file(s) folded", + new.len() + ); + continue; + } + offenders += 1; + println!( + " UNSPOOLED {path} -- {} new section(s) but {folded} spool file(s) folded", + new.len() + ); + for t in new.iter().take(10) { + println!(" {t}"); + } + if new.len() > 10 { + println!(" ... and {} more", new.len() - 10); + } + } + println!("checked {checked} file(s) that exist on {base}; {offenders} unspooled"); + if offenders > 0 { + println!(); + println!("A section was appended to SKILL.md directly. Two branches doing that"); + println!("choose the same number and the duplicate appears only after the merge,"); + println!("where no branch-side check can see it. Use the spool instead:"); + println!(); + println!(" tri skill add \"\" # writes incoming/<date>-<slug>.md"); + println!(" tri skill fold # appends and numbers, on one branch"); + println!(); + if gate { + std::process::exit(1); + } + } + Ok(()) +} + pub fn run(cmd: &SkillCmd) -> Result<()> { let show_gaps = match cmd { + SkillCmd::Spooled { base, gate } => return spooled(base, *gate), SkillCmd::Refs { list } => return refs(*list), SkillCmd::Claims { list, @@ -833,6 +976,82 @@ pub fn run(cmd: &SkillCmd) -> Result<()> { #[cfg(test)] mod tests { + fn secs(v: &[(usize, &str)]) -> Vec<(usize, String)> { + v.iter().map(|(n, t)| (*n, t.to_string())).collect() + } + + #[test] + fn a_direct_append_is_unspooled() { + use super::unspooled; + let base = secs(&[(1, "one"), (2, "two")]); + let head = secs(&[(1, "one"), (2, "two"), (3, "appended by hand")]); + let (new, ok) = unspooled(&base, &head, 0); + assert_eq!(new, vec!["appended by hand".to_string()]); + assert!(!ok, "a section with no spool file behind it must not pass"); + } + + #[test] + fn a_fold_of_one_spooled_lesson_passes() { + use super::unspooled; + let base = secs(&[(1, "one")]); + let head = secs(&[(1, "one"), (2, "folded")]); + let (new, ok) = unspooled(&base, &head, 1); + assert_eq!(new.len(), 1); + assert!(ok, "one new section against one deleted spool file is a fold"); + } + + #[test] + fn a_renumber_moves_every_number_and_adds_no_work() { + use super::unspooled; + // Why the comparison is by TITLE. `tri skill renumber` rewrites every + // heading, so comparing NUMBERS would call the whole file new -- and the + // guard would fire hardest on the one command whose job is to avoid + // collisions. + let base = secs(&[(1, "one"), (2, "two"), (3, "three")]); + let head = secs(&[(7, "one"), (8, "two"), (9, "three")]); + let (new, ok) = unspooled(&base, &head, 0); + assert!(new.is_empty(), "renumber adds no title, got {new:?}"); + assert!(ok); + } + + #[test] + fn a_withdrawal_removes_and_never_fires() { + use super::unspooled; + let base = secs(&[(1, "one"), (2, "two")]); + let head = secs(&[(1, "one")]); + let (new, ok) = unspooled(&base, &head, 0); + assert!(new.is_empty()); + assert!(ok); + } + + #[test] + fn folding_two_lessons_needs_two_spool_files() { + use super::unspooled; + let base = secs(&[(1, "one")]); + let head = secs(&[(1, "one"), (2, "a"), (3, "b")]); + assert!(!unspooled(&base, &head, 1).1, "two sections, one spool file"); + assert!(unspooled(&base, &head, 2).1); + } + + #[test] + fn a_heading_quoted_in_a_fence_is_not_a_new_section() { + use super::{sections, unspooled}; + // A diff-line matcher counts this as an appended section and demands a + // spool file for it. 3 of the 518 `## N. ` lines on master have exactly + // this shape, and miscounting one has already destroyed a real section. + let base_text = "## 1. one\n\nbody\n"; + let quoted = "## 1. one\n\nbody\n\n```text\n## 2. quoted, not a section\n```\n"; + let (new, ok) = unspooled(§ions(base_text), §ions(quoted), 0); + assert!(new.is_empty(), "a quotation is not a section, got {new:?}"); + assert!(ok); + // The control, without which the assertion above passes for the wrong + // reason: the SAME line OUTSIDE a fence must be counted. + let real = format!("{base_text}\n## 2. quoted, not a section\n"); + let (new, ok) = unspooled(§ions(base_text), §ions(&real), 0); + assert_eq!(new.len(), 1, "outside a fence this IS a section"); + assert!(!ok); + } + #[test] fn a_spool_path_is_unique_per_lesson() { use super::spool_name; From 6d35d90a2b5e40ea039a341a9efaa45c63f26b6f Mon Sep 17 00:00:00 2001 From: lab <lab@example.com> Date: Sun, 6 Sep 2026 01:37:00 +0700 Subject: [PATCH 2/4] ci: run the spool guard, and refuse to pass when it cannot read the base Refs #3236 Three things, all in service of the guard actually running. 1. The gate itself, in cli-tri.yml beside `tri skill check`. That check finds a duplicate number; it cannot find the practice that makes one, because the collision is in neither branch. This asks the question a diff can answer. 2. `tri skill spooled` exits 2, not 0, when no skill file can be read on the base. This checkout is shallow, so `origin/master` does not resolve, every file reads as absent-on-base, the population empties, and a gate that never ran prints a pass. The step fetches the ref; the exit code is there for when something else stops it resolving. Control: `--base origin/no-such-branch` exits 2. 3. `skill_files()` reads the directory entry instead of joining "SKILL.md". 5 skill files are tracked and 2 are spelled `skill.md`, so the old form read 3 of 5 -- and on a case-insensitive filesystem it read the other two under a name git does not have, so `git show origin/master:<path>` failed and they looked NEW. Neither lowercase file carries a numbered heading, so the missing population is empty and no past check was wrong; the next numbered heading added to one of them would have been. `tri skill check` now reports 5 files and still exits 0. Census: `shell` run: steps 235 -> 236, the one step added above. Blessed here. Controls, all four run: - branch adding no section -> exit 0 - `## 999.` appended by hand -> UNSPOOLED, exit 1 - spool file on base, folded now -> exit 0 - the SAME two commits, base moved back before the spool file -> exit 1 The last is the sharpest: identical trees, opposite verdicts, which is the add-and-fold-in-one-branch case being refused. A branch that does both still picks its number against its own base and collides exactly like a direct append. --- .github/workflows/cli-tri.yml | 21 +++++++++++++++++++++ cli/tri/src/skillnum.rs | 24 ++++++++++++++++++++++-- tools/census/shell.txt | 4 ++-- 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/.github/workflows/cli-tri.yml b/.github/workflows/cli-tri.yml index cdcd151ea5..69e813886d 100644 --- a/.github/workflows/cli-tri.yml +++ b/.github/workflows/cli-tri.yml @@ -142,6 +142,27 @@ jobs: - name: No two skill sections share a number run: ./target/debug/tri skill check + # The check above finds a duplicate number. It cannot find the PRACTICE + # that makes one, because the collision exists in neither branch: two + # branches each append `## N.` numbered from their own base, `tri skill + # check` passes on both sides, and the duplicate appears only in the merge + # result. It happened twice in two passes, and the two repairs then raced + # each other as well. + # + # `tri skill add` / `tri skill fold` remove it by giving each lesson a + # unique path and assigning the number after the merge. That is a habit, + # and a habit is exactly what failed here the first time -- so this asks + # the question a diff CAN answer: did every section this branch adds come + # from a spool file that was on the base? + # + # The fetch is not decoration. This checkout is shallow, so `origin/master` + # does not resolve, every file would read as absent-on-base, the population + # would empty, and a gate that never ran would print a pass. + - name: Every new skill section came through the spool + run: | + git fetch --depth=1 origin master + ./target/debug/tri skill spooled --base origin/master --gate + # A census that moves silently is a number nobody re-read. Measured over # the 39 most recent transitions on master with one fixed instrument: # 8 commits moved a census and only 4 said so -- and one of the silent diff --git a/cli/tri/src/skillnum.rs b/cli/tri/src/skillnum.rs index e55f1ca818..ec805d0dd6 100644 --- a/cli/tri/src/skillnum.rs +++ b/cli/tri/src/skillnum.rs @@ -862,6 +862,20 @@ fn spooled(base: &str, gate: bool) -> Result<()> { } } println!("checked {checked} file(s) that exist on {base}; {offenders} unspooled"); + // A shallow checkout has no `origin/master`, so every file reads NEW, the + // population empties, and a gate that never ran prints a pass. That is the + // failure this whole skill is about, so it is spelled COULD NOT RUN and + // given its own exit code rather than folded into the clean one. + if checked == 0 && !files.is_empty() { + println!(); + println!("COULD NOT RUN: no skill file could be read on `{base}`."); + println!(" The ref is probably missing -- a shallow clone has no remote-tracking"); + println!(" branches. Fetch it first: git fetch --depth=1 origin master"); + if gate { + std::process::exit(2); + } + return Ok(()); + } if offenders > 0 { println!(); println!("A section was appended to SKILL.md directly. Two branches doing that"); @@ -997,7 +1011,10 @@ mod tests { let head = secs(&[(1, "one"), (2, "folded")]); let (new, ok) = unspooled(&base, &head, 1); assert_eq!(new.len(), 1); - assert!(ok, "one new section against one deleted spool file is a fold"); + assert!( + ok, + "one new section against one deleted spool file is a fold" + ); } #[test] @@ -1029,7 +1046,10 @@ mod tests { use super::unspooled; let base = secs(&[(1, "one")]); let head = secs(&[(1, "one"), (2, "a"), (3, "b")]); - assert!(!unspooled(&base, &head, 1).1, "two sections, one spool file"); + assert!( + !unspooled(&base, &head, 1).1, + "two sections, one spool file" + ); assert!(unspooled(&base, &head, 2).1); } diff --git a/tools/census/shell.txt b/tools/census/shell.txt index 335cb69fbc..98cbbfdbc6 100644 --- a/tools/census/shell.txt +++ b/tools/census/shell.txt @@ -2,10 +2,10 @@ WHICH INTERPRETER EACH GATE STEP IS HANDED TO, AND WHO SAYS SO workflow files read 50 jobs 71 - run: steps 235 + run: steps 236 who names the shell: - the runner does 214 no container, so bash -eo pipefail + the runner does 215 no container, so bash -eo pipefail a `shell:` key does 0 NOBODY 21 a container and no `shell:` key From 3750a934df76c8f6fd7097430c7c61d4b1332e06 Mon Sep 17 00:00:00 2001 From: lab <lab@example.com> Date: Sun, 6 Sep 2026 01:37:27 +0700 Subject: [PATCH 3/4] docs(now): the spool guard Refs #3236 --- ...l-is-now-the-way-and-a-diff-can-see-when-it-is-not.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 docs/now/2026-09-06-the-spool-is-now-the-way-and-a-diff-can-see-when-it-is-not.md diff --git a/docs/now/2026-09-06-the-spool-is-now-the-way-and-a-diff-can-see-when-it-is-not.md b/docs/now/2026-09-06-the-spool-is-now-the-way-and-a-diff-can-see-when-it-is-not.md new file mode 100644 index 0000000000..da0a7ba273 --- /dev/null +++ b/docs/now/2026-09-06-the-spool-is-now-the-way-and-a-diff-can-see-when-it-is-not.md @@ -0,0 +1,9 @@ +# NOW -- the spool is now the way, and a diff can see when it is not (2026-09-06) + +## the spool is now the way, and a diff can see when it is not (Refs #3236) + +- Pass 117 shipped tri skill add / fold so two branches cannot pick the same section number. It shipped as a tool and a habit, and a habit is what failed here first: the next pass reaches for cat >> SKILL.md. +- The rule is now at the top of SKILL.md, and it is checked. The collision is invisible on a branch -- skill check passes on both sides and fails only on the merge -- but fold deletes one spool file per section it appends and a direct append deletes nothing, and that is in the diff. +- tri skill spooled compares section TITLES on base against titles now. By title because renumber rewrites every number and keeps every title; by parsed section because a +## N. line cannot be told from a heading quoted inside a fence, and 3 of the 518 such lines on master are quotations. +- The gate could not have run in CI: the checkout is shallow, origin/master does not resolve, every file reads absent-on-base, and a gate that never ran prints a pass. The step fetches the ref and the command exits 2 COULD NOT RUN instead of 0. +- skill_files() read 3 of 5 tracked skill files -- 2 are spelled skill.md, and on a case-insensitive filesystem it found them under a name git does not have. Both carry zero numbered headings, so the missing population is empty and no past check was wrong. From bf0b49ed9d0dafda3ae591a7daa36c3d6372f288 Mon Sep 17 00:00:00 2001 From: lab <lab@example.com> Date: Sun, 6 Sep 2026 01:54:57 +0700 Subject: [PATCH 4/4] skill: restore the skill_files fix this branch claimed but did not carry Refs #3236 The PR body described `skill_files()` reading the directory entry instead of joining "SKILL.md". The code was written, verified, and then destroyed before it was committed -- by a control in this same branch. The control needed two commits to test a fold against a base. It built them with `git add -A`, which swept in the uncommitted `skill_files` change, and cleaned up with `git reset --hard $BASE~1`, which threw that commit away. The controls all passed and the fix was gone. Only `git cat-file` on the pushed head found it: the description said one thing and the tree said another. The change itself, again: 5 skill files are tracked and 2 are spelled `skill.md`, so joining the uppercase name reads 3 of 5 -- and on a case-insensitive filesystem `join("SKILL.md").is_file()` is TRUE for a file actually named `skill.md`, so the path pushed is one git has never heard of, `git show origin/master:<path>` fails, and a tracked file reads as NEW. That is the quiet direction to be wrong in. Neither lowercase file carries a numbered heading, so the population that was missing is empty and no past check gave a wrong answer. That is luck, not design. Controls re-run on the restored code: all 5 paths the guard names now resolve in git (previously 2 did not), `skill check` 0, `skill refs` 0, `skill spooled --gate` 0, 795 tests 0 failed. --- cli/tri/src/skillnum.rs | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/cli/tri/src/skillnum.rs b/cli/tri/src/skillnum.rs index ec805d0dd6..9589e544ff 100644 --- a/cli/tri/src/skillnum.rs +++ b/cli/tri/src/skillnum.rs @@ -273,9 +273,26 @@ fn skill_files(root: &std::path::Path) -> Vec<PathBuf> { return out; }; for e in rd.flatten() { - let p = e.path().join("SKILL.md"); - if p.is_file() { - out.push(p); + // Read the entry that is THERE rather than joining a name and asking + // whether it exists. 5 skill files are tracked here and 2 are spelled + // `skill.md`, so joining the uppercase name reads 3 of 5 -- and on a + // case-insensitive filesystem `join("SKILL.md").is_file()` is true for a + // file actually called `skill.md`, handing back a path git has never + // heard of. `git show origin/master:<that>` then fails and a tracked + // file reads as NEW, which is the quiet direction to be wrong in. + // + // Neither lowercase file carries a numbered heading today, so the + // population that was missing is empty and no past check gave a wrong + // answer. That is luck: the next numbered heading added to one of them + // would have been unguarded. + let Ok(inner) = std::fs::read_dir(e.path()) else { + continue; + }; + for f in inner.flatten() { + if f.file_name().eq_ignore_ascii_case("SKILL.md") && f.path().is_file() { + out.push(f.path()); + break; + } } } out.sort();