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