Skip to content
Open
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
7 changes: 6 additions & 1 deletion scripts/research_workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -893,10 +893,13 @@ def check_package(package, root, require_resolved=False):
owners.append((owned, task["owner"]))
resolutions = {}
for item in items:
document = contained(package, item["path"])
for block in RESOLUTION.findall(
unfenced(contained(package, item["path"]).read_text())
unfenced(document.read_text())
):
entry = json.loads(block)
if not isinstance(entry, dict):
fail(f"{document}: resolution block must be a JSON object")
if entry.get("note") in resolutions:
fail("Duplicate note disposition")
resolutions[entry.get("note")] = (item["id"], entry)
Expand All @@ -918,6 +921,8 @@ def check_package(package, root, require_resolved=False):
if not found:
fail(f"{path}: expected one immutable note marker")
note = json.loads(found[1])
if not isinstance(note, dict):
fail(f"{path}: note marker must be a JSON object")
body = content[found.end() :]
if not body.startswith("\n\n") or hashlib.sha256(
body[2:].encode()
Expand Down
10 changes: 8 additions & 2 deletions scripts/research_workflow_live.py
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,8 @@ def read_note(path, ids):
if not found or c.NOTE.search(content, found.end()):
fail(f"{path}: expected exactly one note marker")
note = json.loads(found[1])
if not isinstance(note, dict):
fail(f"{path}: note marker must be a JSON object")
body = content[found.end() :]
if not body.startswith("\n\n") or hashlib.sha256(
body[2:].encode()
Expand Down Expand Up @@ -464,9 +466,13 @@ def resolve_command(args, root, config, package):
if receipt.exists() or receipt.is_symlink():
fail(f"{key} already has a receipt: receipts/{path.name}")
for concept in state["concepts"]:
text = c.contained(package, concept["path"]).read_text(encoding="utf-8")
document = c.contained(package, concept["path"])
text = document.read_text(encoding="utf-8")
for block in c.RESOLUTION.findall(c.unfenced(text)):
if json.loads(block).get("note") == key:
entry = json.loads(block)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the new receipt payload while scanning resolutions

If any concept contains an existing resolution block for a different note, this assignment overwrites the receipt payload constructed above with that unrelated resolution. receipt_text(note, entry) then writes the previous note's disposition and JSON under the new note's receipt filename, ignoring the requested resolution. Subsequent check rejects the receipt, and retrying resolve-note is blocked because the file already exists. Use a separate variable for the scanned resolution so the validated receipt payload remains intact.

Useful? React with 👍 / 👎.

if not isinstance(entry, dict):
fail(f"{document}: resolution block must be a JSON object")
if entry.get("note") == key:
fail(f"{key} already has a disposition in {concept['path']}")
content = receipt_text(note, entry).encode("utf-8")
if receipt.parent.is_symlink():
Expand Down
44 changes: 44 additions & 0 deletions scripts/tests/test_research_workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -575,6 +575,50 @@ def resolve_note(self, package, note, disposition, reason, ok=True):
ok=ok,
)

def test_check_rejects_non_object_resolution_blocks_with_named_errors(self):
package = self.init()
concept = package / self.state(package)["concepts"][0]["path"]
original = concept.read_bytes().decode("utf-8")
for malformed in ("[]", '"x"', "42"):
with self.subTest(resolution=malformed):
concept.write_bytes(
(
original
+ "\n<!-- workflow:resolution -->\n"
+ malformed
+ "\n<!-- /workflow:resolution -->\n"
).encode("utf-8")
)
result = self.check(package, ok=False)
self.assertIn(
f"{concept}: resolution block must be a JSON object",
result.stderr,
)
self.assertNotIn("AttributeError", result.stderr)

def test_check_rejects_non_object_note_markers_with_named_errors(self):
package = self.init()
note_path = package / self.create_note(package)
original = note_path.read_bytes().decode("utf-8")
marker = re.compile(
r"(<!-- workflow:note -->\s*)(.*?)(\s*<!-- /workflow:note -->)", re.S
)
for malformed in ("[]", '"x"', "42", "null", "[1, 2]"):
with self.subTest(marker=malformed):
content, count = marker.subn(
lambda match: match.group(1) + malformed + match.group(3),
original,
count=1,
)
self.assertEqual(count, 1)
note_path.write_bytes(content.encode("utf-8"))
result = self.check(package, ok=False)
self.assertIn(
f"{note_path}: note marker must be a JSON object",
result.stderr,
)
self.assertNotIn("AttributeError", result.stderr)

def test_every_disposition_passes_require_resolved_via_resolve_note(self):
# The skill names five dispositions (#492): each one, recorded
# through resolve-note, must satisfy check --require-resolved.
Expand Down
41 changes: 41 additions & 0 deletions scripts/tests/test_research_workflow_live.py
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,47 @@ def resolve(self, note=None, ok=True, **fields):
def check(self, ok=True):
return self.cli("check", "--package", self.package, "--require-resolved", ok=ok)

def test_resolve_note_rejects_non_object_note_markers_with_named_errors(self):
note_path = self.package / self.note
original = note_path.read_bytes().decode("utf-8")
marker = re.compile(
r"(<!-- workflow:note -->\s*)(.*?)(\s*<!-- /workflow:note -->)", re.S
)
for malformed in ("[]", '"x"', "42", "null", "[1, 2]"):
with self.subTest(marker=malformed):
content, count = marker.subn(
lambda match: match.group(1) + malformed + match.group(3),
original,
count=1,
)
self.assertEqual(count, 1)
note_path.write_bytes(content.encode("utf-8"))
error = self.resolve(ok=False)
self.assertIn(
f"{note_path}: note marker must be a JSON object", error
)
self.assertNotIn("AttributeError", error)

def test_resolve_note_rejects_non_object_resolution_blocks_with_named_errors(self):
concept = self.package / "concepts/C002-concept-2.md"
original = concept.read_bytes().decode("utf-8")
for malformed in ("[]", '"x"', "42"):
with self.subTest(resolution=malformed):
concept.write_bytes(
(
original
+ "\n<!-- workflow:resolution -->\n"
+ malformed
+ "\n<!-- /workflow:resolution -->\n"
).encode("utf-8")
)
error = self.resolve(ok=False)
self.assertIn(
f"{concept}: resolution block must be a JSON object", error
)
self.assertNotIn("AttributeError", error)
self.assertFalse(self.receipt.exists())

def test_the_receipt_is_its_own_file_and_resolves_the_note(self):
self.check(ok=False)
before = self.snapshot()
Expand Down