Skip to content

Commit bbf9914

Browse files
committed
refactor(validate-all): marker heuristic, sorted errors, JSON-only output
- Replace hardcoded directory excludes with text-based GTS marker heuristic: skip files whose raw text lacks "gts.", "gts://", or "x-gts-ref" before paying JSON parse cost. - Rename _validate_json_schemas → _check_schema_field_type (type-only). - Report malformed/non-GTS schema $id distinctly ("registry" stage). - Sort schema errors by (depth, gts_id, file, index): base-type first, then derived-type, each in total order on the remaining keys. - Sort instance errors by (depth, gts_id, file, index). - Update _is_gts_related to check gts://, x-gts-ref in addition to gts. - Remove stderr issue printing from validate-all CLI; output JSON only. - Add tests: malformed ID, incidental mention, duplicate entity, non-GTS file filtering, marker heuristic, schema/instance ordering, JSON-only CLI output. Signed-off-by: Artfizer <artifizer@gmail.com>
1 parent fa10065 commit bbf9914

3 files changed

Lines changed: 269 additions & 71 deletions

File tree

‎gts/src/gts/_cli.py‎

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ def build_parser() -> argparse.ArgumentParser:
3636
s.add_argument("--scope", choices=["major", "full"], default="major")
3737

3838
s = sub.add_parser(
39-
"validate-json", help="Validate all JSON documents in a file or directory"
39+
"validate-all", help="Validate all JSON documents in a file or directory"
4040
)
4141
s.add_argument("--path", dest="scan_path", help="JSON file or directory to scan")
4242

@@ -152,16 +152,11 @@ def main(argv: list[str] | None = None) -> None:
152152
json.dump(out, sys.stdout, ensure_ascii=False, indent=2)
153153
sys.stdout.write("\n")
154154
return
155-
elif args.op == "validate-json":
155+
elif args.op == "validate-all":
156156
scan_path = args.scan_path or args.path
157157
if not scan_path:
158-
parser.error("validate-json requires --path")
158+
parser.error("validate-all requires --path")
159159
result = GtsJsonValidator(scan_path, ops.cfg).validate()
160-
for issue in result.issues:
161-
suffix = f"#{issue.index}" if issue.index is not None else ""
162-
sys.stderr.write(
163-
f"{issue.file}{suffix}: {issue.stage}: {issue.message}\n"
164-
)
165160
out = result.to_dict()
166161
elif args.op == "validate-id":
167162
out = ops.validate_id(args.gts_id).to_dict()

‎gts/src/gts/_json_validation.py‎

Lines changed: 102 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,12 @@
77
from pathlib import Path
88
from typing import Any
99

10-
from jsonschema.validators import validator_for
11-
1210
from .entities import GtsEntity, GtsFile
13-
from .gts import GtsID
11+
from .gts import GTS_PREFIX, GTS_URI_PREFIX, GtsID
1412
from .store import GtsStore
1513

14+
_X_GTS_REF_KEYWORD = "x-gts-ref"
15+
1616

1717
@dataclass
1818
class GtsJsonValidationIssue:
@@ -67,8 +67,12 @@ def __init__(self, path: str, cfg: Any) -> None:
6767
def validate(self) -> GtsJsonValidationResult:
6868
for file_path in self._json_files():
6969
self._read_file(file_path)
70-
self._validate_json_schemas()
70+
self._check_schema_field_type()
7171
store = self._register_gts_entities()
72+
schemas_count, instances_count = self._count_schema_instance()
73+
self.result.schemas = schemas_count
74+
self.result.instances = instances_count
75+
self.result.gts_entities = schemas_count + instances_count
7276
self._validate_schemas(store)
7377
self._validate_instances(store)
7478
return self.result
@@ -87,22 +91,35 @@ def _json_files(self) -> list[Path]:
8791
return []
8892

