1	//! File scanning and tag extraction.
2	//!
3	//! Walks the configured scan roots and recognizes the bracketed stage/req-id
4	//! tokens defined by the spec. For Rust, C/C++, and Python the scanner only
5	//! considers comment regions (non-comment source text is ignored); Markdown
6	//! is scanned on every physical line. JavaScript/TypeScript use the same slash
7	//! comment rules as Rust and C/C++. See SPEC.md §Tags.
8	//!
9	//! [doc->REQ-SCAN-LANGS]
10	//! [doc->REQ-SCAN-JS-TS]
11	//! [doc->REQ-TAG-GRAMMAR-PARSE]
12	//! [doc->REQ-TAG-GRAMMAR-REJECT]
13	//! [doc->REQ-MARKDOWN-TAG-CANDIDATE]
14	//! [doc->REQ-COMMENT-SCOPE]
15	//! [doc->REQ-PY-DOCSTRING-SCAN]
16	//! [doc->REQ-BLOCK-COMMENT]
17
18	use std::collections::{BTreeSet, HashSet};
19	use std::path::{Path, PathBuf};
20	use walkdir::WalkDir;
21
22	use crate::manifest::STAGE_NAMES;
23
24	#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25	pub enum Lang {
26	    Rust,
27	    Cpp,
28	    JsTs,
29	    Python,
30	    Markdown,
31	    Cmake,
32	    Yaml,
33	}
34
35	// [impl->REQ-SCAN-LANGS]
36	// [impl->REQ-SCAN-JS-TS]
37	fn lang_for(path: &Path) -> Option<Lang> {
38	    if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
39	        if name == "CMakeLists.txt" {
40	            return Some(Lang::Cmake);
41	        }
42	    }
43	    let ext = path.extension()?.to_str()?.to_ascii_lowercase();
44	    Some(match ext.as_str() {
45	        "rs" => Lang::Rust,
46	        "c" | "cc" | "cpp" | "cxx" | "c++" | "h" | "hh" | "hpp" | "hxx" | "h++" => Lang::Cpp,
47	        "js" | "jsx" | "mjs" | "cjs" | "ts" | "tsx" | "mts" | "cts" => Lang::JsTs,
48	        "py" => Lang::Python,
49	        "md" | "markdown" => Lang::Markdown,
50	        "cmake" => Lang::Cmake,
51	        "yml" | "yaml" => Lang::Yaml,
52	        _ => return None,
53	    })
54	}
55
56	#[derive(Debug, Clone)]
57	pub struct TagEvidence {
58	    pub stage: String,
59	    pub req_id: String,
60	    pub path: String,
61	    pub line: usize,
62	}
63
64	#[derive(Debug, Clone)]
65	pub struct ParseErrorTag {
66	    pub path: String,
67	    pub line: usize,
68	}
69
70	#[derive(Debug, Clone)]
71	pub struct ScanError {
72	    pub path: String,
73	    pub message: String,
74	}
75
76	#[derive(Debug, Default)]
77	pub struct ScanOutput {
78	    pub tags: Vec<TagEvidence>,
79	    pub parse_errors: Vec<ParseErrorTag>,
80	    pub scan_errors: Vec<ScanError>,
81	}
82
83	pub fn scan_roots(
84	    repo_root: &Path,
85	    roots: &[PathBuf],
86	    allowed_stages: &HashSet<String>,
87	) -> ScanOutput {
88	    let mut output = ScanOutput::default();
89	    for root in roots {
90	        let abs_root = repo_root.join(root);
91	        for entry in WalkDir::new(&abs_root).follow_links(false) {
92	            let entry = match entry {
93	                Ok(e) => e,
94	                Err(err) => {
95	                    let path = err
96	                        .path()
97	                        .map(|p| repo_relative(repo_root, p))
98	                        .unwrap_or_else(|| root.display().to_string());
99	                    output.scan_errors.push(ScanError {
100	                        path,
101	                        message: err.to_string(),
102	                    });
103	                    continue;
104	                }
105	            };
106	            if !entry.file_type().is_file() {
107	                continue;
108	            }
109	            let lang = match lang_for(entry.path()) {
110	                Some(l) => l,
111	                None => continue,
112	            };
113	            let rel = repo_relative(repo_root, entry.path());
114	            match std::fs::read_to_string(entry.path()) {
115	                Ok(text) => scan_text(&text, lang, &rel, allowed_stages, &mut output),
116	                Err(err) => output.scan_errors.push(ScanError {
117	                    path: rel,
118	                    message: err.to_string(),
119	                }),
120	            }
121	        }
122	    }
123	    output
124	}
125
126	/// Convenience helper for tests and callers that only care about built-in stages.
127	pub fn builtin_stage_set() -> HashSet<String> {
128	    STAGE_NAMES.iter().map(|s| s.to_string()).collect()
129	}
130
131	fn repo_relative(repo_root: &Path, path: &Path) -> String {
132	    let stripped = path.strip_prefix(repo_root).unwrap_or(path);
133	    stripped
134	        .components()
135	        .map(|c| c.as_os_str().to_string_lossy().into_owned())
136	        .collect::<Vec<_>>()
137	        .join("/")
138	}
139
140	fn scan_text(
141	    text: &str,
142	    lang: Lang,
143	    rel_path: &str,
144	    allowed_stages: &HashSet<String>,
145	    output: &mut ScanOutput,
146	) {
147	    let mut in_block = false;
148	    let mut python_triple: Option<&'static str> = None;
149	    for (idx, line) in text.split('\n').enumerate() {
150	        let line = line.strip_suffix('\r').unwrap_or(line);
151	        let line_no = idx + 1;
152	        let regions = if lang == Lang::Python {
153	            // [impl->REQ-PY-DOCSTRING-SCAN]
154	            python_comment_regions(line, &mut python_triple)
155	        } else {
156	            comment_regions(line, lang, &mut in_block)
157	        };
158	        if regions.is_empty() {
159	            continue;
160	        }
161	        let mut seen_on_line: BTreeSet<(String, String)> = BTreeSet::new();
162	        let mut parse_emitted_for_line = false;
163	        for region in regions {
164	            for inner in find_tag_tokens(region) {
165	                match classify_tag(inner, allowed_stages) {
166	                    TagClass::Valid { stage, req_id } => {
167	                        let key = (stage.clone(), req_id.clone());
168	                        if seen_on_line.insert(key) {
169	                            output.tags.push(TagEvidence {
170	                                stage,
171	                                req_id,
172	                                path: rel_path.to_string(),
173	                                line: line_no,
174	                            });
175	                        }
176	                    }
177	                    TagClass::Invalid => {
178	                        if !parse_emitted_for_line {
179	                            output.parse_errors.push(ParseErrorTag {
180	                                path: rel_path.to_string(),
181	                                line: line_no,
182	                            });
183	                            parse_emitted_for_line = true;
184	                        }
185	                    }
186	                }
187	            }
188	        }
189	    }
190	}
191
192	// [impl->REQ-COMMENT-SCOPE]
193	fn comment_regions<'a>(line: &'a str, lang: Lang, in_block: &mut bool) -> Vec<&'a str> {
194	    match lang {
195	        Lang::Markdown => vec![line],
196	        Lang::Python | Lang::Cmake | Lang::Yaml => match line.find('#') {
197	            Some(idx) => vec![&line[idx..]],
198	            None => Vec::new(),
199	        },
200	        Lang::Rust | Lang::Cpp | Lang::JsTs => rust_cpp_comment_regions(line, in_block),
201	    }
202	}
203
204	// [impl->REQ-COMMENT-SCOPE]
205	// [impl->REQ-PY-DOCSTRING-SCAN]
206	fn python_comment_regions<'a>(line: &'a str, in_triple: &mut Option<&'static str>) -> Vec<&'a str> {
207	    let mut regions = Vec::new();
208	    let mut pos = 0;
209	    while pos < line.len() {
210	        if let Some(delim) = *in_triple {
211	            match line[pos..].find(delim) {
212	                Some(off) => {
213	                    let end = pos + off + delim.len();
214	                    regions.push(&line[pos..end]);
215	                    *in_triple = None;
216	                    pos = end;
217	                }
218	                None => {
219	                    regions.push(&line[pos..]);
220	                    break;
221	                }
222	            }
223	            continue;
224	        }
225
226	        let next_hash = line[pos..].find('#').map(|off| (pos + off, "#"));
227	        let next_double = line[pos..].find("\"\"\"").map(|off| (pos + off, "\"\"\""));
228	        let next_single = line[pos..].find("'''").map(|off| (pos + off, "'''"));
229	        let next = [next_hash, next_double, next_single]
230	            .into_iter()
231	            .flatten()
232	            .min_by_key(|(idx, _)| *idx);
233
234	        let Some((start, token)) = next else {
235	            break;
236	        };
237	        if token == "#" {
238	            regions.push(&line[start..]);
239	            break;
240	        }
241
242	        match line[start + token.len()..].find(token) {
243	            Some(off) => {
244	                let end = start + token.len() + off + token.len();
245	                regions.push(&line[start..end]);
246	                pos = end;
247	            }
248	            None => {
249	                regions.push(&line[start..]);
250	                *in_triple = Some(token);
251	                break;
252	            }
253	        }
254	    }
255	    regions
256	}
257
258	// [impl->REQ-BLOCK-COMMENT]
259	// [impl->REQ-COMMENT-SCOPE]
260	fn rust_cpp_comment_regions<'a>(line: &'a str, in_block: &mut bool) -> Vec<&'a str> {
261	    let bytes = line.as_bytes();
262	    let n = bytes.len();
263	    let mut regions: Vec<&'a str> = Vec::new();
264	    let mut i = 0;
265	    let mut block_start: Option<usize> = if *in_block { Some(0) } else { None };
266	    let mut in_string = false;
267
268	    while i < n {
269	        if *in_block {
270	            if i + 1 < n && bytes[i] == b'*' && bytes[i + 1] == b'/' {
271	                let start = block_start.take().unwrap_or(0);
272	                regions.push(&line[start..i + 2]);
273	                *in_block = false;
274	                i += 2;
275	            } else {
276	                i += 1;
277	            }
278	        } else if in_string {
279	            if bytes[i] == b'\\' && i + 1 < n {
280	                i += 2;
281	            } else if bytes[i] == b'"' {
282	                in_string = false;
283	                i += 1;
284	            } else {
285	                i += 1;
286	            }
287	        } else if i + 1 < n && bytes[i] == b'/' && bytes[i + 1] == b'/' {
288	            regions.push(&line[i..]);
289	            return regions;
290	        } else if i + 1 < n && bytes[i] == b'/' && bytes[i + 1] == b'*' {
291	            block_start = Some(i);
292	            *in_block = true;
293	            i += 2;
294	        } else if bytes[i] == b'"' {
295	            in_string = true;
296	            i += 1;
297	        } else {
298	            i += 1;
299	        }
300	    }
301
302	    if *in_block {
303	        let start = block_start.unwrap_or(0);
304	        regions.push(&line[start..]);
305	    }
306
307	    regions
308	}
309
310	// [impl->REQ-TAG-GRAMMAR-PARSE]
311	// [impl->REQ-TAG-GRAMMAR-REJECT]
312	// [impl->REQ-MARKDOWN-TAG-CANDIDATE]
313	fn find_tag_tokens(text: &str) -> Vec<&str> {
314	    let bytes = text.as_bytes();
315	    let n = bytes.len();
316	    let mut tokens = Vec::new();
317	    let mut i = 0;
318	    while i < n {
319	        if bytes[i] == b'[' {
320	            let mut j = i + 1;
321	            while j < n && bytes[j] != b']' && bytes[j] != b'[' {
322	                j += 1;
323	            }
324	            if j < n && bytes[j] == b']' {
325	                let inner = &text[i + 1..j];
326	                if is_tag_candidate(inner) {
327	                    tokens.push(inner);
328	                }
329	                i = j + 1;
330	                continue;
331	            }
332	        }
333	        i += 1;
334	    }
335	    tokens
336	}
337
338	// [impl->REQ-MARKDOWN-TAG-CANDIDATE]
339	fn is_tag_candidate(inner: &str) -> bool {
340	    let Some((stage, _)) = inner.split_once("->") else {
341	        return false;
342	    };
343	    is_stage_name_shape(stage.trim())
344	}
345
346	fn is_stage_name_shape(name: &str) -> bool {
347	    let mut chars = name.chars();
348	    let Some(first) = chars.next() else {
349	        return false;
350	    };
351	    if !first.is_ascii_lowercase() {
352	        return false;
353	    }
354	    chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
355	}
356
357	enum TagClass {
358	    Valid { stage: String, req_id: String },
359	    Invalid,
360	}
361
362	// [impl->REQ-TAG-GRAMMAR-PARSE]
363	// [impl->REQ-TAG-GRAMMAR-REJECT]
364	// [impl->REQ-MANIFEST-CUSTOM-STAGES]
365	fn classify_tag(inner: &str, allowed_stages: &HashSet<String>) -> TagClass {
366	    if inner.chars().any(|c| c.is_whitespace()) {
367	        return TagClass::Invalid;
368	    }
369	    let mut parts = inner.splitn(2, "->");
370	    let stage = match parts.next() {
371	        Some(s) => s,
372	        None => return TagClass::Invalid,
373	    };
374	    let id = match parts.next() {
375	        Some(s) => s,
376	        None => return TagClass::Invalid,
377	    };
378	    if id.contains("->") {
379	        return TagClass::Invalid;
380	    }
381	    if !allowed_stages.contains(stage) {
382	        return TagClass::Invalid;
383	    }
384	    if !is_valid_req_id(id) {
385	        return TagClass::Invalid;
386	    }
387	    TagClass::Valid {
388	        stage: stage.to_string(),
389	        req_id: id.to_string(),
390	    }
391	}
392
393	// [impl->REQ-TAG-GRAMMAR-PARSE]
394	fn is_valid_req_id(id: &str) -> bool {
395	    let rest = match id.strip_prefix("REQ-") {
396	        Some(r) => r,
397	        None => return false,
398	    };
399	    if rest.is_empty() {
400	        return false;
401	    }
402	    let mut had_segment_char = false;
403	    for c in rest.chars() {
404	        if c == '-' {
405	            if !had_segment_char {
406	                return false;
407	            }
408	            had_segment_char = false;
409	        } else if c.is_ascii_uppercase() || c.is_ascii_digit() {
410	            had_segment_char = true;
411	        } else {
412	            return false;
413	        }
414	    }
415	    had_segment_char
416	}
417
418	#[cfg(test)]
419	mod tests {
420	    use super::*;
421
422	    // [unit->REQ-SCAN-LANGS]
423	    // [unit->REQ-SCAN-JS-TS]
424	    #[test]
425	    fn lang_for_recognises_each_supported_extension() {
426	        assert_eq!(lang_for(Path::new("a.rs")), Some(Lang::Rust));
427	        assert_eq!(lang_for(Path::new("a.py")), Some(Lang::Python));
428	        assert_eq!(lang_for(Path::new("a.cpp")), Some(Lang::Cpp));
429	        assert_eq!(lang_for(Path::new("a.cc")), Some(Lang::Cpp));
430	        assert_eq!(lang_for(Path::new("a.c")), Some(Lang::Cpp));
431	        assert_eq!(lang_for(Path::new("a.h")), Some(Lang::Cpp));
432	        assert_eq!(lang_for(Path::new("a.hpp")), Some(Lang::Cpp));
433	        assert_eq!(lang_for(Path::new("a.js")), Some(Lang::JsTs));
434	        assert_eq!(lang_for(Path::new("a.jsx")), Some(Lang::JsTs));
435	        assert_eq!(lang_for(Path::new("a.mjs")), Some(Lang::JsTs));
436	        assert_eq!(lang_for(Path::new("a.cjs")), Some(Lang::JsTs));
437	        assert_eq!(lang_for(Path::new("a.ts")), Some(Lang::JsTs));
438	        assert_eq!(lang_for(Path::new("a.tsx")), Some(Lang::JsTs));
439	        assert_eq!(lang_for(Path::new("a.mts")), Some(Lang::JsTs));
440	        assert_eq!(lang_for(Path::new("a.cts")), Some(Lang::JsTs));
441	        assert_eq!(lang_for(Path::new("a.md")), Some(Lang::Markdown));
442	        assert_eq!(lang_for(Path::new("a.markdown")), Some(Lang::Markdown));
443	        assert_eq!(lang_for(Path::new("a.cmake")), Some(Lang::Cmake));
444	        assert_eq!(lang_for(Path::new("CMakeLists.txt")), Some(Lang::Cmake));
445	        assert_eq!(
446	            lang_for(Path::new("nested/CMakeLists.txt")),
447	            Some(Lang::Cmake)
448	        );
449	        assert_eq!(lang_for(Path::new("ci.yml")), Some(Lang::Yaml));
450	        assert_eq!(lang_for(Path::new("ci.yaml")), Some(Lang::Yaml));
451	        assert_eq!(lang_for(Path::new("a.txt")), None);
452	        assert_eq!(lang_for(Path::new("Cargo.toml")), None);
453	    }
454
455	    // [unit->REQ-COMMENT-SCOPE]
456	    #[test]
457	    fn cmake_takes_only_text_after_hash() {
458	        let mut in_block = false;
459	        let regions = comment_regions(
460	            "set(FOO bar)  # [impl->REQ-A-001]",
461	            Lang::Cmake,
462	            &mut in_block,
463	        );
464	        assert_eq!(regions, vec!["# [impl->REQ-A-001]"]);
465	        let none = comment_regions("set(FOO bar)", Lang::Cmake, &mut in_block);
466	        assert!(none.is_empty());
467	    }
468
469	    // [unit->REQ-COMMENT-SCOPE]
470	    #[test]
471	    fn yaml_takes_only_text_after_hash() {
472	        let mut in_block = false;
473	        let regions = comment_regions(
474	            "name: build  # [impl->REQ-CI-PIPELINE]",
475	            Lang::Yaml,
476	            &mut in_block,
477	        );
478	        assert_eq!(regions, vec!["# [impl->REQ-CI-PIPELINE]"]);
479	        let none = comment_regions("name: build", Lang::Yaml, &mut in_block);
480	        assert!(none.is_empty());
481	    }
482
483	    // [unit->REQ-COMMENT-SCOPE]
484	    #[test]
485	    fn markdown_returns_whole_line_as_region() {
486	        let mut in_block = false;
487	        let regions = comment_regions("plain text [doc->REQ-A-001]", Lang::Markdown, &mut in_block);
488	        assert_eq!(regions, vec!["plain text [doc->REQ-A-001]"]);
489	    }
490
491	    // [unit->REQ-COMMENT-SCOPE]
492	    #[test]
493	    fn python_takes_only_text_after_hash() {
494	        let mut in_block = false;
495	        let regions = comment_regions("x = 1  # hello world", Lang::Python, &mut in_block);
496	        assert_eq!(regions, vec!["# hello world"]);
497	        let none = comment_regions("x = 1", Lang::Python, &mut in_block);
498	        assert!(none.is_empty());
499	    }
500
501	    // [unit->REQ-COMMENT-SCOPE]
502	    // [unit->REQ-PY-DOCSTRING-SCAN]
503	    #[test]
504	    fn python_scans_triple_quoted_docstring_lines() {
505	        let mut in_triple = None;
506	        let r1 = python_comment_regions("def f():", &mut in_triple);
507	        assert!(r1.is_empty());
508
509	        let r2 = python_comment_regions("    \"\"\"Docstring", &mut in_triple);
510	        assert_eq!(r2, vec!["\"\"\"Docstring"]);
511	        assert_eq!(in_triple, Some("\"\"\""));
512
513	        let r3 = python_comment_regions("    [impl->REQ-A-001]", &mut in_triple);
514	        assert_eq!(r3, vec!["    [impl->REQ-A-001]"]);
515	        assert_eq!(in_triple, Some("\"\"\""));
516
517	        let r4 = python_comment_regions("    \"\"\"", &mut in_triple);
518	        assert_eq!(r4, vec!["    \"\"\""]);
519	        assert!(in_triple.is_none());
520	    }
521
522	    // [unit->REQ-COMMENT-SCOPE]
523	    // [unit->REQ-PY-DOCSTRING-SCAN]
524	    #[test]
525	    fn python_scans_single_line_triple_quoted_docstring() {
526	        let mut in_triple = None;
527	        let regions = python_comment_regions("    '''[unit->REQ-A-001]'''", &mut in_triple);
528	        assert_eq!(regions, vec!["'''[unit->REQ-A-001]'''"]);
529	        assert!(in_triple.is_none());
530	    }
531
532	    // [unit->REQ-COMMENT-SCOPE]
533	    #[test]
534	    fn rust_line_comment_only_scans_after_slashes() {
535	        let mut in_block = false;
536	        let regions = comment_regions("let x = 1; // tail comment", Lang::Rust, &mut in_block);
537	        assert_eq!(regions, vec!["// tail comment"]);
538	    }
539
540	    // [unit->REQ-SCAN-JS-TS]
541	    // [unit->REQ-COMMENT-SCOPE]
542	    #[test]
543	    fn typescript_uses_slash_comment_regions() {
544	        let mut in_block = false;
545	        let line = "const tag = '[impl->REQ-A-001]'; // [unit->REQ-A-001]";
546	        let regions = comment_regions(line, Lang::JsTs, &mut in_block);
547	        assert_eq!(regions, vec!["// [unit->REQ-A-001]"]);
548	    }
549
550	    // [unit->REQ-COMMENT-SCOPE]
551	    #[test]
552	    fn rust_string_literals_do_not_open_comments() {
553	        let mut in_block = false;
554	        let line_with_double_slash_in_string = format!("let s = {}; do_thing();", "\"//\"");
555	        let regions = comment_regions(&line_with_double_slash_in_string, Lang::Rust, &mut in_block);
556	        assert!(
557	            regions.is_empty(),
558	            "string-internal // must not start a comment, got {:?}",
559	            regions
560	        );
561	    }
562
563	    // [unit->REQ-BLOCK-COMMENT]
564	    #[test]
565	    fn block_comment_spans_multiple_lines() {
566	        let mut in_block = false;
567	        let lines = ["/* opening", " continuing", " closing */"];
568
569	        let r1 = comment_regions(lines[0], Lang::Cpp, &mut in_block);
570	        assert_eq!(r1, vec!["/* opening"]);
571	        assert!(in_block, "still in block after opening line");
572
573	        let r2 = comment_regions(lines[1], Lang::Cpp, &mut in_block);
574	        assert_eq!(r2, vec![" continuing"]);
575	        assert!(in_block, "still in block on middle line");
576
577	        let r3 = comment_regions(lines[2], Lang::Cpp, &mut in_block);
578	        assert_eq!(r3, vec![" closing */"]);
579	        assert!(!in_block, "block closed by */");
580	    }
581
582	    // [unit->REQ-BLOCK-COMMENT]
583	    #[test]
584	    fn block_comment_open_and_close_on_one_line() {
585	        let mut in_block = false;
586	        let r = comment_regions("code(); /* inline */ more();", Lang::Cpp, &mut in_block);
587	        assert_eq!(r, vec!["/* inline */"]);
588	        assert!(!in_block);
589	    }
590
591	    // [unit->REQ-TAG-GRAMMAR-PARSE]
592	    #[test]
593	    fn find_tag_tokens_picks_up_arrow_brackets() {
594	        let tokens = find_tag_tokens("noise [impl->REQ-A-001] more [doc->REQ-B] end");
595	        assert_eq!(tokens, vec!["impl->REQ-A-001", "doc->REQ-B"]);
596	    }
597
598	    // [unit->REQ-TAG-GRAMMAR-PARSE]
599	    // [unit->REQ-MARKDOWN-TAG-CANDIDATE]
600	    #[test]
601	    fn find_tag_tokens_skips_mermaid_labels_with_arrows() {
602	        let tokens = find_tag_tokens("A[\"rgb24 -> yuv420p -> H.264\"] --> B");
603	        assert!(tokens.is_empty());
604	    }
605
606	    // [unit->REQ-TAG-GRAMMAR-REJECT]
607	    #[test]
608	    fn find_tag_tokens_keeps_plausible_invalid_tags_for_parse_errors() {
609	        let tokens = find_tag_tokens("[left->right] [impl -> REQ-A-001]");
610	        assert_eq!(tokens, vec!["left->right", "impl -> REQ-A-001"]);
611	    }
612
613	    // [unit->REQ-TAG-GRAMMAR-PARSE]
614	    #[test]
615	    fn find_tag_tokens_skips_brackets_without_arrow() {
616	        let tokens = find_tag_tokens("a [link] b [foo bar] c");
617	        assert!(tokens.is_empty());
618	    }
619
620	    // [unit->REQ-TAG-GRAMMAR-PARSE]
621	    #[test]
622	    fn classify_tag_accepts_canonical_form() {
623	        match classify_tag("impl->REQ-LOGIN-001", &builtin_stage_set()) {
624	            TagClass::Valid { stage, req_id } => {
625	                assert_eq!(stage, "impl");
626	                assert_eq!(req_id, "REQ-LOGIN-001");
627	            }
628	            _ => panic!("expected valid"),
629	        }
630	    }
631
632	    // [unit->REQ-TAG-GRAMMAR-REJECT]
633	    #[test]
634	    fn classify_tag_rejects_unknown_stage() {
635	        assert!(matches!(
636	            classify_tag("smoke->REQ-A-001", &builtin_stage_set()),
637	            TagClass::Invalid
638	        ));
639	    }
640
641	    // [unit->REQ-TAG-GRAMMAR-REJECT]
642	    #[test]
643	    fn classify_tag_rejects_internal_whitespace() {
644	        assert!(matches!(
645	            classify_tag("impl->REQ A 001", &builtin_stage_set()),
646	            TagClass::Invalid
647	        ));
648	    }
649
650	    // [unit->REQ-TAG-GRAMMAR-REJECT]
651	    #[test]
652	    fn classify_tag_rejects_double_arrow() {
653	        assert!(matches!(
654	            classify_tag("impl->REQ-A->B", &builtin_stage_set()),
655	            TagClass::Invalid
656	        ));
657	    }
658
659	    // [unit->REQ-MANIFEST-CUSTOM-STAGES]
660	    #[test]
661	    fn classify_tag_accepts_declared_custom_stage() {
662	        let mut allowed = builtin_stage_set();
663	        allowed.insert("qc".to_string());
664	        match classify_tag("qc->REQ-LOGIN-001", &allowed) {
665	            TagClass::Valid { stage, req_id } => {
666	                assert_eq!(stage, "qc");
667	                assert_eq!(req_id, "REQ-LOGIN-001");
668	            }
669	            _ => panic!("expected valid"),
670	        }
671	        // Same tag against built-ins-only fails.
672	        assert!(matches!(
673	            classify_tag("qc->REQ-LOGIN-001", &builtin_stage_set()),
674	            TagClass::Invalid
675	        ));
676	    }
677
678	    // [unit->REQ-TAG-GRAMMAR-PARSE]
679	    // [unit->REQ-TAG-GRAMMAR-REJECT]
680	    #[test]
681	    fn is_valid_req_id_enforces_grammar() {
682	        assert!(is_valid_req_id("REQ-A"));
683	        assert!(is_valid_req_id("REQ-A-001"));
684	        assert!(is_valid_req_id("REQ-LOGIN-001-V2"));
685	        assert!(!is_valid_req_id("REQ-"));
686	        assert!(!is_valid_req_id("REQ--A"));
687	        assert!(!is_valid_req_id("REQ-A-"));
688	        assert!(!is_valid_req_id("REQ-a"));
689	        assert!(!is_valid_req_id("FOO-A"));
690	        assert!(!is_valid_req_id(""));
691	    }
692	}
693

