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
41 changes: 36 additions & 5 deletions scripts/research_workflow_items.py
Original file line number Diff line number Diff line change
Expand Up @@ -949,15 +949,46 @@ def apply(state, main, items):
return edit(package, apply)


def manifest_segments(manifest):
"""Return the validated segments list from a package.json manifest.

The manifest is shape-checked before use: the top level must be a
JSON object and segments (when present) a list of objects each
with a string id. Any violation — including a manifest that is
not JSON at all — fails via fail() naming package.json and the
field/expectation, never a bare Attribute/Key/Type error.
"""
if not manifest.is_file():
return []
try:
data = json.loads(manifest.read_text())
except (json.JSONDecodeError, UnicodeDecodeError):
fail(
"package.json: manifest is not valid JSON; "
"expected a JSON object with a segments list"
)
if not isinstance(data, dict):
fail(
"package.json: manifest must be a JSON object; "
"expected an object with a segments list"
)
segments = data.get("segments", [])
if not isinstance(segments, list):
fail("package.json: segments must be a list of objects each with a string id")
for segment in segments:
if not isinstance(segment, dict) or not isinstance(segment.get("id"), str):
fail(
"package.json: segments entries must be objects "
"each with a string id"
)
return segments


def list_ids(args, root, config, package):
state, main, _ = load(package)
items = parse_items(main, concept_ids(state), state.get("created_at"))
manifest = package / "package.json"
segments = (
json.loads(manifest.read_text()).get("segments", [])
if manifest.is_file()
else []
)
segments = manifest_segments(manifest)
return {
"package": str(package),
"concepts": [
Expand Down
28 changes: 28 additions & 0 deletions scripts/tests/test_research_workflow_items.py
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,34 @@ def test_ids_lists_concepts_items_notes_and_decisions(self):
self.assertEqual(result["segments"], [])
self.assertEqual(result["reserved"], ["G"])

def test_ids_validates_the_package_manifest_shape(self):
package = self.rendered()
manifest = package / "package.json"
# Controls: a well-formed manifest lists its segment ids, and a
# manifest without segments behaves exactly as no manifest does.
manifest.write_text(
json.dumps({"segments": [{"id": "S1"}, {"id": "S2"}]}) + "\n",
encoding="utf-8",
)
self.assertEqual(self.cli("ids", "--package", package)["segments"], ["S1", "S2"])
manifest.write_text("{}\n", encoding="utf-8")
self.assertEqual(self.cli("ids", "--package", package)["segments"], [])
# Wrong shapes fail via the tool's single-line error contract,
# naming package.json — never a bare Attribute/Key/Type message.
for content in (
"[]",
'"segments"',
'{"segments": "S1"}',
'{"segments": {"id": "S1"}}',
'{"segments": ["S1"]}',
'{"segments": [{"name": "S1"}]}',
'{"segments": [{"id": 3}]}',
"not json",
):
manifest.write_text(content + "\n", encoding="utf-8")
error = self.cli("ids", "--package", package, ok=False)
self.assertIn("package.json", error, content)

# ------------------------------------------------------------- render

def test_render_is_idempotent_and_replaces_init_placeholders(self):
Expand Down