Skip to main content

speccoder_runtime/format/speckit/
bug.rs

1//! Bugs in Spec Kit's layout. With the first-party `bug` extension
2//! installed and enabled, a bug is the extension's own
3//! `.specify/bugs/<slug>/assessment.md`, written by its assess skill —
4//! speccoder writes nothing there and only reads the person's words back.
5//! Without it, the standing unit `NNN-bugfixes`: each bug a user story
6//! whose acceptance scenarios are the triad, an approved requirement in
7//! triad form and a task citing it, appended.
8//!
9//! The parse of `assessment.md` is pure (no model, no tool), so a verifier
10//! reads a bug's triad from a commit's tree.
11
12use std::path::Path;
13
14use speccoder_core::bug::{BugHome, HomeKind, Triad, normalise, valid_slug};
15
16use super::quick::highest;
17use crate::format::{BugRefusal, BugStep, ReadBug, RecordedBug, Rules};
18
19/// The standing unit's name, after its number.
20const NAME: &str = "bugfixes";
21/// Where the extension keeps bugs.
22const BUGS: &str = ".specify/bugs";
23/// The extension's id.
24const EXTENSION: &str = "bug";
25/// Its assessment file.
26const ASSESSMENT: &str = "assessment.md";
27
28/// What the extension's assess skill may write: only bug directories (a
29/// second slug is caught after the run).
30pub static ASSESS_RULES: Rules = Rules {
31    intent: super::texts::RULES.intent,
32    intent_reason: super::texts::RULES.intent_reason,
33    authoring_path: |p| p.starts_with(".specify/bugs/") && !p.contains(".."),
34    command: |_, _| None,
35    env: &[],
36    found_env: super::python::command_env,
37    authoring_refusal: |p| {
38        format!(
39            "the `bug` extension's assess step writes only its bug's directory (.specify/bugs/<slug>/); `{p}` is not in it"
40        )
41    },
42    judged: super::texts::judged,
43    plan: super::texts::RULES.plan,
44};
45
46/// The format's own bug step: the extension's assess skill, given the
47/// person's labelled triad verbatim and the slug.
48pub(super) const STEP: BugStep = BugStep {
49    skill: "bug-assess",
50    arguments: |triad, slug| format!("{}slug={slug}", triad.labelled()),
51    rules: &ASSESS_RULES,
52};
53
54/// Where a bug lives here, with why.
55pub(super) fn unit(root: &Path) -> Result<BugHome, BugRefusal> {
56    let inventory = super::extensions::inventory(root);
57    let listed = inventory
58        .as_ref()
59        .and_then(|i| i.entries.iter().find(|e| e.id == EXTENSION));
60    let from = |i: &crate::format::Inventory| match i.source {
61        crate::format::InventorySource::Cli => "`specify extension list`",
62        crate::format::InventorySource::Files => ".specify/extensions/.registry",
63    };
64    if let (Some(e), Some(i)) = (listed, inventory.as_ref())
65        && e.enabled
66    {
67        return Ok(BugHome {
68            kind: HomeKind::NativeSpeckitBug,
69            location: BUGS.to_owned(),
70            reason: format!(
71                "Spec Kit's `bug` extension {} is installed and enabled ({}): the bug is the extension's own",
72                e.version,
73                from(i)
74            ),
75        });
76    }
77    let reason = match (listed, inventory.as_ref()) {
78        (Some(e), Some(i)) => format!(
79            "Spec Kit's `bug` extension {} is installed but disabled ({}): the standing bug-fix unit",
80            e.version,
81            from(i)
82        ),
83        _ => "Spec Kit without the `bug` extension: the standing bug-fix unit".to_owned(),
84    };
85    Ok(BugHome {
86        kind: HomeKind::StandingSpeckit,
87        location: format!(
88            "{}/{}",
89            speccoder_speckit::features::SPECS,
90            standing_name(root)
91        ),
92        reason,
93    })
94}
95
96/// The standing unit's directory name: the existing one, else the next number.
97fn standing_name(root: &Path) -> String {
98    let specs = root.join(speccoder_speckit::features::SPECS);
99    let names: Vec<String> = std::fs::read_dir(&specs)
100        .into_iter()
101        .flatten()
102        .flatten()
103        .map(|e| e.file_name().to_string_lossy().into_owned())
104        .collect();
105    if let Some(n) = names.iter().find(|n| {
106        n.ends_with(&format!("-{NAME}")) && speccoder_speckit::features::prefix(n).is_some()
107    }) {
108        return n.clone();
109    }
110    let next = names
111        .iter()
112        .filter_map(|n| {
113            let p = speccoder_speckit::features::prefix(n)?.to_owned();
114            (p.len() <= 4).then(|| p.parse::<u32>().ok()).flatten()
115        })
116        .max()
117        .unwrap_or(0)
118        + 1;
119    format!("{next:03}-{NAME}")
120}
121
122/// The extension's directory and unit name for `slug` (`None`: not this
123/// layout, or not a slug — never a path).
124pub(super) fn dir(home: &BugHome, slug: &str) -> Option<(String, String)> {
125    (home.kind == HomeKind::NativeSpeckitBug && valid_slug(slug))
126        .then(|| (format!("{BUGS}/{slug}"), format!("bugs/{slug}")))
127}
128
129/// Whether `path` (or its directory chain under `root`) is a symbolic link:
130/// a bug directory that is one could point anywhere.
131fn linked(root: &Path, rel: &[&str]) -> bool {
132    let mut p = root.to_path_buf();
133    rel.iter().any(|part| {
134        p.push(part);
135        std::fs::symlink_metadata(&p).is_ok_and(|m| m.file_type().is_symlink())
136    })
137}
138
139/// A slug for `title`: two to four kebab-case words, made unique.
140pub(super) fn slug(root: &Path, title: &str) -> String {
141    let words: Vec<String> = title
142        .split(|c: char| !c.is_ascii_alphanumeric())
143        .filter(|w| !w.is_empty())
144        .map(str::to_ascii_lowercase)
145        .take(4)
146        .collect();
147    let base = if words.is_empty() {
148        "bug".to_owned()
149    } else {
150        words.join("-")
151    };
152    let taken = |s: &str| root.join(BUGS).join(s).exists();
153    if !taken(&base) {
154        return base;
155    }
156    (2..)
157        .map(|n| format!("{base}-{n}"))
158        .find(|s| !taken(s))
159        .unwrap_or(base)
160}
161
162/// Records `triad` in the standing unit, creating it the first time.
163pub(super) fn record(
164    root: &Path,
165    home: &BugHome,
166    triad: &Triad,
167) -> Result<RecordedBug, BugRefusal> {
168    if home.kind != HomeKind::StandingSpeckit {
169        return Err(BugRefusal::new(
170            "BUG-NO-UNIT",
171            "the bug lives in the `bug` extension's own layout: its assess step records it",
172        ));
173    }
174    let io = |e: String| BugRefusal::new("INTERNAL", e);
175    let name = home
176        .location
177        .rsplit('/')
178        .next()
179        .unwrap_or_default()
180        .to_owned();
181    let dir = root.join(&home.location);
182    std::fs::create_dir_all(&dir).map_err(|e| io(format!("{}: {e}", dir.display())))?;
183    let spec_path = dir.join("spec.md");
184    let tasks_path = dir.join("tasks.md");
185    let mut spec = std::fs::read_to_string(&spec_path).unwrap_or_else(|_| {
186        format!(
187            "# Feature Specification: Bug Fixes\n\n**Feature Branch**: `{name}`\n**Status**: Ongoing\n**Input**: Bugs reported one at a time (`speccoder bug`). Each is a user story whose acceptance scenarios are the reporter's words — Current, Expected, Unchanged — with its own requirement and task, reproduced before it is fixed.\n\n## User Scenarios & Testing\n"
188        )
189    });
190    let mut tasks =
191        std::fs::read_to_string(&tasks_path).unwrap_or_else(|_| "# Tasks: Bug Fixes\n".to_owned());
192    let story = highest(&spec, "### User Story ") + 1;
193    let fr = highest(&spec, "**FR-") + 1;
194    let task = highest(&tasks, "- [ ] T")
195        .max(highest(&tasks, "- [x] T"))
196        .max(highest(&tasks, "- [X] T"))
197        + 1;
198    let t = triad.title();
199    let requirement = format!("FR-{fr:03}");
200    let mut block = format!(
201        "\n### User Story {story} - {t} (Priority: P1)\n\nA bug, in the reporter's words.\n\n**Acceptance Scenarios**:\n\n1. **Current**: {}\n",
202        triad.current.text
203    );
204    for (n, s) in (2..).zip(triad.expected.iter().chain(&triad.unchanged)) {
205        let label = if s.id.starts_with('E') {
206            "Expected"
207        } else {
208            "Unchanged"
209        };
210        block.push_str(&format!("{n}. **{label}**: {}\n", s.text));
211    }
212    let mut line = format!("Current: {}.", triad.current.text.trim_end_matches('.'));
213    for e in &triad.expected {
214        line.push_str(&format!(" Expected: {}.", e.text.trim_end_matches('.')));
215    }
216    for u in &triad.unchanged {
217        line.push_str(&format!(" Unchanged: {}.", u.text.trim_end_matches('.')));
218    }
219    block.push_str(&format!("\n- **{requirement}**: {line}\n"));
220    if !spec.ends_with('\n') {
221        spec.push('\n');
222    }
223    spec.push_str(&block);
224    let task_id = format!("T{task:03}");
225    if !tasks.ends_with('\n') {
226        tasks.push('\n');
227    }
228    tasks.push_str(&format!(
229        "\n## Phase {story}: User Story {story} - {t} (Priority: P1)\n\n- [ ] {task_id} [US{story}] Fix: {t} per {requirement}\n"
230    ));
231    // The person's words read back from what is about to be written,
232    // exactly (no markup of theirs stripped), or nothing is written.
233    let back = standing_triad(&spec, &requirement).map_err(|_| unfaithful(&requirement))?;
234    if !same_words(&back, triad) {
235        return Err(unfaithful(&requirement));
236    }
237    // Appends only: what was there is an exact prefix of what is written,
238    // checked again right before writing (principle I).
239    for (path, new) in [(&spec_path, &spec), (&tasks_path, &tasks)] {
240        if let Ok(now) = std::fs::read_to_string(path)
241            && !new.starts_with(now.trim_end_matches('\n'))
242        {
243            return Err(BugRefusal::new(
244                "INTERNAL",
245                format!(
246                    "{} changed while the bug was being recorded; try again",
247                    path.display()
248                ),
249            ));
250        }
251    }
252    std::fs::write(&spec_path, spec).map_err(|e| io(format!("{}: {e}", spec_path.display())))?;
253    std::fs::write(&tasks_path, tasks).map_err(|e| io(format!("{}: {e}", tasks_path.display())))?;
254    Ok(RecordedBug {
255        unit: name,
256        dir: home.location.clone(),
257        requirement,
258        task: task_id,
259        title: t,
260        files: vec![
261            format!("{}/spec.md", home.location),
262            format!("{}/tasks.md", home.location),
263        ],
264    })
265}
266
267/// The refusal of a triad whose words would not read back as given.
268pub(crate) fn unfaithful(requirement: &str) -> BugRefusal {
269    BugRefusal::new(
270        "BUG-ASSESSMENT-DRIFT",
271        format!(
272            "{requirement} would not read back exactly your words (a statement holds what the file's own markup means); nothing is written — reword it"
273        ),
274    )
275}
276
277/// `read` carries exactly `triad`'s statements, in order.
278pub(crate) fn same_words(read: &ReadBug, triad: &Triad) -> bool {
279    read.current == [triad.current.text.clone()]
280        && read.expected
281            == triad
282                .expected
283                .iter()
284                .map(|s| s.text.clone())
285                .collect::<Vec<_>>()
286        && read.unchanged
287            == triad
288                .unchanged
289                .iter()
290                .map(|s| s.text.clone())
291                .collect::<Vec<_>>()
292}
293
294/// The triad as the home holds it: a native bug by slug, a standing one by
295/// requirement.
296pub(super) fn read(root: &Path, home: &BugHome, key: &str) -> Result<ReadBug, BugRefusal> {
297    match home.kind {
298        HomeKind::NativeSpeckitBug => {
299            if !valid_slug(key) {
300                return Err(BugRefusal::new(
301                    "BUG-ASSESSMENT-UNREADABLE",
302                    format!("`{key}` is not a bug slug"),
303                ));
304            }
305            if linked(root, &[".specify", "bugs", key, ASSESSMENT]) {
306                return Err(BugRefusal::new(
307                    "BUG-ASSESSMENT-UNREADABLE",
308                    format!("{BUGS}/{key} is reached through a symbolic link: it is not read"),
309                ));
310            }
311            let file = root.join(BUGS).join(key).join(ASSESSMENT);
312            let text = std::fs::read_to_string(&file).map_err(|_| {
313                BugRefusal::new(
314                    "BUG-ASSESSMENT-UNREADABLE",
315                    format!("{BUGS}/{key}/{ASSESSMENT} is missing"),
316                )
317            })?;
318            parse_assessment(&text).map(|mut r| {
319                r.file = format!("{BUGS}/{key}/{ASSESSMENT}");
320                r
321            })
322        }
323        _ => {
324            let text = std::fs::read_to_string(root.join(&home.location).join("spec.md")).map_err(
325                |_| {
326                    BugRefusal::new(
327                        "BUG-NO-UNIT",
328                        format!("{}/spec.md is missing", home.location),
329                    )
330                },
331            )?;
332            standing_triad(&text, key).map(|mut r| {
333                r.file = format!("{}/spec.md", home.location);
334                r
335            })
336        }
337    }
338}
339
340/// The labelled statement a line holds: `Current: …`, `**Expected**: …`,
341/// `> Unchanged: …`, `1. **Current**: …`. The list or quote marker and the
342/// label are matched exactly at the start; the words after the label are
343/// the person's, kept byte for byte (whitespace normalised) — markup in
344/// them is theirs, never stripped.
345fn labelled(line: &str) -> Option<(&'static str, String)> {
346    let mut l = line.trim_start();
347    loop {
348        let before = l;
349        if let Some(r) = l.strip_prefix('>') {
350            l = r.trim_start();
351        } else if let Some(r) = ["- ", "* ", "+ "].iter().find_map(|m| l.strip_prefix(m)) {
352            l = r.trim_start();
353        } else if let Some((n, rest)) = l.split_once(". ")
354            && !n.is_empty()
355            && n.chars().all(|c| c.is_ascii_digit())
356        {
357            l = rest.trim_start();
358        }
359        if l == before {
360            break;
361        }
362    }
363    for label in ["current", "expected", "unchanged"] {
364        for (open, close) in [("**", "**:"), ("**", ":**"), ("", ":")] {
365            let Some(rest) = l.strip_prefix(open) else {
366                continue;
367            };
368            let Some(word) = rest.get(..label.len()) else {
369                continue;
370            };
371            if !word.eq_ignore_ascii_case(label) {
372                continue;
373            }
374            if let Some(text) = rest[label.len()..].strip_prefix(close) {
375                let text = normalise(text);
376                return (!text.is_empty()).then_some((label, text));
377            }
378        }
379    }
380    None
381}
382
383fn collect(lines: impl Iterator<Item = String>) -> ReadBug {
384    let mut r = ReadBug {
385        current: Vec::new(),
386        expected: Vec::new(),
387        unchanged: Vec::new(),
388        title: None,
389        file: String::new(),
390    };
391    for line in lines {
392        if let Some((label, text)) = labelled(&line) {
393            match label {
394                "current" => r.current.push(text),
395                "expected" => r.expected.push(text),
396                _ => r.unchanged.push(text),
397            }
398        }
399    }
400    r
401}
402
403/// `assessment.md` in the extension's layout (1.0.1): a `# Bug Assessment:`
404/// title, a `## Report` and a `## Symptom` section, and the triad as
405/// labelled lines. Any other shape is refused, never guessed at.
406///
407/// # Errors
408///
409/// `BUG-ASSESSMENT-UNREADABLE`: not that layout, or no Current and
410/// Expected lines.
411pub fn parse_assessment(text: &str) -> Result<ReadBug, BugRefusal> {
412    let unreadable = |why: &str| {
413        BugRefusal::new(
414            "BUG-ASSESSMENT-UNREADABLE",
415            format!("{ASSESSMENT} is not in the `bug` extension's layout speccoder reads: {why}"),
416        )
417    };
418    let title = text
419        .lines()
420        .find_map(|l| l.strip_prefix("# Bug Assessment:"))
421        .map(|t| t.trim().to_owned())
422        .ok_or_else(|| unreadable("no `# Bug Assessment:` title"))?;
423    let heading = |h: &str| text.lines().any(|l| l.trim_start().starts_with(h));
424    if !heading("## Report") || !heading("## Symptom") {
425        return Err(unreadable("no `## Report` and `## Symptom` sections"));
426    }
427    // The person's words are in the Report section (quoted or listed).
428    let report: Vec<String> = text
429        .lines()
430        .skip_while(|l| !l.trim_start().starts_with("## Report"))
431        .skip(1)
432        .take_while(|l| !l.trim_start().starts_with("## "))
433        .map(str::to_owned)
434        .collect();
435    let mut r = collect(report.into_iter());
436    r.title = Some(title);
437    if r.current.is_empty() || r.expected.is_empty() {
438        return Err(unreadable(
439            "its Report section has no `Current:` and `Expected:` lines",
440        ));
441    }
442    Ok(r)
443}
444
445/// A standing unit's story for `requirement`: its labelled scenarios.
446fn standing_triad(spec: &str, requirement: &str) -> Result<ReadBug, BugRefusal> {
447    let mut story: Vec<&str> = Vec::new();
448    let mut found = None;
449    for line in spec.lines() {
450        if line.starts_with("### User Story ") {
451            if found.is_some() {
452                break;
453            }
454            story.clear();
455        }
456        story.push(line);
457        if line.contains(&format!("**{requirement}**")) {
458            found = Some(story.clone());
459        }
460    }
461    let lines = found.ok_or_else(|| {
462        BugRefusal::new(
463            "BUG-NO-UNIT",
464            format!("no requirement {requirement} in the bug-fix unit"),
465        )
466    })?;
467    // The scenarios only: the requirement line repeats them in a sentence.
468    let r = collect(
469        lines
470            .iter()
471            .filter(|l| !l.contains(&format!("**{requirement}**")))
472            .map(|l| (*l).to_owned()),
473    );
474    if r.current.is_empty() || r.expected.is_empty() {
475        return Err(BugRefusal::new(
476            "BUG-ASSESSMENT-UNREADABLE",
477            format!("{requirement}'s story has no Current and Expected scenarios"),
478        ));
479    }
480    Ok(r)
481}
482
483#[cfg(test)]
484mod tests {
485    use super::*;
486
487    const FIXTURE: &str = include_str!(
488        "../../../../speccoder-speckit/tests/fixtures/compat/1.1.3-bug-ext/.specify/bugs/empty-title-crash/assessment.md"
489    );
490
491    #[test]
492    fn the_fixture_assessment_reads_back_the_triad() {
493        let r = parse_assessment(FIXTURE).unwrap_or_else(|e| panic!("{e:?}"));
494        assert_eq!(r.current, ["saving an empty title crashes"]);
495        assert_eq!(r.expected, ["an empty title is refused with a message"]);
496        assert_eq!(r.unchanged, ["a non-empty title is saved as typed"]);
497        assert_eq!(r.title.as_deref(), Some("Saving an empty title crashes"));
498    }
499
500    #[test]
501    fn another_shape_is_refused_not_guessed() {
502        let e = parse_assessment("# Notes\n\nCurrent: x\nExpected: y\n").err();
503        assert_eq!(e.map(|e| e.code), Some("BUG-ASSESSMENT-UNREADABLE"));
504        let no_lines = FIXTURE.replace("> Expected:", "> Hoped:");
505        assert_eq!(
506            parse_assessment(&no_lines).err().map(|e| e.code),
507            Some("BUG-ASSESSMENT-UNREADABLE")
508        );
509    }
510
511    #[test]
512    fn labels_are_read_in_every_list_form() {
513        for l in [
514            "Current: a  b",
515            "> Current: a b",
516            "- **Current**: a b",
517            "1. **Current**: a b",
518            "  * current: a b",
519        ] {
520            assert_eq!(labelled(l), Some(("current", "a b".to_owned())), "{l}");
521        }
522        assert_eq!(labelled("Severity: high"), None);
523        // The person's own markup stays theirs.
524        assert_eq!(
525            labelled("2. **Expected**: passing **kwargs drops them"),
526            Some(("expected", "passing **kwargs drops them".to_owned()))
527        );
528        assert_eq!(
529            labelled("> Expected: `*args` and **kwargs**: kept"),
530            Some(("expected", "`*args` and **kwargs**: kept".to_owned()))
531        );
532        assert_eq!(
533            labelled("**Current:** x"),
534            Some(("current", "x".to_owned()))
535        );
536    }
537
538    proptest::proptest! {
539        #[test]
540        fn the_standing_unit_reads_back_exactly_what_it_records(
541            current in "[ -~]{1,40}",
542            expected in proptest::collection::vec("[ -~]{1,40}", 1..3),
543            unchanged in proptest::collection::vec("[ -~]{1,40}", 0..2),
544        ) {
545            let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
546            let Ok(triad) = Triad::new(&current, &expected, &unchanged, crate::integrity::sha256_hex) else {
547                return Ok(());
548            };
549            let home = BugHome {
550                kind: HomeKind::StandingSpeckit,
551                location: "specs/001-bugfixes".to_owned(),
552                reason: String::new(),
553            };
554            // Recorded words read back exactly, or nothing is written.
555            match record(dir.path(), &home, &triad) {
556                Ok(rec) => {
557                    let back = read(dir.path(), &home, &rec.requirement).unwrap_or_else(|e| panic!("{e:?}"));
558                    proptest::prop_assert!(same_words(&back, &triad), "{back:?} vs {triad:?}");
559                }
560                Err(e) => {
561                    proptest::prop_assert_eq!(e.code, "BUG-ASSESSMENT-DRIFT");
562                    proptest::prop_assert!(!dir.path().join("specs/001-bugfixes/spec.md").exists());
563                }
564            }
565        }
566    }
567
568    #[test]
569    #[cfg(unix)]
570    fn a_slug_never_reaches_outside_the_bugs_directory() {
571        let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
572        let home = BugHome {
573            kind: HomeKind::NativeSpeckitBug,
574            location: BUGS.to_owned(),
575            reason: String::new(),
576        };
577        std::fs::create_dir_all(tmp.path().join("elsewhere")).unwrap_or_else(|e| panic!("{e}"));
578        std::fs::write(tmp.path().join("elsewhere/assessment.md"), FIXTURE)
579            .unwrap_or_else(|e| panic!("{e}"));
580        assert_eq!(dir(&home, "../../elsewhere"), None);
581        assert_eq!(
582            read(tmp.path(), &home, "../../elsewhere")
583                .err()
584                .map(|e| e.code),
585            Some("BUG-ASSESSMENT-UNREADABLE")
586        );
587        // A bug directory that is a symbolic link is not read.
588        std::fs::create_dir_all(tmp.path().join(BUGS)).unwrap_or_else(|e| panic!("{e}"));
589        std::os::unix::fs::symlink(
590            tmp.path().join("elsewhere"),
591            tmp.path().join(BUGS).join("linked"),
592        )
593        .unwrap_or_else(|e| panic!("{e}"));
594        assert_eq!(
595            read(tmp.path(), &home, "linked").err().map(|e| e.code),
596            Some("BUG-ASSESSMENT-UNREADABLE")
597        );
598    }
599
600    #[test]
601    fn slugs_are_kebab_case_and_unique() {
602        let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
603        assert_eq!(
604            slug(dir.path(), "Saving an empty title crashes"),
605            "saving-an-empty-title"
606        );
607        std::fs::create_dir_all(dir.path().join(".specify/bugs/saving-an-empty-title"))
608            .unwrap_or_else(|e| panic!("{e}"));
609        assert_eq!(
610            slug(dir.path(), "Saving an empty title crashes"),
611            "saving-an-empty-title-2"
612        );
613    }
614}
615
616#[cfg(test)]
617mod policy_tests {
618    #[test]
619    fn the_assess_step_writes_its_bug_directory_only() {
620        let p = crate::tools::policy::Policy::new(&[], &[])
621            .rules(&super::ASSESS_RULES)
622            .authoring();
623        let d = |path: &str| {
624            p.classify(
625                "write_file",
626                &serde_json::json!({ "path": path, "content": "x" }),
627                None,
628            )
629        };
630        assert_eq!(
631            d(".specify/bugs/x/assessment.md"),
632            crate::tools::policy::Decision::Allow
633        );
634        assert_ne!(d("src/lib.rs"), crate::tools::policy::Decision::Allow);
635        assert_ne!(
636            d("specs/001-x/spec.md"),
637            crate::tools::policy::Decision::Allow
638        );
639    }
640}