//! File scanning and tag extraction.
//!
//! Walks the configured scan roots and recognizes the bracketed stage/req-id
//! tokens defined by the spec. For Rust, C/C++, and Python the scanner only
//! considers comment regions (non-comment source text is ignored); Markdown
//! is scanned on every physical line. JavaScript/TypeScript use the same slash
//! comment rules as Rust and C/C++. See SPEC.md §Tags.
//!
//! [doc->REQ-SCAN-LANGS]
//! [doc->REQ-SCAN-JS-TS]
//! [doc->REQ-TAG-GRAMMAR-PARSE]
//! [doc->REQ-TAG-GRAMMAR-REJECT]
//! [doc->REQ-MARKDOWN-TAG-CANDIDATE]
//! [doc->REQ-COMMENT-SCOPE]
//! [doc->REQ-PY-DOCSTRING-SCAN]
//! [doc->REQ-BLOCK-COMMENT]

use std::collections::{BTreeSet, HashSet};
use std::path::{Path, PathBuf};
use walkdir::WalkDir;

use crate::manifest::STAGE_NAMES;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Lang {
    Rust,
    Cpp,
    JsTs,
    Python,
    Markdown,
    Cmake,
    Yaml,
}

// [impl->REQ-SCAN-LANGS]
// [impl->REQ-SCAN-JS-TS]
fn lang_for(path: &Path) -> Option<Lang> {
    if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
        if name == "CMakeLists.txt" {
            return Some(Lang::Cmake);
        }
    }
    let ext = path.extension()?.to_str()?.to_ascii_lowercase();
    Some(match ext.as_str() {
        "rs" => Lang::Rust,
        "c" | "cc" | "cpp" | "cxx" | "c++" | "h" | "hh" | "hpp" | "hxx" | "h++" => Lang::Cpp,
        "js" | "jsx" | "mjs" | "cjs" | "ts" | "tsx" | "mts" | "cts" => Lang::JsTs,
        "py" => Lang::Python,
        "md" | "markdown" => Lang::Markdown,
        "cmake" => Lang::Cmake,
        "yml" | "yaml" => Lang::Yaml,
        _ => return None,
    })
}