8993
files: list[Path] = []
90-
for root, dirs, names in os.walk(resolved, followlinks=True):
91-
dirs[:] = [
92-
name for name in dirs if name not in {"node_modules", "dist", "build"}
93-
]
94-
files.extend(
95-
Path(root, name).resolve(strict=False)
96-
for name in names
97-
if Path(name).suffix.lower() == ".json"
98-
)
99-
return sorted(set(files))
94+
seen: set[Path] = set()
95+
for root, _dirs, names in os.walk(resolved, followlinks=True):
96+
for name in names:
97+
if Path(name).suffix.lower() == ".json":
98+
rp = Path(root, name).resolve(strict=False)
99+
if rp not in seen:
100+
seen.add(rp)
101+
files.append(rp)
102+
return sorted(files)
103+
104+
@staticmethod
105+
def _is_gts_marker(text: str) -> bool:
106+
return (
107+
GTS_PREFIX in text or GTS_URI_PREFIX in text or _X_GTS_REF_KEYWORD in text
108+
)
100109

101110
def _read_file(self, file_path: Path) -> None:
111+
try:
112+
content_str = file_path.read_text(encoding="utf-8")
113+
except Exception as error: # noqa: BLE001 - report this document and continue
114+
self._issue(file_path, "json", str(error))
115+
return
116+
117+
if not self._is_gts_marker(content_str):
118+
return
102119
self.result.files += 1
120+
103121
try:
104-
with file_path.open(encoding="utf-8") as source:
105-
content = json.load(source)
122+
content = json.loads(content_str)
106123
except Exception as error: # noqa: BLE001 - report this document and continue
107124
self._issue(file_path, "json", str(error))
108125
return
@@ -120,15 +137,14 @@ def _read_file(self, file_path: Path) -> None:
120137
)
121138
)
122139

123-
def _validate_json_schemas(self) -> None:
140+
def _check_schema_field_type(self) -> None:
124141
for entity in self.entities:
125142
content = entity.content
126-
if not isinstance(content, dict) or "$schema" not in content:
143+
if not isinstance(content, dict):
127144
continue
128-
try:
129-
validator_for(content).check_schema(content)
130-
except Exception as error: # noqa: BLE001 - report this document and continue
131-
self._issue(entity, "json-schema", str(error))
145+
schema_val = content.get("$schema")
146+
if schema_val is not None and not isinstance(schema_val, str):
147+
self._issue(entity, "json-schema", "$schema must be a string")
132148

133149
def _register_gts_entities(self) -> GtsStore:
134150
store = GtsStore(reader=None) # type: ignore[arg-type]
@@ -138,9 +154,12 @@ def _register_gts_entities(self) -> GtsStore:
138154
continue
139155
key = self._registry_key(entity)
140156
if key is None:
141-
self._issue(
142-
entity, "registry", "GTS-related document has no registrable GTS ID"
143-
)
157+
if entity.is_schema:
158+
self._issue(
159+
entity,
160+
"registry",
161+
"GTS schema has a malformed or non-GTS $id",
162+
)
144163
continue
145164
if not entity.is_schema and entity.selected_entity_field is None:
146165
raw_id = entity.raw_id
@@ -152,17 +171,28 @@ def _register_gts_entities(self) -> GtsStore:
152171
continue
153172
keys.add(key)
154173
store.register(entity)
155-
self.result.gts_entities += 1
174+
return store
175+
176+
def _count_schema_instance(self) -> tuple[int, int]:
177+
schemas = 0
178+
instances = 0
179+
for entity in self.entities:
180+
if self._registry_key(entity) is None:
181+
continue
156182
if entity.is_schema:
157-
self.result.schemas += 1
183+
schemas += 1
158184
else:
159-
self.result.instances += 1
160-
return store
185+
instances += 1
186+
return schemas, instances
161187

