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