#[derive(Debug, Clone)]
pub struct TagEvidence {
    pub stage: String,
    pub req_id: String,
    pub path: String,
    pub line: usize,
}

#[derive(Debug, Clone)]
pub struct ParseErrorTag {
    pub path: String,
    pub line: usize,
}

#[derive(Debug, Clone)]
pub struct ScanError {
    pub path: String,
    pub message: String,
}

#[derive(Debug, Default)]
pub struct ScanOutput {
    pub tags: Vec<TagEvidence>,
    pub parse_errors: Vec<ParseErrorTag>,
    pub scan_errors: Vec<ScanError>,
}

pub fn scan_roots(
    repo_root: &Path,
    roots: &[PathBuf],
    allowed_stages: &HashSet<String>,
) -> ScanOutput {
    let mut output = ScanOutput::default();
    for root in roots {
        let abs_root = repo_root.join(root);
        for entry in WalkDir::new(&abs_root).follow_links(false) {
            let entry = match entry {
                Ok(e) => e,
                Err(err) => {
                    let path = err
                        .path()
                        .map(|p| repo_relative(repo_root, p))
                        .unwrap_or_else(|| root.display().to_string());
                    output.scan_errors.push(ScanError {
                        path,
                        message: err.to_string(),
                    });
                    continue;
                }
            };
            if !entry.file_type().is_file() {
                continue;
            }
            let lang = match lang_for(entry.path()) {
                Some(l) => l,
                None => continue,
            };
            let rel = repo_relative(repo_root, entry.path());
            match std::fs::read_to_string(entry.path()) {
                Ok(text) => scan_text(&text, lang, &rel, allowed_stages, &mut output),
                Err(err) => output.scan_errors.push(ScanError {
                    path: rel,
                    message: err.to_string(),
                }),
            }
        }
    }
    output
}

/// Convenience helper for tests and callers that only care about built-in stages.
pub fn builtin_stage_set() -> HashSet<String> {
    STAGE_NAMES.iter().map(|s| s.to_string()).collect()
}

fn repo_relative(repo_root: &Path, path: &Path) -> String {
    let stripped = path.strip_prefix(repo_root).unwrap_or(path);
    stripped
        .components()
        .map(|c| c.as_os_str().to_string_lossy().into_owned())
        .collect::<Vec<_>>()
        .join("/")
}

fn scan_text(
    text: &str,
    lang: Lang,
    rel_path: &str,
    allowed_stages: &HashSet<String>,
    output: &mut ScanOutput,
) {
    let mut in_block = false;
    let mut python_triple: Option<&'static str> = None;
    for (idx, line) in text.split('\n').enumerate() {
        let line = line.strip_suffix('\r').unwrap_or(line);
        let line_no = idx + 1;
        let regions = if lang == Lang::Python {
            // [impl->REQ-PY-DOCSTRING-SCAN]
            python_comment_regions(line, &mut python_triple)
        } else {
            comment_regions(line, lang, &mut in_block)
        };
        if regions.is_empty() {
            continue;
        }
        let mut seen_on_line: BTreeSet<(String, String)> = BTreeSet::new();
        let mut parse_emitted_for_line = false;
        for region in regions {
            for inner in find_tag_tokens(region) {
                match classify_tag(inner, allowed_stages) {
                    TagClass::Valid { stage, req_id } => {
                        let key = (stage.clone(), req_id.clone());
                        if seen_on_line.insert(key) {
                            output.tags.push(TagEvidence {
                                stage,
                                req_id,
                                path: rel_path.to_string(),
                                line: line_no,
                            });
                        }
                    }
                    TagClass::Invalid => {
                        if !parse_emitted_for_line {
                            output.parse_errors.push(ParseErrorTag {
                                path: rel_path.to_string(),
                                line: line_no,
                            });
                            parse_emitted_for_line = true;
                        }
                    }
                }
            }
        }
    }
}

// [impl->REQ-COMMENT-SCOPE]
fn comment_regions<'a>(line: &'a str, lang: Lang, in_block: &mut bool) -> Vec<&'a str> {
    match lang {
        Lang::Markdown => vec![line],
        Lang::Python | Lang::Cmake | Lang::Yaml => match line.find('#') {
            Some(idx) => vec![&line[idx..]],
            None => Vec::new(),
        },
        Lang::Rust | Lang::Cpp | Lang::JsTs => rust_cpp_comment_regions(line, in_block),
    }
}

// [impl->REQ-COMMENT-SCOPE]
// [impl->REQ-PY-DOCSTRING-SCAN]
fn python_comment_regions<'a>(line: &'a str, in_triple: &mut Option<&'static str>) -> Vec<&'a str> {
    let mut regions = Vec::new();
    let mut pos = 0;
    while pos < line.len() {
        if let Some(delim) = *in_triple {
            match line[pos..].find(delim) {
                Some(off) => {
                    let end = pos + off + delim.len();
                    regions.push(&line[pos..end]);
                    *in_triple = None;
                    pos = end;
                }
                None => {
                    regions.push(&line[pos..]);
                    break;
                }
            }
            continue;
        }

        let next_hash = line[pos..].find('#').map(|off| (pos + off, "#"));
        let next_double = line[pos..].find("\"\"\"").map(|off| (pos + off, "\"\"\""));
        let next_single = line[pos..].find("'''").map(|off| (pos + off, "'''"));
        let next = [next_hash, next_double, next_single]
            .into_iter()
            .flatten()
            .min_by_key(|(idx, _)| *idx);

        let Some((start, token)) = next else {
            break;
        };
        if token == "#" {
            regions.push(&line[start..]);
            break;
        }

        match line[start + token.len()..].find(token) {
            Some(off) => {
                let end = start + token.len() + off + token.len();
                regions.push(&line[start..end]);
                pos = end;
            }
            None => {
                regions.push(&line[start..]);
                *in_triple = Some(token);
                break;
            }
        }
    }
    regions
}

// [impl->REQ-BLOCK-COMMENT]
// [impl->REQ-COMMENT-SCOPE]
fn rust_cpp_comment_regions<'a>(line: &'a str, in_block: &mut bool) -> Vec<&'a str> {
    let bytes = line.as_bytes();
    let n = bytes.len();
    let mut regions: Vec<&'a str> = Vec::new();
    let mut i = 0;
    let mut block_start: Option<usize> = if *in_block { Some(0) } else { None };
    let mut in_string = false;

    while i < n {
        if *in_block {
            if i + 1 < n && bytes[i] == b'*' && bytes[i + 1] == b'/' {
                let start = block_start.take().unwrap_or(0);
                regions.push(&line[start..i + 2]);
                *in_block = false;
                i += 2;
            } else {
                i += 1;
            }
        } else if in_string {
            if bytes[i] == b'\\' && i + 1 < n {
                i += 2;
            } else if bytes[i] == b'"' {
                in_string = false;
                i += 1;
            } else {
                i += 1;
            }
        } else if i + 1 < n && bytes[i] == b'/' && bytes[i + 1] == b'/' {
            regions.push(&line[i..]);
            return regions;
        } else if i + 1 < n && bytes[i] == b'/' && bytes[i + 1] == b'*' {
            block_start = Some(i);
            *in_block = true;
            i += 2;
        } else if bytes[i] == b'"' {
            in_string = true;
            i += 1;
        } else {
            i += 1;
        }
    }

    if *in_block {
        let start = block_start.unwrap_or(0);
        regions.push(&line[start..]);
    }

    regions
}

// [impl->REQ-TAG-GRAMMAR-PARSE]
// [impl->REQ-TAG-GRAMMAR-REJECT]
// [impl->REQ-MARKDOWN-TAG-CANDIDATE]
fn find_tag_tokens(text: &str) -> Vec<&str> {
    let bytes = text.as_bytes();
    let n = bytes.len();
    let mut tokens = Vec::new();
    let mut i = 0;
    while i < n {
        if bytes[i] == b'[' {
            let mut j = i + 1;
            while j < n && bytes[j] != b']' && bytes[j] != b'[' {
                j += 1;
            }
            if j < n && bytes[j] == b']' {
                let inner = &text[i + 1..j];
                if is_tag_candidate(inner) {
                    tokens.push(inner);
                }
                i = j + 1;
                continue;
            }
        }
        i += 1;
    }
    tokens
}

// [impl->REQ-MARKDOWN-TAG-CANDIDATE]
fn is_tag_candidate(inner: &str) -> bool {
    let Some((stage, _)) = inner.split_once("->") else {
        return false;
    };
    is_stage_name_shape(stage.trim())
}

fn is_stage_name_shape(name: &str) -> bool {
    let mut chars = name.chars();
    let Some(first) = chars.next() else {
        return false;
    };
    if !first.is_ascii_lowercase() {
        return false;
    }
    chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
}

enum TagClass {
    Valid { stage: String, req_id: String },
    Invalid,
}

// [impl->REQ-TAG-GRAMMAR-PARSE]
// [impl->REQ-TAG-GRAMMAR-REJECT]
// [impl->REQ-MANIFEST-CUSTOM-STAGES]
fn classify_tag(inner: &str, allowed_stages: &HashSet<String>) -> TagClass {
    if inner.chars().any(|c| c.is_whitespace()) {
        return TagClass::Invalid;
    }
    let mut parts = inner.splitn(2, "->");
    let stage = match parts.next() {
        Some(s) => s,
        None => return TagClass::Invalid,
    };
    let id = match parts.next() {
        Some(s) => s,
        None => return TagClass::Invalid,
    };
    if id.contains("->") {
        return TagClass::Invalid;
    }
    if !allowed_stages.contains(stage) {
        return TagClass::Invalid;
    }
    if !is_valid_req_id(id) {
        return TagClass::Invalid;
    }
    TagClass::Valid {
        stage: stage.to_string(),
        req_id: id.to_string(),
    }
}

// [impl->REQ-TAG-GRAMMAR-PARSE]
fn is_valid_req_id(id: &str) -> bool {
    let rest = match id.strip_prefix("REQ-") {
        Some(r) => r,
        None => return false,
    };
    if rest.is_empty() {
        return false;
    }
    let mut had_segment_char = false;
    for c in rest.chars() {
        if c == '-' {
            if !had_segment_char {
                return false;
            }
            had_segment_char = false;
        } else if c.is_ascii_uppercase() || c.is_ascii_digit() {
            had_segment_char = true;
        } else {
            return false;
        }
    }
    had_segment_char
}

#[cfg(test)]
mod tests {
    use super::*;