162188
@staticmethod
163189
def _is_gts_related(value: Any) -> bool:
164190
if isinstance(value, str):
165-
return "gts." in value
191+
return (
192+
GTS_PREFIX in value
193+
or GTS_URI_PREFIX in value
194+
or _X_GTS_REF_KEYWORD in value
195+
)
166196
if isinstance(value, dict):
167197
return any(
168198
GtsJsonValidator._is_gts_related(item) for item in value.values()
@@ -187,43 +217,58 @@ def _registry_key(entity: GtsEntity) -> str | None:
187217
return None
188218

189219
def _validate_schemas(self, store: GtsStore) -> None:
190-
schemas = sorted(
191-
(
192-
entity
193-
for entity in self.entities
194-
if entity.is_schema
195-
and entity.gts_id
196-
and store.get(entity.gts_id.id) is entity
197-
),
198-
key=self._schema_depth,
199-
)
200-
for stage, depth in (("base-type", 1), ("derived-type", None)):
201-
for entity in schemas:
202-
if (depth == 1) != (self._schema_depth(entity) == 1):
203-
continue
204-
gts_id = entity.gts_id
205-
if not gts_id:
206-
continue
207-
try:
208-
store.validate_schema(gts_id.id)
209-
except Exception as error: # noqa: BLE001 - report this document and continue
210-
self._issue(entity, stage, str(error))
220+
pending: list[tuple[int, str, str, int | None, GtsEntity]] = []
221+
for entity in self.entities:
222+
if not entity.is_schema or not entity.gts_id:
223+
continue
224+
gid = entity.gts_id
225+
if store.get(gid.id) is not entity:
226+
continue
227+
depth = len(gid.gts_id_segments)
228+
file = entity.file.path if entity.file else entity.label
229+
pending.append((depth, gid.id, file, entity.list_sequence, entity))
230+
pending.sort(key=lambda t: (t[0], t[1], t[2], t[3] if t[3] is not None else -1))
231+
232+
for depth, _gts_id, _file, _idx, entity in pending:
233+
stage = "base-type" if depth <= 1 else "derived-type"
234+
try:
235+
store.validate_schema(entity.gts_id.id) # type: ignore[union-attr]
236+
except Exception as error: # noqa: BLE001 - report this document and continue
237+
self._issue(entity, stage, str(error))
211238

212239
@staticmethod
213240
def _schema_depth(entity: GtsEntity) -> int:
214241
return len(entity.gts_id.gts_id_segments) if entity.gts_id else 0
215242

243+
@staticmethod
244+
def _entity_depth(entity: GtsEntity) -> int:
245+
if entity.gts_id:
246+
return len(entity.gts_id.gts_id_segments)
247+
if entity.type_id and GtsID.is_valid(entity.type_id):
248+
return len(GtsID(entity.type_id).gts_id_segments)
249+
return 0
250+
216251
def _validate_instances(self, store: GtsStore) -> None:
252+
pending: list[tuple[int, str, str, int | None, str]] = []
217253
for entity in self.entities:
254+
if entity.is_schema:
255+
continue
218256
key = self._registry_key(entity)
219-
if (
220-
entity.is_schema
221-
or key is None
222-
or not self._is_gts_related(entity.content)
223-
):
257+
if key is None:
258+
continue
259+
depth = self._entity_depth(entity)
260+
gts_id_str = entity.gts_id.id if entity.gts_id else ""
261+
file = entity.file.path if entity.file else entity.label
262+
pending.append((depth, gts_id_str, file, entity.list_sequence, key))
263+
pending.sort(key=lambda t: (t[0], t[1], t[2], t[3] if t[3] is not None else -1))
264+
265+
for _depth, _gts_id, _file, _idx, registry_key in pending:
266+
# Find the entity for error reporting
267+
entity = store.get(registry_key)
268+
if entity is None:
224269
continue
225270
try:
226-
store.validate_instance(key)
271+
store.validate_instance(registry_key)
227272
except Exception as error: # noqa: BLE001 - report this document and continue
228273
self._issue(entity, "instance", str(error))
229274

0 commit comments

Comments
 (0)