Skip to content

Commit 267b03c

Browse files
nhortonclaude
andauthored
fix: parse DeepSchema hook target files as YAML, not JSON (#349)
* fix: parse DeepSchema hook target files as YAML, not JSON The deepschema_write PostToolUse hook used Path.suffix to decide between yaml.safe_load and json.loads. For files like `.deepreview` whose entire name is treated as a hidden-file marker, Path.suffix is "" — so the hook fell through to json.loads and reported "File is not valid JSON" on every write/edit, even though the file was a perfectly valid YAML .deepreview. Fix: parse the target file (and the referenced JSON Schema file) with yaml.safe_load unconditionally. YAML is a superset of JSON, so both formats are accepted regardless of file extension. Mirrors the approach PR #338 already shipped for the workflow quality gate (jobs/mcp/quality_gate.py). Also handle the SchemaError case that becomes possible once a parser accepts free-form text: if the schema file parses to something that isn't a JSON Schema object or boolean, return "Cannot read JSON Schema: not a JSON Schema object" before invoking the validator. Updates DW-REQ-011.7.3 to match. Adds a regression test for dot-prefixed files (the user-reported case) and revises two existing tests that were asserting on the old "File is not valid JSON" / "schema isn't JSON" contracts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: address review findings on YAML parsing fix - Extract `_write_name_required_schema(tmp_path, slug, matcher)` helper in test_write_hook.py and use it across all 5 JSON-Schema-validation tests (removes ~90 lines of fixture duplication). - Convert function docstrings to leading comments on three tests so the traceability `# THIS TEST VALIDATES A HARD REQUIREMENT` comments remain the first thing inside the function body (matches the repo convention established in PR #346). - deepschema_write.py: cast `schema_data` (typed `dict | bool` after the spec-compliant shape check) to `dict[str, Any]` for the call to `validate_against_schema`; `jsonschema.validate` accepts bool schemas at runtime but our wrapper's type signature only declares dict. All 43 tests in the affected files still pass. ruff + mypy clean. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 1091181 commit 267b03c

5 files changed

Lines changed: 111 additions & 93 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2424

2525
### Fixed
2626

27+
- DeepSchema PostToolUse hook (`deepschema_write`) no longer reports `File is not valid JSON` for YAML files whose name has no extension (e.g. `.deepreview`). The hook now parses target files and the referenced JSON Schema as YAML, which is a superset of JSON, so both formats are accepted regardless of file extension. DW-REQ-011.7.3 updated to match. (Mirrors the fix shipped in #338 for the workflow quality gate.)
28+
2729
### Removed
2830
## [0.13.1] - 2026-04-06
2931

specs/deepwork/DW-REQ-011-deepschema.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ The DeepSchema system provides rich, file-level schemas with automatic validatio
5050

5151
1. The write hook MUST fire on PostToolUse events for Write and Edit tools.
5252
2. For each applicable schema, the hook MUST inject a conformance note: "Note: this file must conform to the DeepSchema at `<path>`".
53-
3. If `json_schema_path` is set, the hook MUST validate the written file against the JSON Schema, parsing YAML files (`.yml`/`.yaml`) as YAML before validation.
53+
3. If `json_schema_path` is set, the hook MUST validate the written file against the JSON Schema. The file content MUST be parsed as YAML (which is a superset of JSON), so both YAML and JSON formats are accepted regardless of file extension.
5454
4. If `verification_bash_command` is set, the hook MUST execute each command with the file path as `$1`, with a 30-second timeout.
5555
5. Validation failures MUST be reported via `hookSpecificOutput.additionalContext` so the agent can act on them.
5656
6. The hook MUST NOT use `systemMessage` for validation output — that route is user-visible only.

src/deepwork/hooks/deepschema_write.py

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@
77

88
from __future__ import annotations
99

10-
import json
1110
import os
1211
import subprocess
1312
import sys
1413
from pathlib import Path
14+
from typing import Any, cast
15+
16+
import yaml
1517

1618
from deepwork.hooks.wrapper import (
1719
HookInput,
@@ -114,34 +116,38 @@ def _relative_path(path: Path, project_root: Path) -> str:
114116
def _validate_json_schema(filepath: Path, schema_path: Path) -> str | None:
115117
"""Validate a file against a JSON Schema.
116118
117-
Parses the file as YAML if it has a .yml/.yaml extension, otherwise as JSON.
118-
Returns error message or None on success.
119+
Parses the file as YAML, which is a superset of JSON, so both YAML and
120+
JSON formats are accepted regardless of file extension. Returns error
121+
message or None on success.
119122
"""
120123
if not schema_path.exists():
121124
return f"JSON Schema file not found: {schema_path}"
122125

123126
try:
124-
content = filepath.read_text(encoding="utf-8")
125-
if filepath.suffix in (".yml", ".yaml"):
126-
import yaml
127-
128-
parsed = yaml.safe_load(content)
129-
else:
130-
parsed = json.loads(content)
131-
except json.JSONDecodeError as e:
132-
return f"File is not valid JSON: {e}"
133-
except Exception as e:
127+
parsed = yaml.safe_load(filepath.read_text(encoding="utf-8"))
128+
except (yaml.YAMLError, UnicodeDecodeError) as e:
134129
return f"Cannot parse file: {e}"
135130

136131
try:
137-
schema_data = json.loads(schema_path.read_text(encoding="utf-8"))
138-
except (json.JSONDecodeError, OSError) as e:
132+
schema_data = yaml.safe_load(schema_path.read_text(encoding="utf-8"))
133+
except (yaml.YAMLError, UnicodeDecodeError, OSError) as e:
139134
return f"Cannot read JSON Schema: {e}"
140135

136+
# JSON Schema must be an object or a boolean (per JSON Schema spec).
137+
# Anything else (e.g., a bare string from yaml.safe_load on free-form
138+
# text) would crash the validator with a SchemaError.
139+
if not isinstance(schema_data, (dict, bool)):
140+
return (
141+
f"Cannot read JSON Schema: not a JSON Schema object (got {type(schema_data).__name__})"
142+
)
143+
141144
try:
142145
from deepwork.utils.validation import ValidationError, validate_against_schema
143146

144-
validate_against_schema(parsed, schema_data)
147+
# validate_against_schema's signature only types dict, but the
148+
# underlying jsonschema.validate also accepts bool schemas per spec
149+
# (true = accept all, false = reject all). Cast to satisfy mypy.
150+
validate_against_schema(parsed, cast("dict[str, Any]", schema_data))
145151
except ValidationError as e:
146152
return f"JSON Schema validation failed: {e}"
147153

tests/unit/deepschema/test_write_hook.py

Lines changed: 55 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,32 @@ def _make_hook_input(
2424
)
2525

2626

27+
def _write_name_required_schema(tmp_path: Path, slug: str, matcher: str) -> None:
28+
"""Create a named DeepSchema under `.deepwork/schemas/<slug>/` whose
29+
`json_schema_path` requires a top-level `name` string. Used by the
30+
_validate_json_schema exercise tests below, which all share the same
31+
schema shape and differ only in the matcher glob.
32+
"""
33+
schema_dir = tmp_path / ".deepwork" / "schemas" / slug
34+
schema_dir.mkdir(parents=True)
35+
(schema_dir / "test.schema.json").write_text(
36+
json.dumps(
37+
{
38+
"type": "object",
39+
"required": ["name"],
40+
"properties": {"name": {"type": "string"}},
41+
}
42+
),
43+
encoding="utf-8",
44+
)
45+
(schema_dir / "deepschema.yml").write_text(
46+
f"matchers:\n - '{matcher}'\n"
47+
"json_schema_path: 'test.schema.json'\n"
48+
"requirements:\n r1: 'MUST conform'\n",
49+
encoding="utf-8",
50+
)
51+
52+
2753
class TestDeepschemaWriteHook:
2854
def test_no_schemas_returns_empty(self, tmp_path: Path) -> None:
2955
# THIS TEST VALIDATES A HARD REQUIREMENT (DW-REQ-011.7.1).
@@ -70,25 +96,7 @@ def test_anonymous_schema_conformance_note(self, tmp_path: Path) -> None:
7096
def test_json_schema_validation_failure(self, tmp_path: Path) -> None:
7197
# THIS TEST VALIDATES A HARD REQUIREMENT (DW-REQ-011.7.3, DW-REQ-011.7.5).
7298
# YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES
73-
# Create a JSON Schema
74-
json_schema = tmp_path / ".deepwork" / "schemas" / "json_test" / "test.schema.json"
75-
json_schema.parent.mkdir(parents=True)
76-
json_schema.write_text(
77-
json.dumps(
78-
{
79-
"type": "object",
80-
"required": ["name"],
81-
"properties": {"name": {"type": "string"}},
82-
}
83-
),
84-
encoding="utf-8",
85-
)
86-
# Create schema referencing the JSON Schema
87-
(json_schema.parent / "deepschema.yml").write_text(
88-
"matchers:\n - '**/*.json'\njson_schema_path: 'test.schema.json'\nrequirements:\n r1: 'MUST conform'\n",
89-
encoding="utf-8",
90-
)
91-
# Write invalid JSON
99+
_write_name_required_schema(tmp_path, slug="json_test", matcher="**/*.json")
92100
target = tmp_path / "data.json"
93101
target.write_text('{"other": "field"}', encoding="utf-8")
94102

@@ -100,22 +108,7 @@ def test_json_schema_validation_failure(self, tmp_path: Path) -> None:
100108
def test_json_schema_validation_success(self, tmp_path: Path) -> None:
101109
# THIS TEST VALIDATES A HARD REQUIREMENT (DW-REQ-011.7.3).
102110
# YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES
103-
json_schema = tmp_path / ".deepwork" / "schemas" / "json_test" / "test.schema.json"
104-
json_schema.parent.mkdir(parents=True)
105-
json_schema.write_text(
106-
json.dumps(
107-
{
108-
"type": "object",
109-
"required": ["name"],
110-
"properties": {"name": {"type": "string"}},
111-
}
112-
),
113-
encoding="utf-8",
114-
)
115-
(json_schema.parent / "deepschema.yml").write_text(
116-
"matchers:\n - '**/*.json'\njson_schema_path: 'test.schema.json'\nrequirements:\n r1: 'MUST conform'\n",
117-
encoding="utf-8",
118-
)
111+
_write_name_required_schema(tmp_path, slug="json_test", matcher="**/*.json")
119112
target = tmp_path / "data.json"
120113
target.write_text('{"name": "valid"}', encoding="utf-8")
121114

@@ -127,23 +120,8 @@ def test_json_schema_validation_success(self, tmp_path: Path) -> None:
127120
def test_json_schema_validation_of_yaml_file(self, tmp_path: Path) -> None:
128121
# THIS TEST VALIDATES A HARD REQUIREMENT (DW-REQ-011.7.3).
129122
# YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES
130-
"""YAML files validated against a JSON Schema should be parsed as YAML, not JSON."""
131-
json_schema = tmp_path / ".deepwork" / "schemas" / "yml_test" / "test.schema.json"
132-
json_schema.parent.mkdir(parents=True)
133-
json_schema.write_text(
134-
json.dumps(
135-
{
136-
"type": "object",
137-
"required": ["name"],
138-
"properties": {"name": {"type": "string"}},
139-
}
140-
),
141-
encoding="utf-8",
142-
)
143-
(json_schema.parent / "deepschema.yml").write_text(
144-
"matchers:\n - '**/*.yml'\njson_schema_path: 'test.schema.json'\nrequirements:\n r1: 'MUST conform'\n",
145-
encoding="utf-8",
146-
)
123+
# YAML files validated against a JSON Schema should be parsed as YAML, not JSON.
124+
_write_name_required_schema(tmp_path, slug="yml_test", matcher="**/*.yml")
147125
target = tmp_path / "data.yml"
148126
target.write_text("name: valid\nother: field\n", encoding="utf-8")
149127

@@ -152,26 +130,34 @@ def test_json_schema_validation_of_yaml_file(self, tmp_path: Path) -> None:
152130
assert "must conform to the DeepSchema" in result.context
153131
assert "CRITICAL" not in result.context
154132

133+
def test_json_schema_validation_of_yaml_file_with_no_extension(self, tmp_path: Path) -> None:
134+
# THIS TEST VALIDATES A HARD REQUIREMENT (DW-REQ-011.7.3).
135+
# YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES
136+
# Files like `.deepreview` whose `Path.suffix` is empty (the leading
137+
# dot is treated as a hidden-file marker, not an extension separator)
138+
# MUST still be parsed as YAML — not as JSON. Regression for the bug
139+
# where `.deepreview` files reported `File is not valid JSON:
140+
# Expecting value: line 1 column 1 (char 0)`.
141+
_write_name_required_schema(tmp_path, slug="dotfile_test", matcher="**/.myappconfig")
142+
target = tmp_path / ".myappconfig"
143+
target.write_text("name: valid\n", encoding="utf-8")
144+
# Sanity check: this is the case the old extension-based dispatch
145+
# missed — Path.suffix is empty for any dot-prefixed filename, so
146+
# the old code routed `.myappconfig`, `.deepreview`, etc. to
147+
# json.loads() and produced false-positive parse errors.
148+
assert target.suffix == ""
149+
150+
hook_input = _make_hook_input(str(target), str(tmp_path))
151+
result = deepschema_write_hook(hook_input)
152+
assert "must conform to the DeepSchema" in result.context
153+
assert "CRITICAL" not in result.context
154+
assert "File is not valid JSON" not in result.context
155+
155156
def test_json_schema_validation_of_invalid_yaml_file(self, tmp_path: Path) -> None:
156157
# THIS TEST VALIDATES A HARD REQUIREMENT (DW-REQ-011.7.3, DW-REQ-011.7.5).
157158
# YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES
158-
"""YAML files that fail JSON Schema validation should report errors."""
159-
json_schema = tmp_path / ".deepwork" / "schemas" / "yml_test" / "test.schema.json"
160-
json_schema.parent.mkdir(parents=True)
161-
json_schema.write_text(
162-
json.dumps(
163-
{
164-
"type": "object",
165-
"required": ["name"],
166-
"properties": {"name": {"type": "string"}},
167-
}
168-
),
169-
encoding="utf-8",
170-
)
171-
(json_schema.parent / "deepschema.yml").write_text(
172-
"matchers:\n - '**/*.yml'\njson_schema_path: 'test.schema.json'\nrequirements:\n r1: 'MUST conform'\n",
173-
encoding="utf-8",
174-
)
159+
# YAML files that fail JSON Schema validation should report errors.
160+
_write_name_required_schema(tmp_path, slug="yml_test", matcher="**/*.yml")
175161
target = tmp_path / "data.yml"
176162
target.write_text("other: field\n", encoding="utf-8")
177163

tests/unit/test_deepschema_write_hook.py

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -246,15 +246,20 @@ def test_schema_file_not_found(self, tmp_path: Path) -> None:
246246
assert result is not None
247247
assert "not found" in result
248248

249-
def test_invalid_json_file(self, tmp_path: Path) -> None:
250-
"""Returns an error string when the data file is not valid JSON."""
251-
data_file = tmp_path / "data.json"
252-
data_file.write_text("not json")
249+
def test_invalid_yaml_file(self, tmp_path: Path) -> None:
250+
"""Returns an error string when the data file cannot be parsed as YAML.
251+
252+
Files are now parsed via yaml.safe_load (a JSON superset), so the
253+
previous "not json" content is actually valid YAML (a string scalar).
254+
Use a syntactically broken construct that YAML cannot parse.
255+
"""
256+
data_file = tmp_path / "data.yml"
257+
data_file.write_text("key: [unclosed\n")
253258
schema_file = tmp_path / "schema.json"
254259
schema_file.write_text("{}")
255260
result = _validate_json_schema(data_file, schema_file)
256261
assert result is not None
257-
assert "not valid JSON" in result
262+
assert "Cannot parse file" in result
258263

259264
def test_generic_parse_error(self, tmp_path: Path) -> None:
260265
"""Returns a 'Cannot parse file' error when reading the data file raises a decode error."""
@@ -269,15 +274,34 @@ def test_generic_parse_error(self, tmp_path: Path) -> None:
269274
assert result is not None
270275
assert "Cannot parse file" in result
271276

272-
def test_invalid_schema_file_json(self, tmp_path: Path) -> None:
273-
"""Returns a 'Cannot read JSON Schema' error when the schema file is not valid JSON."""
277+
def test_invalid_schema_file_not_an_object(self, tmp_path: Path) -> None:
278+
"""Returns a 'Cannot read JSON Schema' error when the schema file
279+
parses but is not a JSON Schema (i.e., not an object or boolean).
280+
281+
Schema files are now parsed via yaml.safe_load, which turns
282+
"not json schema" into the bare string "not json schema". That is
283+
not a valid JSON Schema, so we reject it before invoking the
284+
validator (which would otherwise raise SchemaError).
285+
"""
274286
data_file = tmp_path / "data.json"
275287
data_file.write_text('{"key": "value"}')
276288
schema_file = tmp_path / "schema.json"
277289
schema_file.write_text("not json schema")
278290
result = _validate_json_schema(data_file, schema_file)
279291
assert result is not None
280292
assert "Cannot read JSON Schema" in result
293+
assert "not a JSON Schema object" in result
294+
295+
def test_invalid_schema_file_unparseable(self, tmp_path: Path) -> None:
296+
"""Returns a 'Cannot read JSON Schema' error when the schema file
297+
cannot be parsed at all (broken YAML/JSON)."""
298+
data_file = tmp_path / "data.json"
299+
data_file.write_text('{"key": "value"}')
300+
schema_file = tmp_path / "schema.json"
301+
schema_file.write_text("key: [unclosed\n")
302+
result = _validate_json_schema(data_file, schema_file)
303+
assert result is not None
304+
assert "Cannot read JSON Schema" in result
281305

282306
def test_schema_file_oserror(self, tmp_path: Path) -> None:
283307
"""Returns a 'Cannot read JSON Schema' error when an OSError occurs reading the schema file."""

0 commit comments

Comments
 (0)