Skip to content

Commit 1a9ec0d

Browse files
fix --fields list leak, reject non-dict JSON input, add --semantic-diff mode
--fields incorrectly preserved leaf lists (e.g. regions, tags) whose keys were not in the field set, defeating the purpose of token-reduction for agent pipelines. Now only recurses into structural containers (dicts and lists-of-dicts for block hierarchy). jsontohcl2 --dry-run and normal mode silently accepted non-dict JSON (arrays, scalars) producing garbled output with exit 0. load_python() now raises TypeError, caught by the existing handler to exit 2. New --semantic-diff ORIGINAL mode compares parsed HCL dict against JSON dict using diff_dicts(), completely ignoring formatting differences (alignment, trailing commas, comments, array layout). --diff-json flag outputs structured JSON for machine consumption. Documented --fragment inner-quote string convention in CLI help, epilog, and docs to prevent the common first-use mistake of passing plain JSON strings. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e1b8819 commit 1a9ec0d

10 files changed

Lines changed: 503 additions & 49 deletions

File tree

CLAUDE.md

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ All CLIs use structured error output (plain text to stderr) and distinct exit co
9292
| 2 | All unparsable | Bad HCL structure | Parse error |
9393
| 3 ||| Query error |
9494
| 4 | I/O error | I/O error | I/O error |
95-
| 5 || Differences found (`--diff`) ||
95+
| 5 || Differences found (`--diff` / `--semantic-diff`) ||
9696

9797
### `hcl2tojson`
9898