    // [unit->REQ-SCAN-LANGS]
    // [unit->REQ-SCAN-JS-TS]
    #[test]
    fn lang_for_recognises_each_supported_extension() {
        assert_eq!(lang_for(Path::new("a.rs")), Some(Lang::Rust));
        assert_eq!(lang_for(Path::new("a.py")), Some(Lang::Python));
        assert_eq!(lang_for(Path::new("a.cpp")), Some(Lang::Cpp));
        assert_eq!(lang_for(Path::new("a.cc")), Some(Lang::Cpp));
        assert_eq!(lang_for(Path::new("a.c")), Some(Lang::Cpp));
        assert_eq!(lang_for(Path::new("a.h")), Some(Lang::Cpp));
        assert_eq!(lang_for(Path::new("a.hpp")), Some(Lang::Cpp));
        assert_eq!(lang_for(Path::new("a.js")), Some(Lang::JsTs));
        assert_eq!(lang_for(Path::new("a.jsx")), Some(Lang::JsTs));
        assert_eq!(lang_for(Path::new("a.mjs")), Some(Lang::JsTs));
        assert_eq!(lang_for(Path::new("a.cjs")), Some(Lang::JsTs));
        assert_eq!(lang_for(Path::new("a.ts")), Some(Lang::JsTs));
        assert_eq!(lang_for(Path::new("a.tsx")), Some(Lang::JsTs));
        assert_eq!(lang_for(Path::new("a.mts")), Some(Lang::JsTs));
        assert_eq!(lang_for(Path::new("a.cts")), Some(Lang::JsTs));
        assert_eq!(lang_for(Path::new("a.md")), Some(Lang::Markdown));
        assert_eq!(lang_for(Path::new("a.markdown")), Some(Lang::Markdown));
        assert_eq!(lang_for(Path::new("a.cmake")), Some(Lang::Cmake));
        assert_eq!(lang_for(Path::new("CMakeLists.txt")), Some(Lang::Cmake));
        assert_eq!(
            lang_for(Path::new("nested/CMakeLists.txt")),
            Some(Lang::Cmake)
        );
        assert_eq!(lang_for(Path::new("ci.yml")), Some(Lang::Yaml));
        assert_eq!(lang_for(Path::new("ci.yaml")), Some(Lang::Yaml));
        assert_eq!(lang_for(Path::new("a.txt")), None);
        assert_eq!(lang_for(Path::new("Cargo.toml")), None);
    }

    // [unit->REQ-COMMENT-SCOPE]
    #[test]
    fn cmake_takes_only_text_after_hash() {
        let mut in_block = false;
        let regions = comment_regions(
            "set(FOO bar)  # [impl->REQ-A-001]",
            Lang::Cmake,
            &mut in_block,
        );
        assert_eq!(regions, vec!["# [impl->REQ-A-001]"]);
        let none = comment_regions("set(FOO bar)", Lang::Cmake, &mut in_block);
        assert!(none.is_empty());
    }

    // [unit->REQ-COMMENT-SCOPE]
    #[test]
    fn yaml_takes_only_text_after_hash() {
        let mut in_block = false;
        let regions = comment_regions(
            "name: build  # [impl->REQ-CI-PIPELINE]",
            Lang::Yaml,
            &mut in_block,
        );
        assert_eq!(regions, vec!["# [impl->REQ-CI-PIPELINE]"]);
        let none = comment_regions("name: build", Lang::Yaml, &mut in_block);
        assert!(none.is_empty());
    }

    // [unit->REQ-COMMENT-SCOPE]
    #[test]
    fn markdown_returns_whole_line_as_region() {
        let mut in_block = false;
        let regions = comment_regions("plain text [doc->REQ-A-001]", Lang::Markdown, &mut in_block);
        assert_eq!(regions, vec!["plain text [doc->REQ-A-001]"]);
    }

    // [unit->REQ-COMMENT-SCOPE]
    #[test]
    fn python_takes_only_text_after_hash() {
        let mut in_block = false;
        let regions = comment_regions("x = 1  # hello world", Lang::Python, &mut in_block);
        assert_eq!(regions, vec!["# hello world"]);
        let none = comment_regions("x = 1", Lang::Python, &mut in_block);
        assert!(none.is_empty());
    }

    // [unit->REQ-COMMENT-SCOPE]
    // [unit->REQ-PY-DOCSTRING-SCAN]
    #[test]
    fn python_scans_triple_quoted_docstring_lines() {
        let mut in_triple = None;
        let r1 = python_comment_regions("def f():", &mut in_triple);
        assert!(r1.is_empty());

        let r2 = python_comment_regions("    \"\"\"Docstring", &mut in_triple);
        assert_eq!(r2, vec!["\"\"\"Docstring"]);
        assert_eq!(in_triple, Some("\"\"\""));

        let r3 = python_comment_regions("    [impl->REQ-A-001]", &mut in_triple);
        assert_eq!(r3, vec!["    [impl->REQ-A-001]"]);
        assert_eq!(in_triple, Some("\"\"\""));

        let r4 = python_comment_regions("    \"\"\"", &mut in_triple);
        assert_eq!(r4, vec!["    \"\"\""]);
        assert!(in_triple.is_none());
    }

    // [unit->REQ-COMMENT-SCOPE]
    // [unit->REQ-PY-DOCSTRING-SCAN]
    #[test]
    fn python_scans_single_line_triple_quoted_docstring() {
        let mut in_triple = None;
        let regions = python_comment_regions("    '''[unit->REQ-A-001]'''", &mut in_triple);
        assert_eq!(regions, vec!["'''[unit->REQ-A-001]'''"]);
        assert!(in_triple.is_none());
    }

    // [unit->REQ-COMMENT-SCOPE]
    #[test]
    fn rust_line_comment_only_scans_after_slashes() {
        let mut in_block = false;
        let regions = comment_regions("let x = 1; // tail comment", Lang::Rust, &mut in_block);
        assert_eq!(regions, vec!["// tail comment"]);
    }

    // [unit->REQ-SCAN-JS-TS]
    // [unit->REQ-COMMENT-SCOPE]
    #[test]
    fn typescript_uses_slash_comment_regions() {
        let mut in_block = false;
        let line = "const tag = '[impl->REQ-A-001]'; // [unit->REQ-A-001]";
        let regions = comment_regions(line, Lang::JsTs, &mut in_block);
        assert_eq!(regions, vec!["// [unit->REQ-A-001]"]);
    }

    // [unit->REQ-COMMENT-SCOPE]
    #[test]
    fn rust_string_literals_do_not_open_comments() {
        let mut in_block = false;
        let line_with_double_slash_in_string = format!("let s = {}; do_thing();", "\"//\"");
        let regions = comment_regions(&line_with_double_slash_in_string, Lang::Rust, &mut in_block);
        assert!(
            regions.is_empty(),
            "string-internal // must not start a comment, got {:?}",
            regions
        );
    }

    // [unit->REQ-BLOCK-COMMENT]
    #[test]
    fn block_comment_spans_multiple_lines() {
        let mut in_block = false;
        let lines = ["/* opening", " continuing", " closing */"];

        let r1 = comment_regions(lines[0], Lang::Cpp, &mut in_block);
        assert_eq!(r1, vec!["/* opening"]);
        assert!(in_block, "still in block after opening line");

        let r2 = comment_regions(lines[1], Lang::Cpp, &mut in_block);
        assert_eq!(r2, vec![" continuing"]);
        assert!(in_block, "still in block on middle line");

        let r3 = comment_regions(lines[2], Lang::Cpp, &mut in_block);
        assert_eq!(r3, vec![" closing */"]);
        assert!(!in_block, "block closed by */");
    }

    // [unit->REQ-BLOCK-COMMENT]
    #[test]
    fn block_comment_open_and_close_on_one_line() {
        let mut in_block = false;
        let r = comment_regions("code(); /* inline */ more();", Lang::Cpp, &mut in_block);
        assert_eq!(r, vec!["/* inline */"]);
        assert!(!in_block);
    }

    // [unit->REQ-TAG-GRAMMAR-PARSE]
    #[test]
    fn find_tag_tokens_picks_up_arrow_brackets() {
        let tokens = find_tag_tokens("noise [impl->REQ-A-001] more [doc->REQ-B] end");
        assert_eq!(tokens, vec!["impl->REQ-A-001", "doc->REQ-B"]);
    }

    // [unit->REQ-TAG-GRAMMAR-PARSE]
    // [unit->REQ-MARKDOWN-TAG-CANDIDATE]
    #[test]
    fn find_tag_tokens_skips_mermaid_labels_with_arrows() {
        let tokens = find_tag_tokens("A[\"rgb24 -> yuv420p -> H.264\"] --> B");
        assert!(tokens.is_empty());
    }

    // [unit->REQ-TAG-GRAMMAR-REJECT]
    #[test]
    fn find_tag_tokens_keeps_plausible_invalid_tags_for_parse_errors() {
        let tokens = find_tag_tokens("[left->right] [impl -> REQ-A-001]");
        assert_eq!(tokens, vec!["left->right", "impl -> REQ-A-001"]);
    }

    // [unit->REQ-TAG-GRAMMAR-PARSE]
    #[test]
    fn find_tag_tokens_skips_brackets_without_arrow() {
        let tokens = find_tag_tokens("a [link] b [foo bar] c");
        assert!(tokens.is_empty());
    }

    // [unit->REQ-TAG-GRAMMAR-PARSE]
    #[test]
    fn classify_tag_accepts_canonical_form() {
        match classify_tag("impl->REQ-LOGIN-001", &builtin_stage_set()) {
            TagClass::Valid { stage, req_id } => {
                assert_eq!(stage, "impl");
                assert_eq!(req_id, "REQ-LOGIN-001");
            }
            _ => panic!("expected valid"),
        }
    }

    // [unit->REQ-TAG-GRAMMAR-REJECT]
    #[test]
    fn classify_tag_rejects_unknown_stage() {
        assert!(matches!(
            classify_tag("smoke->REQ-A-001", &builtin_stage_set()),
            TagClass::Invalid
        ));
    }

    // [unit->REQ-TAG-GRAMMAR-REJECT]
    #[test]
    fn classify_tag_rejects_internal_whitespace() {
        assert!(matches!(
            classify_tag("impl->REQ A 001", &builtin_stage_set()),
            TagClass::Invalid
        ));
    }

    // [unit->REQ-TAG-GRAMMAR-REJECT]
    #[test]
    fn classify_tag_rejects_double_arrow() {
        assert!(matches!(
            classify_tag("impl->REQ-A->B", &builtin_stage_set()),
            TagClass::Invalid
        ));
    }

    // [unit->REQ-MANIFEST-CUSTOM-STAGES]
    #[test]
    fn classify_tag_accepts_declared_custom_stage() {
        let mut allowed = builtin_stage_set();
        allowed.insert("qc".to_string());
        match classify_tag("qc->REQ-LOGIN-001", &allowed) {
            TagClass::Valid { stage, req_id } => {
                assert_eq!(stage, "qc");
                assert_eq!(req_id, "REQ-LOGIN-001");
            }
            _ => panic!("expected valid"),
        }
        // Same tag against built-ins-only fails.
        assert!(matches!(
            classify_tag("qc->REQ-LOGIN-001", &builtin_stage_set()),
            TagClass::Invalid
        ));
    }

    // [unit->REQ-TAG-GRAMMAR-PARSE]
    // [unit->REQ-TAG-GRAMMAR-REJECT]
    #[test]
    fn is_valid_req_id_enforces_grammar() {
        assert!(is_valid_req_id("REQ-A"));
        assert!(is_valid_req_id("REQ-A-001"));
        assert!(is_valid_req_id("REQ-LOGIN-001-V2"));
        assert!(!is_valid_req_id("REQ-"));
        assert!(!is_valid_req_id("REQ--A"));
        assert!(!is_valid_req_id("REQ-A-"));
        assert!(!is_valid_req_id("REQ-a"));
        assert!(!is_valid_req_id("FOO-A"));
        assert!(!is_valid_req_id(""));
    }
}


M6-D1: spt-releases repo + public face (README, licenses)

Create SaberMage/spt-releases private via gh; author README.md per grill decision 7; LICENSE-MIT + LICENSE-BINARY; Pages config deferred to D7 flip. Activate REQ-REL-1 doc stage.

Creating spt-releases repo + public face

M6-D2: installer scripts (install.sh / install.ps1)

Non-interactive: latest-release lookup → platform asset fetch → sha256 verify → per-OS install root → user PATH → print absolute path. Idempotent. CI rung against staged artifact + spt --version. Activate REQ-INSTALL-1/2/3/5.

M6-D3: manifest JSON Schema (schemars) + drift gate

schemars derives on spt-runtime manifest types; emit manifest.schema.json at stable site-root path + release asset; CI regenerate+diff gate. Activate REQ-DOCS-4 (partial), REQ-DOCS-5.

M6-D4: docs-site Tier 1 content (mdBook)

mdBook skeleton + Starlight-inspired theme; mental model; human quickstart; adapter quickstart (mock-adapter); harness-contract vertical complete; shell getting-started (spt-shell-notify); stubs for remaining verticals. Activate REQ-DOCS-1/2/3.

M6-D5: agent-export + generation plumbing

llms.txt + llms-full.txt; raw .md alongside HTML; CLI ref from clap; rustdoc → Pages; source-repo CI publish job → spt-releases Pages branch; drift gates. REQ-REL-1 impl, REQ-DOCS-4/5 complete.

M6-D6: release pipeline + signing ceremony

Workspace → 0.1.0; tag-triggered build matrix both runners; cross-repo release via fine-grained token; key ceremony per ADR-0015 (primary + recovery, embed both pubkeys in VerifyPolicy trusted set, seed backups); manual SignedRelease signing. Activate REQ-REL-2/3.

M6-D7: v0.1.0 ship + flip public + acceptance run

Tag v0.1.0; ceremony; publish release; flip spt-releases public; Pages live; acceptance proof per M6-PLAN (clean-machine install, quickstarts, llms.txt + schema fetch, staged v0.1.1 verify, recovery-key verify).


Good news — main already scans `.yml` (newer than my installed 0.1.1). Gap = `.sh`/`.ps1` only. Following their JS/TS precedent (own REQ + tests). Checking their spec/manifest.


PowerShell

Select-String -Path C:\Users\decid\Documents\projects\traceable-reqs\rust\Cargo.toml -Pattern "^version"; Select-String -Path C:\Users\decid\Documents\projects\traceable-reqs\example\traceable-reqs.toml -Pattern "REQ-SCAN"

Version + scan REQs in manifest


C:\Users\decid\Documents\projects\traceable-reqs\rust\Cargo.toml:3:version = "0.1.1"


