Skip to content

Commit de4e3ac

Browse files
nhortonclaude
andcommitted
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>
1 parent 1091181 commit de4e3ac

5 files changed

Lines changed: 91 additions & 23 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: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +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
1514

15+
import yaml
16+
1617
from deepwork.hooks.wrapper import (
1718
HookInput,
1819
HookOutput,
@@ -114,30 +115,29 @@ def _relative_path(path: Path, project_root: Path) -> str:
114115
def _validate_json_schema(filepath: Path, schema_path: Path) -> str | None:
115116
"""Validate a file against a JSON Schema.
116117
117-
Parses the file as YAML if it has a .yml/.yaml extension, otherwise as JSON.
118-
Returns error message or None on success.
118+
Parses the file as YAML, which is a superset of JSON, so both YAML and
119+
JSON formats are accepted regardless of file extension. Returns error
120+
message or None on success.
119121
"""
120122
if not schema_path.exists():
121123
return f"JSON Schema file not found: {schema_path}"
122124

123125
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:
126+
parsed = yaml.safe_load(filepath.read_text(encoding="utf-8"))
127+
except (yaml.YAMLError, UnicodeDecodeError) as e:
134128
return f"Cannot parse file: {e}"
135129

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

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

tests/unit/deepschema/test_write_hook.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,48 @@ def test_json_schema_validation_of_yaml_file(self, tmp_path: Path) -> None:
152152
assert "must conform to the DeepSchema" in result.context
153153
assert "CRITICAL" not in result.context
154154

155+
def test_json_schema_validation_of_yaml_file_with_no_extension(
156+
self, tmp_path: Path
157+
) -> None:
158+
# THIS TEST VALIDATES A HARD REQUIREMENT (DW-REQ-011.7.3).
159+
# YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES
160+
"""Files like `.deepreview` whose `Path.suffix` is empty (the leading
161+
dot is treated as a hidden-file marker, not an extension separator)
162+
MUST still be parsed as YAML — not as JSON. Regression for the bug
163+
where `.deepreview` files reported `File is not valid JSON: Expecting
164+
value: line 1 column 1 (char 0)`."""
165+
json_schema = (
166+
tmp_path / ".deepwork" / "schemas" / "dotfile_test" / "test.schema.json"
167+
)
168+
json_schema.parent.mkdir(parents=True)
169+
json_schema.write_text(
170+
json.dumps(
171+
{
172+
"type": "object",
173+
"required": ["name"],
174+
"properties": {"name": {"type": "string"}},
175+
}
176+
),
177+
encoding="utf-8",
178+
)
179+
(json_schema.parent / "deepschema.yml").write_text(
180+
"matchers:\n - '**/.myappconfig'\njson_schema_path: 'test.schema.json'\nrequirements:\n r1: 'MUST conform'\n",
181+
encoding="utf-8",
182+
)
183+
target = tmp_path / ".myappconfig"
184+
target.write_text("name: valid\n", encoding="utf-8")
185+
# Sanity check: this is the case the old extension-based dispatch
186+
# missed — Path.suffix is empty for any dot-prefixed filename, so
187+
# the old code routed `.myappconfig`, `.deepreview`, etc. to
188+
# json.loads() and produced false-positive parse errors.
189+
assert target.suffix == ""
190+
191+
hook_input = _make_hook_input(str(target), str(tmp_path))
192+
result = deepschema_write_hook(hook_input)
193+
assert "must conform to the DeepSchema" in result.context
194+
assert "CRITICAL" not in result.context
195+
assert "File is not valid JSON" not in result.context
196+
155197
def test_json_schema_validation_of_invalid_yaml_file(self, tmp_path: Path) -> None:
156198
# THIS TEST VALIDATES A HARD REQUIREMENT (DW-REQ-011.7.3, DW-REQ-011.7.5).
157199
# YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES

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)