@@ -114,14 +114,16 @@ Key flags: `--ndjson`, `--compact`, `--only`/`--exclude`, `--fields`, `-q`/`--qu
114114
### `jsontohcl2`
115115

116116
```
117-
jsontohcl2 file.json # single file to stdout
118-
jsontohcl2 --diff original.tf modified.json # preview changes
119-
jsontohcl2 --dry-run file.json # convert without writing
120-
jsontohcl2 --fragment - # attribute snippets from stdin
117+
jsontohcl2 file.json # single file to stdout
118+
jsontohcl2 --diff original.tf modified.json # preview text changes
119+
jsontohcl2 --semantic-diff original.tf modified.json # semantic-only changes
120+
jsontohcl2 --semantic-diff original.tf --diff-json m.json # semantic diff as JSON
121+
jsontohcl2 --dry-run file.json # convert without writing
122+
jsontohcl2 --fragment - # attribute snippets from stdin
121123
jsontohcl2 --indent 4 --no-align file.json
122124
```
123125

124-
Key flags: `--diff ORIGINAL`, `--dry-run`, `--fragment`, `-q`/`--quiet`, `--indent N`, `--no-align`, `--colon-separator`.
126+
Key flags: `--diff ORIGINAL`, `--semantic-diff ORIGINAL`, `--diff-json`, `--dry-run`, `--fragment`, `-q`/`--quiet`, `--indent N`, `--no-align`, `--colon-separator`.
125127

126128
Add new options as `parser.add_argument()` calls in the relevant entry point module.
127129

cli/hcl_to_json.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from hcl2 import load
1010
from hcl2.utils import SerializationOptions
1111
from hcl2.version import __version__
12-
from .helpers import (
12+
from cli.helpers import (
1313
EXIT_IO_ERROR,
1414
EXIT_PARSE_ERROR,
1515
EXIT_PARTIAL,
@@ -58,11 +58,15 @@ def _project_fields(data, field_set):
5858
for key, val in data.items():
5959
if key in field_set or key.startswith("__"):
6060
result[key] = val
61-
elif isinstance(val, (dict, list)):
61+
elif isinstance(val, dict):
6262
projected = _project_fields(val, field_set)
6363
if projected:
6464
result[key] = projected
65-
# else: leaf value not in field_set — drop it
65+
elif isinstance(val, list) and any(isinstance(item, dict) for item in val):
66+
projected = _project_fields(val, field_set)
67+
if projected:
68+
result[key] = projected
69+
# else: leaf value (scalar or leaf list) not in field_set — drop it
6670
return result
6771
if isinstance(data, list):
6872
out = [_project_fields(item, field_set) for item in data]
@@ -453,3 +457,7 @@ def _resolve_file_paths(paths: List[str], parser) -> List[str]:
453457
if not file_paths:
454458
parser.error("no HCL files found in the given paths")
455459
return file_paths
460+
461+
462+
if __name__ == "__main__":
463+
main()

cli/json_to_hcl.py

Lines changed: 76 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,14 @@
88
from io import StringIO
99
from typing import TextIO
1010

11+
import hcl2
1112
from hcl2 import dump
1213
from hcl2.deserializer import DeserializerOptions
1314
from hcl2.formatter import FormatterOptions
15+
from hcl2.query.diff import diff_dicts, format_diff_json, format_diff_text
16+
from hcl2.utils import SerializationOptions
1417
from hcl2.version import __version__
15-
from .helpers import (
18+
from cli.helpers import (
1619
EXIT_DIFF,
1720
EXIT_IO_ERROR,
1821
EXIT_PARSE_ERROR,
@@ -81,19 +84,26 @@ def _strip_block_markers(data):
8184

8285
_EXAMPLES = """\
8386
examples:
84-
jsontohcl2 file.json # single file to stdout
85-
jsontohcl2 a.json b.json -o out/ # multiple files to output dir
86-
jsontohcl2 --diff original.tf modified.json # preview changes
87-
jsontohcl2 --dry-run file.json # convert without writing
88-
jsontohcl2 --fragment - # attribute snippet from stdin
89-
echo '{"x": 1}' | jsontohcl2 # stdin (no args needed)
87+
jsontohcl2 file.json # single file to stdout
88+
jsontohcl2 a.json b.json -o out/ # multiple files to output dir
89+
jsontohcl2 --diff original.tf modified.json # preview text changes
90+
jsontohcl2 --semantic-diff original.tf modified.json # semantic-only changes
91+
jsontohcl2 --semantic-diff original.tf --diff-json m.json # semantic diff as JSON
92+
jsontohcl2 --dry-run file.json # convert without writing
93+
jsontohcl2 --fragment - # attribute snippet from stdin
94+
echo '{"x": 1}' | jsontohcl2 # stdin (no args needed)
95+
96+
fragment string format:
97+
Strings use python-hcl2's inner-quote convention. To produce HCL "value",
98+
the JSON string must be: "\\"value\\"". Unquoted strings become identifiers.
99+
Example: {"name": "\\"test\\"", "count": 3} => name = "test" count = 3
90100
91101
exit codes:
92102
0 Success
93103
1 JSON parse error
94104
2 Valid JSON but incompatible HCL structure
95105
4 I/O error (file not found)
96-
5 Differences found (--diff mode only)
106+
5 Differences found (--diff / --semantic-diff)
97107
"""
98108

99109

@@ -136,10 +146,22 @@ def main(): # pylint: disable=too-many-branches,too-many-statements,too-many-lo
136146
action="store_true",
137147
help="Convert and print to stdout without writing files",
138148
)
149+
mode_group.add_argument(
150+
"--semantic-diff",
151+
metavar="ORIGINAL",
152+
help="Show semantic-only diff against ORIGINAL (ignores formatting)",
153+
)
139154
mode_group.add_argument(
140155
"--fragment",
141156
action="store_true",
142-
help="Treat input as a JSON fragment (attribute dict, not full HCL document)",
157+
help="Treat input as a JSON fragment (attribute dict, not full HCL "
158+
'document). Strings must use inner quotes: \'"\\"value\\""\' for HCL '
159+
'"value", bare strings become identifiers',
160+
)
161+
parser.add_argument(
162+
"--diff-json",
163+
action="store_true",
164+
help="Output diff results as JSON (works with --diff and --semantic-diff)",
143165
)
144166
parser.add_argument("--version", action="version", version=__version__)
145167

@@ -262,6 +284,47 @@ def convert(in_file, out_file):
262284
sys.exit(EXIT_DIFF)
263285
return
264286

287+
# --semantic-diff mode: compare semantic dicts (ignores formatting)
288+
if args.semantic_diff:
289+
if len(paths) != 1:
290+
parser.error("--semantic-diff requires exactly one input file")
291+
json_path = paths[0]
292+
original_path = args.semantic_diff
293+
294+
if not os.path.isfile(original_path):
295+
print(
296+
_error(
297+
f"File not found: {original_path}",
298+
error_type="io_error",
299+
file=original_path,
300+
),
301+
file=sys.stderr,
302+
)
303+
sys.exit(EXIT_IO_ERROR)
304+
305+
# Parse original HCL → normalized dict
306+
sem_opts = SerializationOptions(
307+
with_comments=False, with_meta=False, explicit_blocks=True
308+
)
309+
with open(original_path, "r", encoding="utf-8") as f:
310+
dict_original = hcl2.load(f, serialization_options=sem_opts)
311+
312+
# Load modified JSON → dict
313+
if json_path == "-":
314+
dict_modified = json.load(sys.stdin)
315+
else:
316+
with open(json_path, "r", encoding="utf-8") as f:
317+
dict_modified = json.load(f)
318+
319+
entries = diff_dicts(dict_original, dict_modified)
320+
if entries:
321+
if args.diff_json:
322+
print(format_diff_json(entries))
323+
else:
324+
print(format_diff_text(entries))
325+
sys.exit(EXIT_DIFF)
326+
return
327+
265328
# --dry-run mode: convert to stdout without writing
266329
if args.dry_run:
267330
if len(paths) != 1:
@@ -381,3 +444,7 @@ def convert(in_file, out_file):
381444
file=sys.stderr,
382445
)
383446
sys.exit(EXIT_IO_ERROR)
447+
448+
449+
if __name__ == "__main__":
450+
main()

docs/01_getting_started.md

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -201,16 +201,18 @@ echo 'x = 1' | hcl2tojson # stdin (no args needed)
201201
Convert JSON files to HCL2. Accepts files, directories, glob patterns, or stdin (default when no args given).
202202

203203
```sh
204-
jsontohcl2 output.json # single file to stdout
205-
jsontohcl2 output.json -o main.tf # single file to output file
206-
jsontohcl2 output/ -o terraform/ # directory conversion
207-
jsontohcl2 --diff original.tf modified.json # preview changes as unified diff
208-
jsontohcl2 --dry-run file.json # convert without writing
209-
jsontohcl2 --fragment - # attribute snippets from stdin
210-
echo '{"x": 1}' | jsontohcl2 # stdin (no args needed)
204+
jsontohcl2 output.json # single file to stdout
205+
jsontohcl2 output.json -o main.tf # single file to output file
206+
jsontohcl2 output/ -o terraform/ # directory conversion
207+
jsontohcl2 --diff original.tf modified.json # preview changes as unified diff
208+
jsontohcl2 --semantic-diff original.tf modified.json # semantic-only diff (ignores formatting)
209+
jsontohcl2 --semantic-diff original.tf --diff-json m.json # semantic diff as JSON
210+
jsontohcl2 --dry-run file.json # convert without writing
211+
jsontohcl2 --fragment - # attribute snippets from stdin
212+
echo '{"x": 1}' | jsontohcl2 # stdin (no args needed)
211213
```
212214

213-
**Exit codes:** 0 = success, 1 = JSON parse error, 2 = bad HCL structure, 4 = I/O error, 5 = differences found (`--diff`).
215+
**Exit codes:** 0 = success, 1 = JSON parse error, 2 = bad HCL structure, 4 = I/O error, 5 = differences found (`--diff` / `--semantic-diff`).
214216

215217
**Flags:**
216218

@@ -220,8 +222,10 @@ echo '{"x": 1}' | jsontohcl2 # stdin (no args needed)
220222
| `-s` | Skip un-parsable files |
221223
| `-q`, `--quiet` | Suppress progress output on stderr |
222224
| `--diff ORIGINAL` | Show unified diff against ORIGINAL file (exit 0 = identical, 5 = differs) |
225+
| `--semantic-diff ORIGINAL` | Show semantic-only diff against ORIGINAL (ignores formatting differences) |
226+
| `--diff-json` | Output diff results as JSON (works with `--diff` and `--semantic-diff`) |
223227
| `--dry-run` | Convert and print to stdout without writing files |
224-
| `--fragment` | Treat input as attribute dict, not full HCL document |
228+
| `--fragment` | Treat input as attribute dict, not full HCL document (see note below) |
225229
| `--indent N` | Indentation width (default: 2) |
226230
| `--colon-separator` | Use `:` instead of `=` in object elements |
227231
| `--no-trailing-comma` | Omit trailing commas in object elements |
@@ -233,6 +237,8 @@ echo '{"x": 1}' | jsontohcl2 # stdin (no args needed)
233237
| `--no-align` | Disable vertical alignment of attributes and object elements |
234238
| `--version` | Show version and exit |
235239

240+
> **Note on `--fragment` string format:** `--fragment` uses python-hcl2's standard JSON format, where HCL string values carry inner quotes. To produce the HCL attribute `name = "test"`, the JSON value must be `"\"test\""` (escaped inner quotes). A plain JSON string like `"test"` becomes the bare identifier `test`. This is the same convention used by `hcl2tojson` output — so piping `hcl2tojson` output into `jsontohcl2 --fragment` works correctly. Numbers, booleans, and expressions (`var.foo`, `local.name`) do not need quoting.
241+
236242
### hq
237243

238244
Query HCL2 files by structure, with optional Python expressions.

hcl2/deserializer.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -101,13 +101,13 @@ def _transformer(self) -> RuleTransformer:
101101

102102
def load_python(self, value: Any) -> StartRule:
103103
"""Deserialize a Python object into a StartRule tree."""
104-
if isinstance(value, dict):
105-
# Top-level dict is always a body (attributes + blocks), not an object
106-
children = self._deserialize_block_elements(value)
107-
result = StartRule([BodyRule(children)])
108-
else:
109-
result = StartRule([self._deserialize(value)])
110-
return result
104+
if not isinstance(value, dict):
105+
raise TypeError(
106+
f"Expected dict for top-level HCL body, got {type(value).__name__}"
107+
)
108+
# Top-level dict is always a body (attributes + blocks), not an object
109+
children = self._deserialize_block_elements(value)
110+
return StartRule([BodyRule(children)])
111111

112112
def loads(self, value: str) -> LarkElement:
113113
"""Deserialize a JSON string into a LarkElement tree."""

0 commit comments

Comments
 (0)