C:\Users\decid\Documents\projects\traceable-reqs\rust\traceable-reqs.toml:37:id = "REQ-SCAN-LANGS"
C:\Users\decid\Documents\projects\traceable-reqs\rust\traceable-reqs.toml:42:id = "REQ-SCAN-JS-TS"
C:\Users\decid\Documents\projects\traceable-reqs\rust\tests\fixture.rs:22:// [int->REQ-SCAN-LANGS]
C:\Users\decid\Documents\projects\traceable-reqs\rust\tests\issues.rs:175:// [int->REQ-SCAN-JS-TS]
C:\Users\decid\Documents\projects\traceable-reqs\rust\src\scan.rs:9://! [doc->REQ-SCAN-LANGS]
C:\Users\decid\Documents\projects\traceable-reqs\rust\src\scan.rs:10://! [doc->REQ-SCAN-JS-TS]
C:\Users\decid\Documents\projects\traceable-reqs\rust\src\scan.rs:35:// [impl->REQ-SCAN-LANGS]
C:\Users\decid\Documents\projects\traceable-reqs\rust\src\scan.rs:36:// [impl->REQ-SCAN-JS-TS]
C:\Users\decid\Documents\projects\traceable-reqs\rust\src\scan.rs:422:    // [unit->REQ-SCAN-LANGS]
C:\Users\decid\Documents\projects\traceable-reqs\rust\src\scan.rs:423:    // [unit->REQ-SCAN-JS-TS]
C:\Users\decid\Documents\projects\traceable-reqs\rust\src\scan.rs:540:    // [unit->REQ-SCAN-JS-TS]


30
31	[[requirements]]
32	id = "REQ-MANIFEST-VALIDATE-ROOTS"
33	title = "Reject manifests whose scan roots escape the manifest dir or are absolute with manifest_error"
34	required_stages = ["doc", "impl", "unit", "int"]
35
36	[[requirements]]
37	id = "REQ-SCAN-LANGS"
38	title = "Scan files in the supported languages (Rust, Python, C/C++, JavaScript/TypeScript, Markdown, CMake, YAML) under configured scan roots"
39	required_stages = ["doc", "impl", "unit", "int"]
40
41	[[requirements]]
42	id = "REQ-SCAN-JS-TS"
43	title = "Scan JavaScript and TypeScript line and block comments under configured scan roots"
44	required_stages = ["doc", "impl", "unit", "int"]
45
46	[[requirements]]
47	id = "REQ-TAG-GRAMMAR-PARSE"
48	title = "Recognise [stage->REQ-ID] tokens as evidence tags during scanning"
49	required_stages = ["doc", "impl", "unit", "int"]
50
51	[[requirements]]
52	id = "REQ-TAG-GRAMMAR-REJECT"
53	title = "Emit parse_error for bracketed tokens that contain '->' but do not match the tag grammar"
54	required_stages = ["doc", "impl", "unit", "int"]

[[requirements]]
id = "REQ-MANIFEST-VALIDATE-ROOTS"
title = "Reject manifests whose scan roots escape the manifest dir or are absolute with manifest_error"
required_stages = ["doc", "impl", "unit", "int"]

[[requirements]]
id = "REQ-SCAN-LANGS"
title = "Scan files in the supported languages (Rust, Python, C/C++, JavaScript/TypeScript, Markdown, CMake, YAML) under configured scan roots"
required_stages = ["doc", "impl", "unit", "int"]

[[requirements]]
id = "REQ-SCAN-JS-TS"
title = "Scan JavaScript and TypeScript line and block comments under configured scan roots"
required_stages = ["doc", "impl", "unit", "int"]

[[requirements]]
id = "REQ-TAG-GRAMMAR-PARSE"
title = "Recognise [stage->REQ-ID] tokens as evidence tags during scanning"
required_stages = ["doc", "impl", "unit", "int"]

[[requirements]]
id = "REQ-TAG-GRAMMAR-REJECT"
title = "Emit parse_error for bracketed tokens that contain '->' but do not match the tag grammar"
required_stages = ["doc", "impl", "unit", "int"]


13-1. Read `traceable-reqs.toml` from the current working directory.
14-2. Treat the current working directory as the repository root.
15-3. Treat manifest `[[requirements]].id` values as the authoritative set of declared requirement IDs.
16:4. Scan Rust, Python, C/C++, JavaScript/TypeScript, Markdown, CMake (`.cmake`, `CMakeLists.txt`), and YAML (`.yml`, `.yaml`) files under configured roots.
17-5. Parse tags of the form `[stage->REQ-ID]` with no internal spaces.
18-6. Recognize only these stage keywords: `doc`, `impl`, `unit`, `int`.
19-7. Accept requirement IDs matching `REQ-[A-Z0-9]+(-[A-Z0-9]+)*`.
--
101-Line scanning rules:
102-
103-- Each physical file line is one evidence location.
104:- Rust, C, C++, JavaScript, and TypeScript line comments and block comments are scanned by physical line. A line is in block-comment context if it lies between an open `/*` and its matching `*/`, inclusive of both.
105-- Python `#` comments and triple-quoted documentation strings (`"""..."""` and `'''...'''`) are scanned by physical line.
106-- Markdown files are scanned one physical line at a time, including Markdown text and HTML comments.
107:- Tag-shaped text in non-comment source code is ignored for Rust, C, C++, JavaScript, TypeScript, and Python.
108-- Markdown diagram labels or prose such as `["rgb24 -> yuv420p"]` are not tag-shaped unless the bracketed text begins with a stage-name-shaped token before `->`.
109-
110-Examples:


140	        ),
141	    );
142	    write(&dir, "src/login.rs", "// [impl->REQ-FR-LOGIN]\n");
143
144	    let fr = Command::new(bin())
145	        .args(["check", "--type", "FR", "--json"])
146	        .current_dir(&dir)
147	        .output()
148	        .expect("run binary");
149	    assert_eq!(
150	        fr.status.code(),
151	        Some(0),
152	        "FR scope should ignore NFR missing impl: stdout={} stderr={}",
153	        String::from_utf8_lossy(&fr.stdout),
154	        String::from_utf8_lossy(&fr.stderr),
155	    );
156	    let fr_json: serde_json::Value = serde_json::from_slice(&fr.stdout).unwrap();
157	    assert_eq!(fr_json["requirements"].as_array().unwrap().len(), 1);
158	    assert_eq!(fr_json["requirements"][0]["type"], "FR");
159	    assert_eq!(fr_json["findings"].as_array().unwrap().len(), 0);
160
161	    let nfr = Command::new(bin())
162	        .args(["check", "--type", "NFR", "--json"])
163	        .current_dir(&dir)
164	        .output()
165	        .expect("run binary");
166	    assert_eq!(nfr.status.code(), Some(1));
167	    let nfr_json: serde_json::Value = serde_json::from_slice(&nfr.stdout).unwrap();
168	    assert_eq!(nfr_json["requirements"].as_array().unwrap().len(), 1);
169	    assert_eq!(nfr_json["requirements"][0]["type"], "NFR");
170	    assert_eq!(nfr_json["findings"][0]["code"], "missing_stage");
171	    assert_eq!(nfr_json["findings"][0]["requirementId"], "REQ-NFR-LATENCY");
172	}
173
174	#[test]
175	// [int->REQ-SCAN-JS-TS]
176	fn typescript_tags_under_later_scan_roots_are_scanned() {
177	    let dir = tempdir();
178	    write(
179	        &dir,
180	        "traceable-reqs.toml",
181	        concat!(
182	            "[scan]\n",
183	            "roots = [\"docs\", \".planning\", \"tools\", \"scripts\", \"packages\", \"apps\"]\n",
184	            "[[requirements]]\n",
185	            "id = \"REQ-DEMO-01\"\n",
186	            "title = \"Demo requirement\"\n",
187	            "required_stages = [\"doc\", \"impl\", \"unit\", \"int\"]\n",
188	        ),
189	    );
190	    write(
191	        &dir,
192	        "docs/demo.md",
193	        "<!-- [doc->REQ-DEMO-01] -->\n# Demo\n",
194	    );
195	    write(
196	        &dir,
197	        "apps/server/src/foo.ts",
198	        "export const foo = true; // [impl->REQ-DEMO-01]\n",
199	    );
200	    write(
201	        &dir,
202	        "apps/server/test/foo.test.ts",
203	        "test('foo', () => {}); // [unit->REQ-DEMO-01]\n",
204	    );
205	    write(
206	        &dir,
207	        "apps/server/test/foo.integ.test.ts",
208	        "test('foo integration', () => {}); // [int->REQ-DEMO-01]\n",
209	    );
210	    for root in [".planning", "tools", "scripts", "packages"] {
211	        std::fs::create_dir_all(dir.join(root)).unwrap();
212	    }
213
214	    let out = Command::new(bin())
215	        .args(["trace", "REQ-DEMO-01", "--json"])
216	        .current_dir(&dir)
217	        .output()
218	        .expect("run binary");
219	    assert_eq!(

        ),
    );
    write(&dir, "src/login.rs", "// [impl->REQ-FR-LOGIN]\n");

    let fr = Command::new(bin())
        .args(["check", "--type", "FR", "--json"])
        .current_dir(&dir)
        .output()
        .expect("run binary");
    assert_eq!(
        fr.status.code(),
        Some(0),
        "FR scope should ignore NFR missing impl: stdout={} stderr={}",
        String::from_utf8_lossy(&fr.stdout),
        String::from_utf8_lossy(&fr.stderr),
    );
    let fr_json: serde_json::Value = serde_json::from_slice(&fr.stdout).unwrap();
    assert_eq!(fr_json["requirements"].as_array().unwrap().len(), 1);
    assert_eq!(fr_json["requirements"][0]["type"], "FR");
    assert_eq!(fr_json["findings"].as_array().unwrap().len(), 0);

    let nfr = Command::new(bin())
        .args(["check", "--type", "NFR", "--json"])
        .current_dir(&dir)
        .output()
        .expect("run binary");
    assert_eq!(nfr.status.code(), Some(1));
    let nfr_json: serde_json::Value = serde_json::from_slice(&nfr.stdout).unwrap();
    assert_eq!(nfr_json["requirements"].as_array().unwrap().len(), 1);
    assert_eq!(nfr_json["requirements"][0]["type"], "NFR");
    assert_eq!(nfr_json["findings"][0]["code"], "missing_stage");
    assert_eq!(nfr_json["findings"][0]["requirementId"], "REQ-NFR-LATENCY");
}

