diff --git a/scripts/research_workflow.py b/scripts/research_workflow.py index ee7b82bc5..7079107a0 100644 --- a/scripts/research_workflow.py +++ b/scripts/research_workflow.py @@ -124,6 +124,37 @@ def slug(value): return value +def load_outline(path): + """Load an --outline file, rejecting wrong shapes via fail(). + + Every rejection names the outline file and the field/expectation, so + a malformed outline produces the tool's clean diagnostic instead of + a raw AttributeError/TypeError/JSONDecodeError from deep in + scaffold() (issue #6862). + """ + try: + outline = json.loads(Path(path).read_text()) + except json.JSONDecodeError as error: + fail(f"Outline {path} is not valid JSON: {error}") + if not isinstance(outline, dict): + fail(f"Outline {path} must be a JSON object at the top level") + for field in ("title", "introduction"): + if field in outline and not isinstance(outline[field], str): + fail(f"Outline {path} field '{field}' must be a string") + concepts = outline.get("concepts") + if isinstance(concepts, list): + for index, concept in enumerate(concepts, 1): + if not isinstance(concept, dict): + fail(f"Outline {path} concept {index} must be a JSON object") + for field in ("slug", "summary"): + if field in concept and not isinstance(concept[field], str): + fail( + f"Outline {path} concept {index} field '{field}'" + " must be a string" + ) + return outline + + def marker(kind, data): payload = json.dumps(data, ensure_ascii=False, indent=2).replace("<", "\\u003c") return f"\n{payload}\n\n" @@ -241,7 +272,7 @@ def scaffold(args, root, config): if package.exists() or package.is_symlink(): fail(f"Package already exists: {package}") outline = ( - json.loads(Path(args.outline).read_text()) + load_outline(args.outline) if args.outline else { "title": args.concept or args.slug.replace("-", " ").title(), diff --git a/scripts/tests/test_research_workflow.py b/scripts/tests/test_research_workflow.py index 1d25df87f..9e576fda6 100644 --- a/scripts/tests/test_research_workflow.py +++ b/scripts/tests/test_research_workflow.py @@ -273,6 +273,80 @@ def test_duplicate_concept_slugs_are_rejected_before_any_creation(self): self.init(outline=outline, ok=False) self.assertEqual(self.snapshot(), before) + def test_outline_top_level_must_be_an_object(self): + before = self.snapshot() + result = self.init(outline=[], ok=False) + self.assertIn("outline.json", result.stderr) + self.assertIn("must be a JSON object", result.stderr) + self.assertEqual(self.snapshot(), before) + + def test_outline_non_json_names_the_file(self): + self.outline_file = self.scratch / "outline.json" + self.outline_file.write_text("this is not json", encoding="utf-8") + before = self.snapshot() + result = self.cli( + "init", + "--repo-root", + self.root, + "--input", + self.input, + "--slug", + "assorted-ideas", + "--timestamp", + TIMESTAMP, + "--outline", + self.outline_file, + ok=False, + ) + self.assertIn("outline.json", result.stderr) + self.assertIn("is not valid JSON", result.stderr) + self.assertEqual(self.snapshot(), before) + + def test_outline_title_must_be_a_string(self): + outline = self.outline() + outline["title"] = 42 + before = self.snapshot() + result = self.init(outline=outline, ok=False) + self.assertIn("outline.json", result.stderr) + self.assertIn("'title' must be a string", result.stderr) + self.assertEqual(self.snapshot(), before) + + def test_outline_introduction_must_be_a_string(self): + outline = self.outline() + outline["introduction"] = 42 + before = self.snapshot() + result = self.init(outline=outline, ok=False) + self.assertIn("outline.json", result.stderr) + self.assertIn("'introduction' must be a string", result.stderr) + self.assertEqual(self.snapshot(), before) + + def test_outline_concept_entries_must_be_objects(self): + outline = self.outline() + outline["concepts"][0] = "just-a-string" + before = self.snapshot() + result = self.init(outline=outline, ok=False) + self.assertIn("outline.json", result.stderr) + self.assertIn("concept 1 must be a JSON object", result.stderr) + self.assertEqual(self.snapshot(), before) + + def test_outline_concept_slug_must_be_a_string(self): + outline = self.outline() + outline["concepts"][0]["slug"] = 42 + before = self.snapshot() + result = self.init(outline=outline, ok=False) + self.assertIn("outline.json", result.stderr) + self.assertIn("'slug' must be a string", result.stderr) + self.assertEqual(self.snapshot(), before) + + def test_outline_concept_summary_must_be_a_string(self): + outline = self.outline() + outline["concepts"][0]["summary"] = 42 + before = self.snapshot() + result = self.init(outline=outline, ok=False) + self.assertIn("outline.json", result.stderr) + self.assertIn("'summary' must be a string", result.stderr) + self.assertEqual(self.snapshot(), before) + def test_existing_package_collision_never_overwrites_completed_intake(self): self.init() before = self.snapshot()