Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion scripts/research_workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"<!-- workflow:{kind} -->\n{payload}\n<!-- /workflow:{kind} -->\n"
Expand Down Expand Up @@ -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(),
Expand Down
74 changes: 74 additions & 0 deletions scripts/tests/test_research_workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down