Add retry loop for truncated LLM analysis output (#149)

* security: fix XSS, YAML injection, path traversal, and workflow permissions - hugo.toml: set unsafe=false to prevent raw HTML in AI-generated content from executing on the published site (XSS via Goldmark renderer) - scripts/generate_content.py: quote every tag/category item in rendered YAML frontmatter via yaml_quote(); previously a crafted tag like 'ai, categories: [injected]' could break the YAML structure - scripts/topic_paths.py: validate topic IDs against a strict regex ([a-z0-9][a-z0-9\-_]{0,63}) before constructing filesystem paths; prevents path traversal via IDs like '../../../etc/passwd' - scripts/render_topic_prompt.py: apply the same topic ID validation and add a Path.resolve() containment check for the wisdom file path - .github/workflows/crawl-and-publish.yml: * Narrow top-level permissions to 'contents: read' (fail-closed default) * Add explicit job-level permissions to crawl (contents:write) and notify (contents:write + discussions:write) jobs * Replace shell-interpolated JSON in webhook step with jq to prevent JSON injection if WEEK or SITE_URL ever contains special characters - tests: 9 new test cases covering YAML quoting (2), topic ID traversal rejection (7), and webhook JSON construction (updated assertion) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Scribe: Process spawn manifest, merge inbox decision, update history - Merged Farnsworth Weekly Headline Review decision from inbox - Appended decision to decisions.md (46,867 → 47,602 bytes) - Deleted farnsworth-weekly-headline-review.md from inbox - Updated Farnsworth history with Scribe processing note - No entries archived (all recent, within 30-day retention) - No history summarization needed Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add retry loop to analysis step for truncated LLM output When the LLM produces a non-deterministically truncated article that fails the quality gate, the workflow now retries up to 3 times before failing. Each retry: - Resets .squad state from failed attempts - Uses isolated per-attempt transcript files - Handles copilot/sanitizer failures gracefully - Suppresses step summary noise from inline gate checks The existing quality-check step remains as the authoritative final gate. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Juan Manuel Servera committed May 21, 2026 at 13:19 UTC 97011c708e21386a8a6977cdca499eb6623ad610
10 files changed +239 -52
.github/workflows/crawl-and-publish.yml
+81 -43
@@ -12,10 +12,7 @@ on:
12 type: boolean
13
14 permissions:
15 - actions: read
16 - contents: write
17 - pages: write
18 - id-token: write
15 + contents: read
16
17 concurrency:
18 group: weekly-crawl
@@ -28,6 +25,9 @@ env:
25 jobs:
26 crawl:
27 runs-on: ubuntu-latest
28 + permissions:
29 + actions: read
30 + contents: write
31
32 steps:
33 - name: Check out repository
@@ -274,7 +274,6 @@ jobs:
274
275 - name: Install Copilot CLI
276 id: install-copilot
277 - continue-on-error: true
277 run: npm install -g @github/copilot
278
279 - name: Run analysis
@@ -304,41 +303,75 @@ jobs:
303 cat "$PRESS_FILE" >> "$PROMPT_FILE"
304 fi
305
307 - # Intentionally rely on Copilot CLI's default model so CI follows the platform-supported default.
308 - if command -v copilot >/dev/null 2>&1 && copilot \
309 - --agent squad \
310 - -p "Farnsworth, read the file at ${PROMPT_FILE} — it contains the weekly data and analysis instructions. Follow them exactly and write the analysis to ${OUTPUT_FILE}." \
311 - -s \
312 - --no-ask-user \
313 - --allow-tool=read \
314 - --allow-tool=write \
315 - --allow-tool=glob \
316 - --allow-tool=grep \
317 - --share=data/metrics/copilot-transcript.md \
318 - > /dev/null; then
319 - ANALYSIS_SOURCE="copilot-cli"
320 - ANALYSIS_MODEL="copilot-default"
321 - elif python3 scripts/analyze_fallback.py \
322 - --raw-json "$WEEK_FILE" \
323 - --output "$OUTPUT_FILE" \
324 - --current-datetime "$CURRENT_DATETIME" \
325 - --press-context "$PRESS_FILE" 2>/dev/null; then
326 - echo "Copilot CLI unavailable or failed; used GitHub Models API."
327 - ANALYSIS_SOURCE="github-models"
328 - ANALYSIS_MODEL="${GITHUB_MODELS_MODEL:-openai/gpt-4o}"
329 - else
330 - echo "Both Copilot CLI and GitHub Models unavailable; using no-AI data summary."
331 - python3 scripts/analyze_fallback.py \
306 + if ! command -v copilot >/dev/null 2>&1; then
307 + echo "::error::Copilot CLI unavailable; rerun crawl-and-publish when Copilot is available."
308 + exit 1
309 + fi
310 +
311 + # Retry loop: LLM output can be non-deterministically truncated,
312 + # so retry up to 3 attempts if the quality gate rejects the article.
313 + MAX_RETRIES=2
314 + ATTEMPT=0
315 + GATE_PASSED=false
316 +
317 + while [ "$GATE_PASSED" = "false" ] && [ "$ATTEMPT" -le "$MAX_RETRIES" ]; do
318 + if [ "$ATTEMPT" -gt 0 ]; then
319 + echo "::warning::Quality gate failed on attempt $ATTEMPT, retrying (attempt $((ATTEMPT + 1))/$((MAX_RETRIES + 1)))..."
320 + rm -f "$OUTPUT_FILE"
321 + # Reset any .squad changes from failed attempt
322 + git checkout -- .squad 2>/dev/null || true
323 + fi
324 +
325 + echo "::notice::Running Copilot analysis attempt $((ATTEMPT + 1))/$((MAX_RETRIES + 1))"
326 + TRANSCRIPT_FILE="data/metrics/copilot-transcript-attempt-${ATTEMPT}.md"
327 + rm -f "$TRANSCRIPT_FILE"
328 +
329 + # Intentionally rely on Copilot CLI's default model so CI follows the platform-supported default.
330 + if ! copilot \
331 + --agent squad \
332 + -p "Farnsworth, read the file at ${PROMPT_FILE} — it contains the weekly data and analysis instructions. Follow them exactly and write the analysis to ${OUTPUT_FILE}." \
333 + -s \
334 + --no-ask-user \
335 + --allow-tool=read \
336 + --allow-tool=write \
337 + --allow-tool=glob \
338 + --allow-tool=grep \
339 + --share="$TRANSCRIPT_FILE" \
340 + > /dev/null; then
341 + echo "::warning::Copilot CLI failed on attempt $((ATTEMPT + 1))"
342 + ATTEMPT=$((ATTEMPT + 1))
343 + continue
344 + fi
345 +
346 + if ! sanitize_agent_output "$OUTPUT_FILE"; then
347 + echo "::warning::Sanitization failed on attempt $((ATTEMPT + 1))"
348 + ATTEMPT=$((ATTEMPT + 1))
349 + continue
350 + fi
351 +
352 + # Inline quality gate check (suppress step summary to avoid noise)
353 + if GITHUB_STEP_SUMMARY="" python3 scripts/analysis_gate.py \
354 + --analysis-file "$OUTPUT_FILE" \
355 --raw-json "$WEEK_FILE" \
333 - --output "$OUTPUT_FILE" \
356 --current-datetime "$CURRENT_DATETIME" \
335 - --press-context "$PRESS_FILE" \
336 - --no-ai
337 - ANALYSIS_SOURCE="no-ai"
338 - ANALYSIS_MODEL="none"
357 + --source copilot-cli 2>&1; then
358 + GATE_PASSED=true
359 + FINAL_TRANSCRIPT="$TRANSCRIPT_FILE"
360 + fi
361 +
362 + ATTEMPT=$((ATTEMPT + 1))
363 + done
364 +
365 + if [ "$GATE_PASSED" = "false" ]; then
366 + echo "::error::Analysis quality gate failed after $((MAX_RETRIES + 1)) attempts. The LLM did not produce a conforming article."
367 + exit 1
368 fi
369
341 - sanitize_agent_output "$OUTPUT_FILE"
370 + ANALYSIS_SOURCE="copilot-cli"
371 + ANALYSIS_MODEL="copilot-default"
372 +
373 + # Copy final transcript to canonical location
374 + cp "$FINAL_TRANSCRIPT" data/metrics/copilot-transcript.md 2>/dev/null || true
375
376 TRANSCRIPT_ARGS=""
377 if [ -f "data/metrics/copilot-transcript.md" ]; then
@@ -355,6 +388,8 @@ jobs:
388 --output-file "$OUTPUT_FILE" \
389 $TRANSCRIPT_ARGS
390 rm -f "$PROMPT_FILE"
391 + # Clean up per-attempt transcripts
392 + rm -f data/metrics/copilot-transcript-attempt-*.md
393 echo "analysis_source=$ANALYSIS_SOURCE" >> "$GITHUB_OUTPUT"
394
395 - name: quality-check
@@ -597,6 +632,7 @@ jobs:
632 runs-on: ubuntu-latest
633 permissions:
634 contents: write
635 + discussions: write
636
637 steps:
638 - uses: actions/checkout@v4
@@ -649,15 +685,17 @@ jobs:
685 run: |
686 SUMMARY=$(ls -t data/analyzed/*-summary.md | head -1)
687 WEEK=$(basename "$SUMMARY" | sed 's/-summary.md//')
652 - SITE_URL="https://jmservera.github.io/SquadScope/weekly/$(echo $WEEK | tr '-' '/' | sed 's/W/w/')/"
653 -
654 - # Post JSON payload (compatible with Discord/Slack webhooks)
688 + SITE_URL="https://jmservera.github.io/SquadScope/weekly/$(echo "$WEEK" | tr '-' '/' | sed 's/W/w/')/"
689 +
690 + # Build JSON payload with jq to prevent injection via WEEK or SITE_URL
691 + PAYLOAD=$(jq -n \
692 + --arg content "📊 **SquadScope Week ${WEEK}** — New tech trends summary published!\n${SITE_URL}" \
693 + --arg username "SquadScope" \
694 + '{content: $content, username: $username}')
695 +
696 curl -s -X POST "$WEBHOOK_URL" \
697 -H "Content-Type: application/json" \
657 - -d "{
658 - \"content\": \"📊 **SquadScope Week $WEEK** — New tech trends summary published!\n$SITE_URL\",
659 - \"username\": \"SquadScope\"
660 - }" || echo "Webhook post failed (non-critical)"
698 + -d "$PAYLOAD" || echo "Webhook post failed (non-critical)"
699
700
701 reskill-check:
.squad/agents/farnsworth/history.md
+2
@@ -12,3 +12,5 @@
12 - The learning loop only matters when lessons are persisted and injected back into the next prompt through shared wisdom and skills.
13 - The squad reskill audit showed repeated charter and history scaffolding across agents; that boilerplate now lives in `minimal-agent-charter`, `agent-history-hygiene`, and `weekly-learning-loop`.
14 - The reskill pass also cut squad agent-doc footprint from 39568 to 12521 bytes, with every charter at or below the 1.5 KB target and the largest histories back under maintenance limits.
15 +- 2026-05-21T12:33:16.507+02:00: Weekly analysis output must use a strong journalistic headline, explicitly state when no press data is available, and keep `Key References` complete so downstream publishing does not inherit placeholder artifacts.
16 +- 2026-05-21T10:38:30Z: Scribe processed spawn manifest; decision on headline review appended to decisions.md and archived from inbox.
.squad/decisions.md
+34
@@ -268,6 +268,17 @@ fi
268 - `data/analyzed/` — recent analysis outputs (quality trend)
269 - `.squad/run-counter.txt` — run history
270
271 +### Decision 7: Weekly Analysis Fail-Fast Policy
272 +
273 +Weekly article generation must only publish Copilot-authored analysis. The workflow now fails immediately if Copilot CLI is unavailable or the analysis call fails; it does not fall back to GitHub Models or no-AI summaries.
274 +
275 +**Enforcement:**
276 +- `scripts/analysis_gate.py` rejects any analysis source other than `copilot-cli`
277 +- The article title must be a journalistic headline, not the generic `Week NN, YYYY Analysis` template
278 +- If Copilot cannot run, the workflow is expected to be rerun later rather than publishing stale content
279 +
280 +**Goal:** prevent generic or stale weekly articles from being published when the preferred analysis agent is unavailable.
281 +
282 **Output:**
283 - `.squad/reskill/YYYY-WNN.md` — improvement recommendations
284 - Optional: PR with proposed changes to agent prompts or pipeline config
@@ -1043,3 +1054,26 @@ Squad agent docs follow a shared minimal-charter and history-hygiene model. Shar
1054 - 1 existing skill upgraded (branch-protection-pr-workflow)
1055 - **Net savings: 68.4% reduction** (39,568 → 12,521 bytes)
1056
1057 +---
1058 +
1059 +# Decision: Farnsworth Weekly Headline Review
1060 +
1061 +**Date:** 2026-05-21T12:33:16.507+02:00
1062 +**Author:** Farnsworth (Analyst)
1063 +**Status:** Implemented
1064 +
1065 +## Context
1066 +
1067 +Week 21 analysis requires both editorial quality and automation compliance. The title and press-fallback handling must satisfy both reader expectations and the analyzer contract.
1068 +
1069 +## Decision
1070 +
1071 +Week 21 analysis should use a journalistic title, not a generic week label, and must keep the no-press fallback explicit when press data is absent.
1072 +
1073 +## Rationale
1074 +
1075 +The published analysis needs to read like an editorial artifact and satisfy the analyzer contract at the same time. A headline plus explicit press fallback keeps the page useful to readers and safe for automation.
1076 +
1077 +## Impact
1078 +
1079 +Applies to future weekly summaries and any generator work that consumes `data/analyzed/*-summary.md`.
hugo.toml
+3 -1
@@ -128,7 +128,9 @@ ignoreFiles = ['data/analyzed/.*\\.md$', 'data/metrics/']
128
129 [markup]
130 [markup.goldmark.renderer]
131 - unsafe = true
131 + # unsafe = false: AI-generated content must not render raw HTML.
132 + # Use Hugo shortcodes or layouts for any intentional HTML embeds.
133 + unsafe = false
134
135 [markup.tableOfContents]
136 startLevel = 2
scripts/generate_content.py
+2 -2
@@ -145,8 +145,8 @@ def render_frontmatter(data: dict[str, object]) -> str:
145 f'title: {yaml_quote(str(data["title"]))}',
146 f'date: {data["date"]}',
147 f'week: {yaml_quote(str(data["week"]))}',
148 - f'tags: [{", ".join(data["tags"])}]',
149 - f'categories: [{", ".join(data["categories"])}]',
148 + f'tags: [{", ".join(yaml_quote(t) for t in data["tags"])}]',
149 + f'categories: [{", ".join(yaml_quote(c) for c in data["categories"])}]',
150 f'repos_featured: {data["repos_featured"]}',
151 f'stars_tracked: {data["stars_tracked"]}',
152 f'top_repo: {yaml_quote(str(data["top_repo"]))}',
scripts/render_topic_prompt.py
+11
@@ -69,8 +69,19 @@ def load_wisdom(topic_id: str | None) -> str:
69 if not topic_id:
70 return ""
71
72 + # Reject topic IDs that could escape the topics/ directory.
73 + import re
74 + if not re.fullmatch(r"[a-z0-9][a-z0-9\-_]{0,63}", topic_id):
75 + return ""
76 +
77 root = find_repo_root()
78 wisdom_path = root / "topics" / topic_id / "wisdom.md"
79 + # Containment check: ensure the resolved path stays inside topics/
80 + topics_root = (root / "topics").resolve()
81 + try:
82 + wisdom_path.resolve().relative_to(topics_root)
83 + except ValueError:
84 + return ""
85 if wisdom_path.exists():
86 return wisdom_path.read_text(encoding="utf-8").strip()
87
scripts/topic_paths.py
+16
@@ -14,6 +14,7 @@ resolve to the legacy flat layout (data/raw/, data/analyzed/, etc.).
14
15 from __future__ import annotations
16
17 +import re
18 from pathlib import Path
19
20 DEFAULT_TOPIC = "general"
@@ -21,12 +22,27 @@ DEFAULT_TOPIC = "general"
22 # Base data root (relative to repo root)
23 DATA_ROOT = Path("data")
24
25 +# Only allow lowercase alphanumeric, hyphens, and underscores; 1–64 chars.
26 +# This prevents path traversal via topic IDs like "../../../etc/passwd".
27 +_VALID_TOPIC_RE = re.compile(r"^[a-z0-9][a-z0-9\-_]{0,63}$")
28 +
29 +
30 +def _validate_topic_id(tid: str) -> None:
31 + """Raise ValueError if tid is not a safe, well-formed topic identifier."""
32 + if not _VALID_TOPIC_RE.match(tid):
33 + raise ValueError(
34 + f"Invalid topic ID: {tid!r}. "
35 + "Must be 1–64 lowercase alphanumeric characters, hyphens, or underscores, "
36 + "and must not start with a hyphen or underscore."
37 + )
38 +
39
40 def _resolve(base: Path, topic_id: str | None) -> Path:
41 """Return namespaced path, creating parents on first access."""
42 tid = (topic_id or DEFAULT_TOPIC).strip().lower()
43 if tid == DEFAULT_TOPIC:
44 return base
45 + _validate_topic_id(tid)
46 return base / tid
47
48
tests/test_generate_content.py
+38
@@ -78,6 +78,44 @@ title: \"Week 21, 2026 Analysis\"
78 """
79 )
80
81 + def test_render_frontmatter_quotes_each_tag(self) -> None:
82 + """Tags containing special chars must be individually quoted to prevent YAML injection."""
83 + data = {
84 + "title": "Test Week",
85 + "date": "2026-05-18",
86 + "week": "2026-W20",
87 + "tags": ["ai", 'evil: injected, categories: [hacked]'],
88 + "categories": ["weekly"],
89 + "repos_featured": 1,
90 + "stars_tracked": 100,
91 + "top_repo": "owner/repo",
92 + "summary": "Test summary.",
93 + }
94 + output = generate_content.render_frontmatter(data)
95 + # Each tag must be wrapped in YAML double-quotes
96 + self.assertIn('"ai"', output)
97 + self.assertIn('"evil: injected, categories: [hacked]"', output)
98 + # The injected key must not appear as a top-level YAML key
99 + self.assertNotIn("\ncategories: [hacked]", output)
100 +
101 + def test_render_frontmatter_quotes_each_category(self) -> None:
102 + """Categories containing special chars must be individually quoted."""
103 + data = {
104 + "title": "Test Week",
105 + "date": "2026-05-18",
106 + "week": "2026-W20",
107 + "tags": ["safe"],
108 + "categories": ["weekly", 'bad: injection'],
109 + "repos_featured": 1,
110 + "stars_tracked": 100,
111 + "top_repo": "owner/repo",
112 + "summary": "Test.",
113 + }
114 + output = generate_content.render_frontmatter(data)
115 + self.assertIn('"weekly"', output)
116 + self.assertIn('"bad: injection"', output)
117 + self.assertNotIn("\nbad:", output)
118 +
119
120 if __name__ == "__main__":
121 unittest.main()
tests/test_pipeline.py
+13 -6
@@ -104,7 +104,7 @@ def make_raw_payload() -> dict:
104
105 def make_analysis_markdown() -> str:
106 return f'''---
107 -title: "Week 21, 2026 Analysis"
107 +title: "Reliable Automation Gains Ground"
108 date: {FIXED_RUN_DATETIME}
109 week: "2026-W21"
110 year: 2026
@@ -195,6 +195,7 @@ class WorkflowConfigTests(unittest.TestCase):
195 install_step = next((s for s in reskill["steps"] if s.get("name") == "Install Copilot CLI"), None)
196 self.assertIsNotNone(install_step)
197 self.assertEqual(install_step["run"], "npm install -g @github/copilot")
198 + self.assertNotIn("continue-on-error", install_step)
199
200 reskill_step = next((s for s in reskill["steps"] if s.get("name") == "Run reskill"), None)
201 self.assertIsNotNone(reskill_step)
@@ -225,6 +226,10 @@ class WorkflowConfigTests(unittest.TestCase):
226 self.assertIn('ANALYSIS_MODEL="copilot-default"', run_analysis)
227 self.assertNotIn("--model claude-sonnet-4", run_analysis)
228 self.assertIn("mkdir -p data/metrics", run_analysis)
229 + self.assertIn("Copilot CLI unavailable; rerun crawl-and-publish when Copilot is available.", run_analysis)
230 + self.assertNotIn("GitHub Models API", run_analysis)
231 + self.assertNotIn("no-AI", run_analysis)
232 + self.assertNotIn("github-models", run_analysis)
233
234 def test_generate_workflow_runs_rollups_and_commits_all_content(self) -> None:
235 workflow_path = Path(".github/workflows/crawl-and-publish.yml")
@@ -278,7 +283,9 @@ class WorkflowConfigTests(unittest.TestCase):
283 webhook_run = webhook_step["run"]
284 self.assertIn("curl -s -X POST \"$WEBHOOK_URL\"", webhook_run)
285 self.assertIn("https://jmservera.github.io/SquadScope/weekly/", webhook_run)
281 - self.assertIn('\\"content\\": \\"📊 **SquadScope Week $WEEK**', webhook_run)
286 + # JSON is now built with jq to prevent injection — check for jq invocation
287 + self.assertIn("jq -n", webhook_run)
288 + self.assertIn("📊 **SquadScope Week", webhook_run)
289 self.assertIn("Webhook post failed (non-critical)", webhook_run)
290
291
@@ -370,7 +377,7 @@ class PipelineIntegrationTests(unittest.TestCase):
377
378 self.assertEqual(output_path, base / "content" / "weekly" / "2026" / "W21.md")
379 rendered = output_path.read_text(encoding="utf-8")
373 - self.assertIn('title: "Week 21, 2026"', rendered)
380 + self.assertIn('title: "Reliable Automation Gains Ground"', rendered)
381 self.assertIn('week: "2026-W21"', rendered)
382 self.assertIn("draft: false", rendered)
383 self.assertNotIn("quality_score", rendered)
@@ -408,7 +415,7 @@ class PipelineIntegrationTests(unittest.TestCase):
415
416 self.assertEqual(exit_code, 0)
417 written = output_path.read_text(encoding="utf-8")
411 - self.assertIn("Week 21, 2026 Analysis", written)
418 + self.assertIn("Reliable Automation Gains Ground", written)
419 self.assertIn("## Signal & Noise", written)
420
421 def test_analysis_gate_validates_analysis_output_correctly(self) -> None:
@@ -433,7 +440,7 @@ class PipelineIntegrationTests(unittest.TestCase):
440 "--current-datetime",
441 FIXED_RUN_DATETIME,
442 "--source",
436 - "integration-test",
443 + "copilot-cli",
444 ]
445 ),
446 0,
@@ -452,7 +459,7 @@ class PipelineIntegrationTests(unittest.TestCase):
459 "--current-datetime",
460 FIXED_RUN_DATETIME,
461 "--source",
455 - "integration-test",
462 + "copilot-cli",
463 ]
464 )
465
tests/test_topic_paths.py
+39
@@ -103,3 +103,42 @@ class TestLoadTopicId:
103 config = tmp_path / "empty.yml"
104 config.write_text("scoring:\n min_stars: 10\n")
105 assert load_topic_id(config) == DEFAULT_TOPIC
106 +
107 +
108 +class TestTopicIdValidation:
109 + """_resolve must reject topic IDs that could cause path traversal."""
110 +
111 + def test_dotdot_raises(self):
112 + from scripts.topic_paths import _resolve, DATA_ROOT
113 + with pytest.raises(ValueError, match="Invalid topic ID"):
114 + _resolve(DATA_ROOT / "raw", "../../../etc")
115 +
116 + def test_absolute_path_raises(self):
117 + from scripts.topic_paths import _resolve, DATA_ROOT
118 + with pytest.raises(ValueError, match="Invalid topic ID"):
119 + _resolve(DATA_ROOT / "raw", "/etc/passwd")
120 +
121 + def test_slash_in_id_raises(self):
122 + from scripts.topic_paths import _resolve, DATA_ROOT
123 + with pytest.raises(ValueError, match="Invalid topic ID"):
124 + _resolve(DATA_ROOT / "raw", "valid/subdir")
125 +
126 + def test_null_byte_raises(self):
127 + from scripts.topic_paths import _resolve, DATA_ROOT
128 + with pytest.raises(ValueError, match="Invalid topic ID"):
129 + _resolve(DATA_ROOT / "raw", "evil\x00byte")
130 +
131 + def test_leading_hyphen_raises(self):
132 + from scripts.topic_paths import _resolve, DATA_ROOT
133 + with pytest.raises(ValueError, match="Invalid topic ID"):
134 + _resolve(DATA_ROOT / "raw", "-bad")
135 +
136 + def test_valid_hyphenated_id_passes(self):
137 + from scripts.topic_paths import _resolve, DATA_ROOT
138 + result = _resolve(DATA_ROOT / "raw", "ai-ml")
139 + assert result == DATA_ROOT / "raw" / "ai-ml"
140 +
141 + def test_valid_underscore_id_passes(self):
142 + from scripts.topic_paths import _resolve, DATA_ROOT
143 + result = _resolve(DATA_ROOT / "raw", "rust_2026")
144 + assert result == DATA_ROOT / "raw" / "rust_2026"