#[test]
// [int->REQ-SCAN-JS-TS]
fn typescript_tags_under_later_scan_roots_are_scanned() {
    let dir = tempdir();
    write(
        &dir,
        "traceable-reqs.toml",
        concat!(
            "[scan]\n",
            "roots = [\"docs\", \".planning\", \"tools\", \"scripts\", \"packages\", \"apps\"]\n",
            "[[requirements]]\n",
            "id = \"REQ-DEMO-01\"\n",
            "title = \"Demo requirement\"\n",
            "required_stages = [\"doc\", \"impl\", \"unit\", \"int\"]\n",
        ),
    );
    write(
        &dir,
        "docs/demo.md",
        "<!-- [doc->REQ-DEMO-01] -->\n# Demo\n",
    );
    write(
        &dir,
        "apps/server/src/foo.ts",
        "export const foo = true; // [impl->REQ-DEMO-01]\n",
    );
    write(
        &dir,
        "apps/server/test/foo.test.ts",
        "test('foo', () => {}); // [unit->REQ-DEMO-01]\n",
    );
    write(
        &dir,
        "apps/server/test/foo.integ.test.ts",
        "test('foo integration', () => {}); // [int->REQ-DEMO-01]\n",
    );
    for root in [".planning", "tools", "scripts", "packages"] {
        std::fs::create_dir_all(dir.join(root)).unwrap();
    }

    let out = Command::new(bin())
        .args(["trace", "REQ-DEMO-01", "--json"])
        .current_dir(&dir)
        .output()
        .expect("run binary");
    assert_eq!(


Implementing in traceable-reqs (own tool, their JS/TS precedent exactly):


3-**NLSpec** (natural-language specification) for a requirements-tracing CLI—organized like [strongdm\attractor](https://github.com/strongdm/attractor): point an agent at the spec to implement the tool or verify behavior.
4-
5:The tool follows the spirit of [OpenFastTrace](https://github.com/itsallcode/openfasttrace): tag-based traces across languages, with a focus on **coding agents** and **CI**.
6-
7-> **Status:** the spec is the primary artifact at the repo root. A reference Rust implementation lives in [`rust/`](rust/) — `cd rust && cargo build` produces `rust/target/debug/traceable-reqs`. Run the binary from a directory that holds `traceable-reqs.toml` (e.g. `cd example && ../rust/target/debug/traceable-reqs check --json`). Issues and PRs welcome.
--
55-## Prior art
56-
57:- [OpenFastTrace](https://github.com/itsallcode/openfasttrace) — tag-based tracing across languages.
58-- [Sphinx-Needs](https://www.sphinx-needs.com/), [Doorstop](https://github.com/doorstop-dev/doorstop), [ReqIF](https://www.omg.org/spec/ReqIF/) — other requirement workflows.
59-
--
74-## Scope
75-
76:The core behavior is intentionally small: required **`traceable-reqs.toml` manifest** with authoritative IDs; comment scanning for Rust, Python, C/C++, JavaScript/TypeScript, Markdown, CMake, and YAML; stages `doc`, `impl`, `unit`, `int`; manifest-driven required stages; pyramid reporting; CLI commands `list`, `trace`, **`check`**, with `--json` for structured output.
77-
78-Additional capabilities such as include/exclude globs, priority gates, custom stage definitions, and stability promises are defined in [**SPEC.md**](SPEC.md). Implementation order is up to the implementor.


Edit

//! Walks the configured scan roots and recognizes the bracketed stage/req-id
//! tokens defined by the spec. For Rust, C/C++, and Python the scanner only
//! considers comment regions (non-comment source text is ignored); Markdown
//! is scanned on every physical line. JavaScript/TypeScript use the same slash
//! comment rules as Rust and C/C++. See SPEC.md §Tags.
//!
//! [doc->REQ-SCAN-LANGS]
//! [doc->REQ-SCAN-JS-TS]

//! Walks the configured scan roots and recognizes the bracketed stage/req-id
//! tokens defined by the spec. For Rust, C/C++, and Python the scanner only
//! considers comment regions (non-comment source text is ignored); Markdown
//! is scanned on every physical line. JavaScript/TypeScript use the same slash
//! comment rules as Rust and C/C++. Shell and PowerShell scripts use `#` line
//! comments (PowerShell `<# ... #>` block comments are not scanned — tag on a
//! `#` line). See SPEC.md §Tags.
//!
//! [doc->REQ-SCAN-LANGS]
//! [doc->REQ-SCAN-JS-TS]
//! [doc->REQ-SCAN-SHELL-PS]


The file C:\Users\decid\Documents\projects\traceable-reqs\rust\src\scan.rs has been updated successfully. (file state is current in your context — no need to Read it back)

//! Walks the configured scan roots and recognizes the bracketed stage/req-id
//! tokens defined by the spec. For Rust, C/C++, and Python the scanner only
//! considers comment regions (non-comment source text is ignored); Markdown
//! is scanned on every physical line. JavaScript/TypeScript use the same slash
//! comment rules as Rust and C/C++. See SPEC.md §Tags.
//!
//! [doc->REQ-SCAN-LANGS]
//! [doc->REQ-SCAN-JS-TS]

//! Walks the configured scan roots and recognizes the bracketed stage/req-id
//! tokens defined by the spec. For Rust, C/C++, and Python the scanner only
//! considers comment regions (non-comment source text is ignored); Markdown
//! is scanned on every physical line. JavaScript/TypeScript use the same slash
//! comment rules as Rust and C/C++. Shell and PowerShell scripts use `#` line
//! comments (PowerShell `<# ... #>` block comments are not scanned — tag on a
//! `#` line). See SPEC.md §Tags.
//!
//! [doc->REQ-SCAN-LANGS]
//! [doc->REQ-SCAN-JS-TS]
//! [doc->REQ-SCAN-SHELL-PS]

-//! comment rules as Rust and C/C++. See SPEC.md §Tags.

+//! comment rules as Rust and C/C++. Shell and PowerShell scripts use `#` line

+//! comments (PowerShell `<# ... #>` block comments are not scanned — tag on a


    Markdown,
    Cmake,
    Yaml,
}

// [impl->REQ-SCAN-LANGS]
// [impl->REQ-SCAN-JS-TS]
fn lang_for(path: &Path) -> Option<Lang> {

    Markdown,
    Cmake,
    Yaml,
    Shell,
}

// [impl->REQ-SCAN-LANGS]
// [impl->REQ-SCAN-JS-TS]
// [impl->REQ-SCAN-SHELL-PS]
fn lang_for(path: &Path) -> Option<Lang> {


    Markdown,
    Cmake,
    Yaml,
}

// [impl->REQ-SCAN-LANGS]
// [impl->REQ-SCAN-JS-TS]
fn lang_for(path: &Path) -> Option<Lang> {

    Markdown,
    Cmake,
    Yaml,
    Shell,
}

// [impl->REQ-SCAN-LANGS]
// [impl->REQ-SCAN-JS-TS]
// [impl->REQ-SCAN-SHELL-PS]
fn lang_for(path: &Path) -> Option<Lang> {

if let Some(name) = path.file_name().and_then(|n| n.to_str()) {


        "cmake" => Lang::Cmake,
        "yml" | "yaml" => Lang::Yaml,

        "cmake" => Lang::Cmake,
        "yml" | "yaml" => Lang::Yaml,
        "sh" | "bash" | "ps1" | "psm1" | "psd1" => Lang::Shell,


        "cmake" => Lang::Cmake,
        "yml" | "yaml" => Lang::Yaml,
        "sh" | "bash" | "ps1" | "psm1" | "psd1" => Lang::Shell,

+        "sh" | "bash" | "ps1" | "psm1" | "psd1" => Lang::Shell,


Lang::Python | Lang::Cmake | Lang::Yaml => match line.find('#') {

        // [impl->REQ-SCAN-SHELL-PS]
        Lang::Python | Lang::Cmake | Lang::Yaml | Lang::Shell => match line.find('#') {


        // [impl->REQ-SCAN-SHELL-PS]
        Lang::Python | Lang::Cmake | Lang::Yaml | Lang::Shell => match line.find('#') {

fn comment_regions<'a>(line: &'a str, lang: Lang, in_block: &mut bool) -> Vec<&'a str> {

-        Lang::Python | Lang::Cmake | Lang::Yaml => match line.find('#') {

+        Lang::Python | Lang::Cmake | Lang::Yaml | Lang::Shell => match line.find('#') {


Unit tests + lang_for assertions:


    // [unit->REQ-SCAN-LANGS]
    // [unit->REQ-SCAN-JS-TS]
    #[test]
    fn lang_for_recognises_each_supported_extension() {

    // [unit->REQ-SCAN-LANGS]
    // [unit->REQ-SCAN-JS-TS]
    // [unit->REQ-SCAN-SHELL-PS]
    #[test]
    fn lang_for_recognises_each_supported_extension() {


    // [unit->REQ-SCAN-LANGS]
    // [unit->REQ-SCAN-JS-TS]
    #[test]
    fn lang_for_recognises_each_supported_extension() {

    // [unit->REQ-SCAN-LANGS]
    // [unit->REQ-SCAN-JS-TS]
    // [unit->REQ-SCAN-SHELL-PS]
    #[test]
    fn lang_for_recognises_each_supported_extension() {


        assert_eq!(lang_for(Path::new("ci.yml")), Some(Lang::Yaml));
        assert_eq!(lang_for(Path::new("ci.yaml")), Some(Lang::Yaml));
        assert_eq!(lang_for(Path::new("a.txt")), None);
        assert_eq!(lang_for(Path::new("Cargo.toml")), None);
    }

        assert_eq!(lang_for(Path::new("ci.yml")), Some(Lang::Yaml));
        assert_eq!(lang_for(Path::new("ci.yaml")), Some(Lang::Yaml));
        assert_eq!(lang_for(Path::new("install.sh")), Some(Lang::Shell));
        assert_eq!(lang_for(Path::new("setup.bash")), Some(Lang::Shell));
        assert_eq!(lang_for(Path::new("install.ps1")), Some(Lang::Shell));
        assert_eq!(lang_for(Path::new("module.psm1")), Some(Lang::Shell));
        assert_eq!(lang_for(Path::new("manifest.psd1")), Some(Lang::Shell));
        assert_eq!(lang_for(Path::new("a.txt")), None);
        assert_eq!(lang_for(Path::new("Cargo.toml")), None);
    }


        assert_eq!(lang_for(Path::new("ci.yml")), Some(Lang::Yaml));
        assert_eq!(lang_for(Path::new("ci.yaml")), Some(Lang::Yaml));
        assert_eq!(lang_for(Path::new("a.txt")), None);
        assert_eq!(lang_for(Path::new("Cargo.toml")), None);
    }

        assert_eq!(lang_for(Path::new("ci.yml")), Some(Lang::Yaml));
        assert_eq!(lang_for(Path::new("ci.yaml")), Some(Lang::Yaml));
        assert_eq!(lang_for(Path::new("install.sh")), Some(Lang::Shell));
        assert_eq!(lang_for(Path::new("setup.bash")), Some(Lang::Shell));
        assert_eq!(lang_for(Path::new("install.ps1")), Some(Lang::Shell));
        assert_eq!(lang_for(Path::new("module.psm1")), Some(Lang::Shell));
        assert_eq!(lang_for(Path::new("manifest.psd1")), Some(Lang::Shell));
        assert_eq!(lang_for(Path::new("a.txt")), None);
        assert_eq!(lang_for(Path::new("Cargo.toml")), None);
    }

+        assert_eq!(lang_for(Path::new("install.sh")), Some(Lang::Shell));

+        assert_eq!(lang_for(Path::new("setup.bash")), Some(Lang::Shell));

+        assert_eq!(lang_for(Path::new("install.ps1")), Some(Lang::Shell));

+        assert_eq!(lang_for(Path::new("module.psm1")), Some(Lang::Shell));

+        assert_eq!(lang_for(Path::new("manifest.psd1")), Some(Lang::Shell));


    // [unit->REQ-COMMENT-SCOPE]
    #[test]
    fn markdown_returns_whole_line_as_region() {

    // [unit->REQ-COMMENT-SCOPE]
    // [unit->REQ-SCAN-SHELL-PS]
    #[test]
    fn shell_and_powershell_take_only_text_after_hash() {
        let mut in_block = false;
        let regions = comment_regions(
            "mv -f \"$WORK/$ASSET\" \"$DIR/spt\"  # [impl->REQ-INSTALL-1]",
            Lang::Shell,
            &mut in_block,
        );
        assert_eq!(regions, vec!["# [impl->REQ-INSTALL-1]"]);
        let ps = comment_regions(
            "# [impl->REQ-INSTALL-5] never prompt",
            Lang::Shell,
            &mut in_block,
        );
        assert_eq!(ps, vec!["# [impl->REQ-INSTALL-5] never prompt"]);
        let none = comment_regions("Move-Item -Force $bin $target", Lang::Shell, &mut in_block);
        assert!(none.is_empty());
    }

    // [unit->REQ-COMMENT-SCOPE]
    #[test]
    fn markdown_returns_whole_line_as_region() {


    // [unit->REQ-COMMENT-SCOPE]
    // [unit->REQ-SCAN-SHELL-PS]
    #[test]
    fn shell_and_powershell_take_only_text_after_hash() {
        let mut in_block = false;
        let regions = comment_regions(
            "mv -f \"$WORK/$ASSET\" \"$DIR/spt\"  # [impl->REQ-INSTALL-1]",
            Lang::Shell,
            &mut in_block,
        );
        assert_eq!(regions, vec!["# [impl->REQ-INSTALL-1]"]);
        let ps = comment_regions(
            "# [impl->REQ-INSTALL-5] never prompt",
            Lang::Shell,
            &mut in_block,
        );
        assert_eq!(ps, vec!["# [impl->REQ-INSTALL-5] never prompt"]);
        let none = comment_regions("Move-Item -Force $bin $target", Lang::Shell, &mut in_block);
        assert!(none.is_empty());
    }

    // [unit->REQ-COMMENT-SCOPE]
    #[test]
    fn markdown_returns_whole_line_as_region() {

+    fn shell_and_powershell_take_only_text_after_hash() {

+            "mv -f \"$WORK/$ASSET\" \"$DIR/spt\"  # [impl->REQ-INSTALL-1]",

+        assert_eq!(regions, vec!["# [impl->REQ-INSTALL-1]"]);

+            "# [impl->REQ-INSTALL-5] never prompt",

+        assert_eq!(ps, vec!["# [impl->REQ-INSTALL-5] never prompt"]);

+        let none = comment_regions("Move-Item -Force $bin $target", Lang::Shell, &mut in_block);

let regions = comment_regions("plain text [doc->REQ-A-001]", Lang::Markdown, &mut in_block);


210	    for root in [".planning", "tools", "scripts", "packages"] {
211	        std::fs::create_dir_all(dir.join(root)).unwrap();
212	    }
213
214	    let out = Command::new(bin())
215	        .args(["trace", "REQ-DEMO-01", "--json"])
216	        .current_dir(&dir)
217	        .output()
218	        .expect("run binary");
219	    assert_eq!(
220	        out.status.code(),
221	        Some(0),
222	        "stdout={} stderr={}",
223	        String::from_utf8_lossy(&out.stdout),
224	        String::from_utf8_lossy(&out.stderr),
225	    );
226	    let json: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
227	    let stages = &json["requirements"][0]["stages"];
228	    assert_eq!(
229	        stages["impl"]["evidence"][0]["path"],
230	        "apps/server/src/foo.ts"
231	    );
232	    assert_eq!(
233	        stages["unit"]["evidence"][0]["path"],
234	        "apps/server/test/foo.test.ts"
235	    );
236	    assert_eq!(
237	        stages["int"]["evidence"][0]["path"],
238	        "apps/server/test/foo.integ.test.ts"
239	    );
240	    assert_eq!(json["findings"].as_array().unwrap().len(), 0);
241	}
242

    for root in [".planning", "tools", "scripts", "packages"] {
        std::fs::create_dir_all(dir.join(root)).unwrap();
    }

    let out = Command::new(bin())
        .args(["trace", "REQ-DEMO-01", "--json"])
        .current_dir(&dir)
        .output()
        .expect("run binary");
    assert_eq!(
        out.status.code(),
        Some(0),
        "stdout={} stderr={}",
        String::from_utf8_lossy(&out.stdout),
        String::from_utf8_lossy(&out.stderr),
    );
    let json: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
    let stages = &json["requirements"][0]["stages"];
    assert_eq!(
        stages["impl"]["evidence"][0]["path"],
        "apps/server/src/foo.ts"
    );
    assert_eq!(
        stages["unit"]["evidence"][0]["path"],
        "apps/server/test/foo.test.ts"
    );
    assert_eq!(
        stages["int"]["evidence"][0]["path"],
        "apps/server/test/foo.integ.test.ts"
    );
    assert_eq!(json["findings"].as_array().unwrap().len(), 0);
}


    assert_eq!(
        stages["int"]["evidence"][0]["path"],
        "apps/server/test/foo.integ.test.ts"
    );
    assert_eq!(json["findings"].as_array().unwrap().len(), 0);
}

    assert_eq!(
        stages["int"]["evidence"][0]["path"],
        "apps/server/test/foo.integ.test.ts"
    );
    assert_eq!(json["findings"].as_array().unwrap().len(), 0);
}

#[test]
// [int->REQ-SCAN-SHELL-PS]
fn shell_and_powershell_tags_are_scanned() {
    let dir = tempdir();
    write(
        &dir,
        "traceable-reqs.toml",
        concat!(
            "[scan]\n",
            "roots = [\"docs\", \"installer\", \"tests\"]\n",
            "[[requirements]]\n",
            "id = \"REQ-DEMO-02\"\n",
            "title = \"Demo installer requirement\"\n",
            "required_stages = [\"doc\", \"impl\", \"int\"]\n",
        ),
    );
    write(
        &dir,
        "docs/install.md",
        "<!-- [doc->REQ-DEMO-02] -->\n# Install\n",
    );
    // sh: tag both as a full-line comment and trailing a command.
    write(
        &dir,
        "installer/install.sh",
        "#!/bin/sh\n# [impl->REQ-DEMO-02]\nmv -f \"$a\" \"$b\"\n",
    );
    // ps1: hash line comment.
    write(
        &dir,
        "installer/install.ps1",
        "$x = 1 # [impl->REQ-DEMO-02]\n",
    );
    write(
        &dir,
        "tests/install_e2e.bash",
        "# [int->REQ-DEMO-02]\nrun_the_thing\n",
    );

    let out = Command::new(bin())
        .args(["trace", "REQ-DEMO-02", "--json"])
        .current_dir(&dir)
        .output()
        .expect("run binary");
    assert_eq!(
        out.status.code(),
        Some(0),
        "stdout={} stderr={}",
        String::from_utf8_lossy(&out.stdout),
        String::from_utf8_lossy(&out.stderr),
    );
    let json: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
    let stages = &json["requirements"][0]["stages"];
    let impl_paths: Vec<&str> = stages["impl"]["evidence"]
        .as_array()
        .unwrap()
        .iter()
        .map(|e| e["path"].as_str().unwrap())
        .collect();
    assert!(impl_paths.contains(&"installer/install.sh"));
    assert!(impl_paths.contains(&"installer/install.ps1"));
    assert_eq!(
        stages["int"]["evidence"][0]["path"],
        "tests/install_e2e.bash"
    );
    assert_eq!(json["findings"].as_array().unwrap().len(), 0);
}


The file C:\Users\decid\Documents\projects\traceable-reqs\rust\tests\issues.rs has been updated successfully. (file state is current in your context — no need to Read it back)

    assert_eq!(
        stages["int"]["evidence"][0]["path"],
        "apps/server/test/foo.integ.test.ts"
    );
    assert_eq!(json["findings"].as_array().unwrap().len(), 0);
}

#[test]
// [int->REQ-SCAN-SHELL-PS]
fn shell_and_powershell_tags_are_scanned() {
    let dir = tempdir();
    write(
        &dir,
        "traceable-reqs.toml",
        concat!(
            "[scan]\n",
            "roots = [\"docs\", \"installer\", \"tests\"]\n",
            "[[requirements]]\n",
            "id = \"REQ-DEMO-02\"\n",
            "title = \"Demo installer requirement\"\n",
            "required_stages = [\"doc\", \"impl\", \"int\"]\n",
        ),
    );
    write(
        &dir,
        "docs/install.md",
        "<!-- [doc->REQ-DEMO-02] -->\n# Install\n",
    );
    // sh: tag both as a full-line comment and trailing a command.
    write(
        &dir,
        "installer/install.sh",
        "#!/bin/sh\n# [impl->REQ-DEMO-02]\nmv -f \"$a\" \"$b\"\n",
    );
    // ps1: hash line comment.
    write(
        &dir,
        "installer/install.ps1",
        "$x = 1 # [impl->REQ-DEMO-02]\n",
    );
    write(
        &dir,
        "tests/install_e2e.bash",
        "# [int->REQ-DEMO-02]\nrun_the_thing\n",
    );

    let out = Command::new(bin())
        .args(["trace", "REQ-DEMO-02", "--json"])
        .current_dir(&dir)
        .output()
        .expect("run binary");
    assert_eq!(
        out.status.code(),
        Some(0),
        "stdout={} stderr={}",
        String::from_utf8_lossy(&out.stdout),
        String::from_utf8_lossy(&out.stderr),
    );
    let json: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
    let stages = &json["requirements"][0]["stages"];
    let impl_paths: Vec<&str> = stages["impl"]["evidence"]
        .as_array()
        .unwrap()
        .iter()
        .map(|e| e["path"].as_str().unwrap())
        .collect();
    assert!(impl_paths.contains(&"installer/install.sh"));
    assert!(impl_paths.contains(&"installer/install.ps1"));
    assert_eq!(
        stages["int"]["evidence"][0]["path"],
        "tests/install_e2e.bash"
    );
    assert_eq!(json["findings"].as_array().unwrap().len(), 0);
}

//! Regression tests for behavior requested in repository issues.

use std::path::{Path, PathBuf};
use std::process::Command;

fn bin() -> &'static str {
    env!("CARGO_BIN_EXE_traceable-reqs")
}

fn tempdir() -> PathBuf {
    let pid = std::process::id();
    let nanos = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap()
        .as_nanos();
    let dir = std::env::temp_dir().join(format!("tr-issues-{}-{}", pid, nanos));
    std::fs::create_dir_all(&dir).unwrap();
    dir
}

fn write(dir: &Path, rel: &str, body: &str) {
    let path = dir.join(rel);
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent).unwrap();
    }
    std::fs::write(path, body).unwrap();
}

#[test]
// [int->REQ-PY-DOCSTRING-SCAN]
fn python_docstring_tags_are_scanned() {
    let dir = tempdir();
    write(
        &dir,
        "traceable-reqs.toml",
        concat!(
            "[scan]\n",
            "roots = [\"src\"]\n",
            "[[requirements]]\n",
            "id = \"REQ-PY-DOCSTRING\"\n",
            "required_stages = [\"impl\"]\n",
        ),
    );
    write(
        &dir,
        "src/code.py",
        concat!(
            "def in_docstring():\n",
            "    \"\"\"Implementation lives here.\n",
            "\n",
            "    [impl->REQ-PY-DOCSTRING]\n",
            "    \"\"\"\n",
            "    return True\n",
        ),
    );

    let out = Command::new(bin())
        .args(["check", "--json"])
        .current_dir(&dir)
        .output()
        .expect("run binary");
    assert_eq!(
        out.status.code(),
        Some(0),
        "stdout={} stderr={}",
        String::from_utf8_lossy(&out.stdout),
        String::from_utf8_lossy(&out.stderr),
    );
    let json: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
    let evidence = &json["requirements"][0]["stages"]["impl"]["evidence"];
    assert_eq!(evidence[0]["path"], "src/code.py");
    assert_eq!(evidence[0]["line"], 4);
}

#[test]
// [int->REQ-MARKDOWN-TAG-CANDIDATE]
fn markdown_mermaid_arrow_labels_do_not_emit_parse_error() {
    let dir = tempdir();
    write(
        &dir,
        "traceable-reqs.toml",
        concat!(
            "[scan]\n",
            "roots = [\"docs\"]\n",
            "[[requirements]]\n",
            "id = \"REQ-DIAGRAM\"\n",
            "required_stages = []\n",
        ),
    );
    write(
        &dir,
        "docs/pipeline.md",
        concat!(
            "# Pipeline\n",
            "\n",
            "```mermaid\n",
            "graph LR\n",
            "  A[\"rgb24 -> yuv420p -> H.264\"] --> B\n",
            "```\n",
        ),
    );

    let out = Command::new(bin())
        .args(["check", "--json"])
        .current_dir(&dir)
        .output()
        .expect("run binary");
    assert_eq!(
        out.status.code(),
        Some(0),
        "stdout={} stderr={}",
        String::from_utf8_lossy(&out.stdout),
        String::from_utf8_lossy(&out.stderr),
    );
    let json: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
    assert_eq!(json["findings"].as_array().unwrap().len(), 0);
}

#[test]
// [int->REQ-MANIFEST-TYPES]
fn type_filter_scopes_check_to_selected_requirement_type() {
    let dir = tempdir();
    write(
        &dir,
        "traceable-reqs.toml",
        concat!(
            "[scan]\n",
            "roots = [\"src\"]\n",
            "[types]\n",
            "FR = { description = \"Functional\" }\n",
            "NFR = { description = \"Non-functional\" }\n",
            "[[requirements]]\n",
            "id = \"REQ-FR-LOGIN\"\n",
            "type = \"FR\"\n",
            "required_stages = [\"impl\"]\n",
            "[[requirements]]\n",
            "id = \"REQ-NFR-LATENCY\"\n",
            "type = \"NFR\"\n",
            "required_stages = [\"impl\"]\n",
        ),
    );
    write(&dir, "src/login.rs", "// [impl->REQ-FR-LOGIN]\n");

    let fr = Command::new(bin())
        .args(["check", "--type", "FR", "--json"])
        .current_dir(&dir)
        .output()
        .expect("run binary");
    assert_eq!(
        fr.status.code(),
        Some(0),
        "FR scope should ignore NFR missing impl: stdout={} stderr={}",
        String::from_utf8_lossy(&fr.stdout),
        String::from_utf8_lossy(&fr.stderr),
    );
    let fr_json: serde_json::Value = serde_json::from_slice(&fr.stdout).unwrap();
    assert_eq!(fr_json["requirements"].as_array().unwrap().len(), 1);
    assert_eq!(fr_json["requirements"][0]["type"], "FR");
    assert_eq!(fr_json["findings"].as_array().unwrap().len(), 0);

    let nfr = Command::new(bin())
        .args(["check", "--type", "NFR", "--json"])
        .current_dir(&dir)
        .output()
        .expect("run binary");
    assert_eq!(nfr.status.code(), Some(1));
    let nfr_json: serde_json::Value = serde_json::from_slice(&nfr.stdout).unwrap();
    assert_eq!(nfr_json["requirements"].as_array().unwrap().len(), 1);
    assert_eq!(nfr_json["requirements"][0]["type"], "NFR");
    assert_eq!(nfr_json["findings"][0]["code"], "missing_stage");
    assert_eq!(nfr_json["findings"][0]["requirementId"], "REQ-NFR-LATENCY");
}

#[test]
// [int->REQ-SCAN-JS-TS]
fn typescript_tags_under_later_scan_roots_are_scanned() {
    let dir = tempdir();
    write(
        &dir,
        "traceable-reqs.toml",
        concat!(
            "[scan]\n",
            "roots = [\"docs\", \".planning\", \"tools\", \"scripts\", \"packages\", \"apps\"]\n",
            "[[requirements]]\n",
            "id = \"REQ-DEMO-01\"\n",
            "title = \"Demo requirement\"\n",
            "required_stages = [\"doc\", \"impl\", \"unit\", \"int\"]\n",
        ),
    );
    write(
        &dir,
        "docs/demo.md",
        "<!-- [doc->REQ-DEMO-01] -->\n# Demo\n",
    );
    write(
        &dir,
        "apps/server/src/foo.ts",
        "export const foo = true; // [impl->REQ-DEMO-01]\n",
    );
    write(
        &dir,
        "apps/server/test/foo.test.ts",
        "test('foo', () => {}); // [unit->REQ-DEMO-01]\n",
    );
    write(
        &dir,
        "apps/server/test/foo.integ.test.ts",
        "test('foo integration', () => {}); // [int->REQ-DEMO-01]\n",
    );
    for root in [".planning", "tools", "scripts", "packages"] {
        std::fs::create_dir_all(dir.join(root)).unwrap();
    }

    let out = Command::new(bin())
        .args(["trace", "REQ-DEMO-01", "--json"])
        .current_dir(&dir)
        .output()
        .expect("run binary");
    assert_eq!(
        out.status.code(),
        Some(0),
        "stdout={} stderr={}",
        String::from_utf8_lossy(&out.stdout),
        String::from_utf8_lossy(&out.stderr),
    );
    let json: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
    let stages = &json["requirements"][0]["stages"];
    assert_eq!(
        stages["impl"]["evidence"][0]["path"],
        "apps/server/src/foo.ts"
    );
    assert_eq!(
        stages["unit"]["evidence"][0]["path"],
        "apps/server/test/foo.test.ts"
    );
    assert_eq!(
        stages["int"]["evidence"][0]["path"],
        "apps/server/test/foo.integ.test.ts"
    );
    assert_eq!(json["findings"].as_array().unwrap().len(), 0);
}

+            "roots = [\"docs\", \"installer\", \"tests\"]\n",

+            "title = \"Demo installer requirement\"\n",

+            "required_stages = [\"doc\", \"impl\", \"int\"]\n",

+    // sh: tag both as a full-line comment and trailing a command.

+        "# [int->REQ-DEMO-02]\nrun_the_thing\n",

+        .args(["trace", "REQ-DEMO-02", "--json"])

+        String::from_utf8_lossy(&out.stdout),

+        String::from_utf8_lossy(&out.stderr),

+    let json: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();

+    let stages = &json["requirements"][0]["stages"];

+    let impl_paths: Vec<&str> = stages["impl"]["evidence"]

+        .map(|e| e["path"].as_str().unwrap())

+    assert!(impl_paths.contains(&"installer/install.sh"));

+    assert!(impl_paths.contains(&"installer/install.ps1"));

+        stages["int"]["evidence"][0]["path"],

+    assert_eq!(json["findings"].as_array().unwrap().len(), 0);


SPEC + manifest + README + version bump:


10
11	An implementation of the core behavior must:
12
13	1. Read `traceable-reqs.toml` from the current working directory.
14	2. Treat the current working directory as the repository root.
15	3. Treat manifest `[[requirements]].id` values as the authoritative set of declared requirement IDs.
16	4. Scan Rust, Python, C/C++, JavaScript/TypeScript, Markdown, CMake (`.cmake`, `CMakeLists.txt`), and YAML (`.yml`, `.yaml`) files under configured roots.
17	5. Parse tags of the form `[stage->REQ-ID]` with no internal spaces.
18	6. Recognize only these stage keywords: `doc`, `impl`, `unit`, `int`.
19	7. Accept requirement IDs matching `REQ-[A-Z0-9]+(-[A-Z0-9]+)*`.
20	8. Accept multiple tags on one scanned file line and record each valid tag independently.
21	9. Evaluate completeness against the stages required by the manifest policy for each requirement.
22	10. Report findings with stable finding codes.
23	11. Provide structured JSON output for requirements, stage status, and findings.
24	12. Present human, agent, and machine output in pyramid form: summary first, requirement-level status next, detail available for drill-down.

An implementation of the core behavior must:

1. Read `traceable-reqs.toml` from the current working directory.
2. Treat the current working directory as the repository root.
3. Treat manifest `[[requirements]].id` values as the authoritative set of declared requirement IDs.
4. Scan Rust, Python, C/C++, JavaScript/TypeScript, Markdown, CMake (`.cmake`, `CMakeLists.txt`), and YAML (`.yml`, `.yaml`) files under configured roots.
5. Parse tags of the form `[stage->REQ-ID]` with no internal spaces.
6. Recognize only these stage keywords: `doc`, `impl`, `unit`, `int`.
7. Accept requirement IDs matching `REQ-[A-Z0-9]+(-[A-Z0-9]+)*`.
8. Accept multiple tags on one scanned file line and record each valid tag independently.
9. Evaluate completeness against the stages required by the manifest policy for each requirement.
10. Report findings with stable finding codes.
11. Provide structured JSON output for requirements, stage status, and findings.
12. Present human, agent, and machine output in pyramid form: summary first, requirement-level status next, detail available for drill-down.


4. Scan Rust, Python, C/C++, JavaScript/TypeScript, Markdown, CMake (`.cmake`, `CMakeLists.txt`), and YAML (`.yml`, `.yaml`) files under configured roots.

4. Scan Rust, Python, C/C++, JavaScript/TypeScript, Markdown, CMake (`.cmake`, `CMakeLists.txt`), YAML (`.yml`, `.yaml`), shell (`.sh`, `.bash`), and PowerShell (`.ps1`, `.psm1`, `.psd1`) files under configured roots.


The file C:\Users\decid\Documents\projects\traceable-reqs\SPEC.md has been updated successfully. (file state is current in your context — no need to Read it back)

1. Read `traceable-reqs.toml` from the current working directory.

2. Treat the current working directory as the repository root.

3. Treat manifest `[[requirements]].id` values as the authoritative set of declared requirement IDs.

-4. Scan Rust, Python, C/C++, JavaScript/TypeScript, Markdown, CMake (`.cmake`, `CMakeLists.txt`), and YAML (`.yml`, `.yaml`) files under configured roots.

+4. Scan Rust, Python, C/C++, JavaScript/TypeScript, Markdown, CMake (`.cmake`, `CMakeLists.txt`), YAML (`.yml`, `.yaml`), shell (`.sh`, `.bash`), and PowerShell (`.ps1`, `.psm1`, `.psd1`) files under configured roots.

5. Parse tags of the form `[stage->REQ-ID]` with no internal spaces.

6. Recognize only these stage keywords: `doc`, `impl`, `unit`, `int`.

7. Accept requirement IDs matching `REQ-[A-Z0-9]+(-[A-Z0-9]+)*`.


- Python `#` comments and triple-quoted documentation strings (`"""..."""` and `'''...'''`) are scanned by physical line.

- Python `#` comments and triple-quoted documentation strings (`"""..."""` and `'''...'''`) are scanned by physical line.
- Shell and PowerShell `#` line comments are scanned by physical line (text from the first `#` to end of line). PowerShell `<# ... #>` block comments are not scanned — place tags on `#` lines.


- Python `#` comments and triple-quoted documentation strings (`"""..."""` and `'''...'''`) are scanned by physical line.
- Shell and PowerShell `#` line comments are scanned by physical line (text from the first `#` to end of line). PowerShell `<# ... #>` block comments are not scanned — place tags on `#` lines.

- Each physical file line is one evidence location.

- Rust, C, C++, JavaScript, and TypeScript line comments and block comments are scanned by physical line. A line is in block-comment context if it lies between an open `/*` and its matching `*/`, inclusive of both.

+- Shell and PowerShell `#` line comments are scanned by physical line (text from the first `#` to end of line). PowerShell `<# ... #>` block comments are not scanned — place tags on `#` lines.

- Markdown files are scanned one physical line at a time, including Markdown text and HTML comments.

- Tag-shaped text in non-comment source code is ignored for Rust, C, C++, JavaScript, TypeScript, and Python.

- Markdown diagram labels or prose such as `["rgb24 -> yuv420p"]` are not tag-shaped unless the bracketed text begins with a stage-name-shaped token before `->`.


id = "REQ-SCAN-LANGS"
title = "Scan files in the supported languages (Rust, Python, C/C++, JavaScript/TypeScript, Markdown, CMake, YAML) under configured scan roots"
required_stages = ["doc", "impl", "unit", "int"]

[[requirements]]
id = "REQ-SCAN-JS-TS"
title = "Scan JavaScript and TypeScript line and block comments under configured scan roots"
required_stages = ["doc", "impl", "unit", "int"]

id = "REQ-SCAN-LANGS"
title = "Scan files in the supported languages (Rust, Python, C/C++, JavaScript/TypeScript, Markdown, CMake, YAML, shell, PowerShell) under configured scan roots"
required_stages = ["doc", "impl", "unit", "int"]

[[requirements]]
id = "REQ-SCAN-JS-TS"
title = "Scan JavaScript and TypeScript line and block comments under configured scan roots"
required_stages = ["doc", "impl", "unit", "int"]

[[requirements]]
id = "REQ-SCAN-SHELL-PS"
title = "Scan shell (.sh/.bash) and PowerShell (.ps1/.psm1/.psd1) hash line comments under configured scan roots"
required_stages = ["doc", "impl", "unit", "int"]


The file C:\Users\decid\Documents\projects\traceable-reqs\rust\traceable-reqs.toml has been updated successfully. (file state is current in your context — no need to Read it back)

id = "REQ-SCAN-LANGS"
title = "Scan files in the supported languages (Rust, Python, C/C++, JavaScript/TypeScript, Markdown, CMake, YAML, shell, PowerShell) under configured scan roots"
required_stages = ["doc", "impl", "unit", "int"]

[[requirements]]
id = "REQ-SCAN-JS-TS"
title = "Scan JavaScript and TypeScript line and block comments under configured scan roots"
required_stages = ["doc", "impl", "unit", "int"]

[[requirements]]
id = "REQ-SCAN-SHELL-PS"
title = "Scan shell (.sh/.bash) and PowerShell (.ps1/.psm1/.psd1) hash line comments under configured scan roots"
required_stages = ["doc", "impl", "unit", "int"]

# Requirements manifest for the Rust reference implementation.
#
# Each requirement maps to a section of the project's SPEC.md (the NLSpec one
# directory above). Doc evidence lives in module-level rustdoc inside this
# crate; SPEC.md itself is intentionally not scanned (the spec carries example
# tags inside code fences that would otherwise flag as `undeclared_id`).
#
# Run from this directory: `cd rust && target/debug/traceable-reqs check`.

[scan]
roots = ["src", "tests"]

[policy]
required_stages = ["doc", "impl", "unit"]

[[requirements]]
id = "REQ-MANIFEST-LOAD"
title = "Load traceable-reqs.toml from the current working directory"
required_stages = ["doc", "impl", "unit", "int"]

[[requirements]]
id = "REQ-MANIFEST-VALIDATE-DUP"
title = "Reject manifests containing duplicate requirement ids with manifest_error"
required_stages = ["doc", "impl", "unit", "int"]

[[requirements]]
id = "REQ-MANIFEST-VALIDATE-STAGES"
title = "Reject manifests containing unknown stage names with manifest_error"
required_stages = ["doc", "impl", "unit", "int"]

[[requirements]]
id = "REQ-MANIFEST-VALIDATE-ROOTS"
title = "Reject manifests whose scan roots escape the manifest dir or are absolute with manifest_error"
required_stages = ["doc", "impl", "unit", "int"]

[[requirements]]
id = "REQ-SCAN-LANGS"
title = "Scan files in the supported languages (Rust, Python, C/C++, JavaScript/TypeScript, Markdown, CMake, YAML) under configured scan roots"
required_stages = ["doc", "impl", "unit", "int"]

[[requirements]]
id = "REQ-SCAN-JS-TS"
title = "Scan JavaScript and TypeScript line and block comments under configured scan roots"
required_stages = ["doc", "impl", "unit", "int"]

[[requirements]]
id = "REQ-TAG-GRAMMAR-PARSE"
title = "Recognise [stage->REQ-ID] tokens as evidence tags during scanning"
required_stages = ["doc", "impl", "unit", "int"]

[[requirements]]
id = "REQ-TAG-GRAMMAR-REJECT"
title = "Emit parse_error for bracketed tokens that contain '->' but do not match the tag grammar"
required_stages = ["doc", "impl", "unit", "int"]

[[requirements]]
id = "REQ-MARKDOWN-TAG-CANDIDATE"
title = "Ignore Markdown arrow text that is not shaped like a requirement tag"
required_stages = ["doc", "impl", "unit", "int"]

[[requirements]]
id = "REQ-COMMENT-SCOPE"
title = "Restrict tag scanning to comments for Rust, C/C++, Python"

[[requirements]]
id = "REQ-PY-DOCSTRING-SCAN"
title = "Scan Python triple-quoted documentation strings for requirement tags"
required_stages = ["doc", "impl", "unit", "int"]

[[requirements]]
id = "REQ-BLOCK-COMMENT"
title = "Scan Rust/C/C++ block comments by physical line, with /* ... */ inclusive of both ends"
required_stages = ["doc", "impl", "unit", "int"]

[[requirements]]
id = "REQ-COMPLETENESS"
title = "Emit missing_stage findings for stages declared in required_stages but not covered by tags"
required_stages = ["doc", "impl", "unit", "int"]

[[requirements]]
id = "REQ-FINDINGS"
title = "Emit stable finding codes (missing_stage, undeclared_id, parse_error, scan_error, manifest_error)"
required_stages = ["doc", "impl", "unit", "int"]

[[requirements]]
id = "REQ-JSON-OUTPUT"
title = "Render JSON output conforming to schemaVersion=1 (summary, requirements, findings)"
required_stages = ["doc", "impl", "unit", "int"]

[[requirements]]
id = "REQ-CMD-LIST"
title = "Provide list command with --missing OR semantics over required-and-incomplete stages"
required_stages = ["doc", "impl", "unit", "int"]

[[requirements]]
id = "REQ-CMD-TRACE-DATA"
title = "Render the per-stage evidence report for a single requirement under the trace command"
required_stages = ["doc", "impl", "unit", "int"]

[[requirements]]
id = "REQ-CMD-TRACE-EXIT"
title = "Exit non-zero from the trace command when the requested id is undeclared"
required_stages = ["doc", "impl", "unit", "int"]

[[requirements]]
id = "REQ-CMD-CHECK"
title = "Provide check command that exits non-zero when any finding exists"
required_stages = ["doc", "impl", "unit", "int"]

[[requirements]]
id = "REQ-CMD-LINT-DETERMINISTIC"
title = "Emit requirement_quality findings from deterministic rubric checks under the lint command"
required_stages = ["doc", "impl", "unit", "int"]

[[requirements]]
id = "REQ-CMD-LINT-PROMPT"
title = "Render an agent-review prompt for the open rubric items under the lint command"
required_stages = ["doc", "impl", "unit", "int"]

[[requirements]]
id = "REQ-MANIFEST-CUSTOM-STAGES"
title = "Accept manifest-declared custom stages from [stages.<name>] tables"
required_stages = ["doc", "impl", "unit", "int"]

[[requirements]]
id = "REQ-MANIFEST-TYPES"
title = "Accept optional requirement type metadata and filter list/check by type"
required_stages = ["doc", "impl", "unit", "int"]

[[requirements]]
id = "REQ-SIGNOFF-EVIDENCE"
title = "Accept manifest-declared [[signoffs]] entries as external URL evidence"
required_stages = ["doc", "impl", "unit", "int"]

[[requirements]]
id = "REQ-CMD-REVIEW-PROMPT"
title = "Render an agent-review prompt for tag placements under the review command"
required_stages = ["doc", "impl", "unit", "int"]

[[requirements]]
id = "REQ-CMD-CHANGED-SINCE-FILTER"
title = "Scope agent-evaluated commands to changes since a given git ref via --changed-since"
required_stages = ["doc", "impl", "unit", "int"]

[[requirements]]
id = "REQ-PRIORITY-LEVELS"
title = "Honour MoSCoW priority on requirements via the --fail-on threshold for check"
required_stages = ["doc", "impl", "unit", "int"]

-title = "Scan files in the supported languages (Rust, Python, C/C++, JavaScript/TypeScript, Markdown, CMake, YAML) under configured scan roots"

+title = "Scan files in the supported languages (Rust, Python, C/C++, JavaScript/TypeScript, Markdown, CMake, YAML, shell, PowerShell) under configured scan roots"

+title = "Scan shell (.sh/.bash) and PowerShell (.ps1/.psm1/.psd1) hash line comments under configured scan roots"

+required_stages = ["doc", "impl", "unit", "int"]