JSON: recursive entity extraction with suppress-parent display - #101
Conversation
There was a problem hiding this comment.
inspect review
Triage: 92 entities analyzed | 0 critical, 0 high, 21 medium, 71 low
Verdict: standard_review
Findings (2)
- [low] extract_object_value can return an incorrect slice because it decrements depth on both '}' and ']' and returns when depth reaches 0, even though it is documented to extract an object value delimited by '{' ... matching '}'. Evidence: in extract_object_value:
match ch { '{' | '[' => depth += 1, '}' | ']' => { depth -= 1; if depth == 0 { return Some(...) }}}. This allows a closing ']' to terminate extraction early if nesting balances to 0 on an array close, truncating the object slice. - [low] extract_object_value can panic via usize underflow when encountering a closing '}' or ']' while
depth == 0(malformed/unexpected input), because it doesdepth -= 1on ausizewithout guarding. Evidence:let mut depth = 0usize; ... '}' | ']' => { depth -= 1, if depth == 0 { ... } }.
Reviewed by inspect | Entity-level triage found 0 high-risk changes
|
could you rebase the PR to main? |
The JSON parser was depth-2: it surfaced top-level keys and one layer of children, then treated everything deeper as opaque text. A change to package.json's scripts.build surfaced as "scripts modified" with the entire scripts object as before/after content. Builds on the parent_name field merged in Ataraxy-Labs#97 and the precision guard merged in Ataraxy-Labs#98. Parser is now fully recursive — every key at every depth is an entity identified by JSON Pointer (/scripts/build). Arrays remain opaque (elements have no stable identity), but array-typed keys themselves are entities. JSON entity IDs are file::pointer (entity_type dropped) so a key whose value type changes (scalar↔object↔array) keeps the same ID and matches Phase 1 as Modified instead of Deleted+Added. parent_name traverses parent_id to build the full ancestor chain (e.g. jest::config for an entity at /jest/config/timeout). Empty ancestor names — package-lock.json's "" root-package key — are skipped so displayed paths stay clean. Suppression extends Ataraxy-Labs#98's precision check: - "object" joins CONTAINER_TYPES so JSON parents are eligible - The Modified-suppression branch additionally requires entity_type to match across before/after, so scalar↔object value transitions keep the parent change - An additional pass drops Moved entries when the entity's old_parent_id is itself in the change set, catching parent-rename failures where children matched by structural hash and the parent did not Behavior is documented in JSON_SEMANTIC_DIFF_SPEC.md, including the remaining limitation around parent-rename plus content change in the same commit. Adds 38 BDD-style tests in json.rs covering top-level, nested rename/ add/delete, deep nesting, type transitions, array opacity, parent- rename fallbacks, document edge cases, and the empty-string-key case from package-lock.json. All 190 sem-core tests pass.
2d592e2 to
a3b96b3
Compare
There was a problem hiding this comment.
inspect review
Triage: 85 entities analyzed | 0 critical, 0 high, 14 medium, 71 low
Verdict: standard_review
Findings (0)
Reviewed by inspect | Entity-level triage found 0 high-risk changes
|
Just rebased onto current main — brings in #98's precision guard. Force-pushed The Modified-suppression path now uses Re: the inspect bot findings on |
|
Thanks a lot for the thorough work here, the deep entity extraction for JSON is a clear improvement over the depth-2 approach. One concern: we recently converted all AST walkers from recursive to iterative (b9384a0, fixing #103) to avoid stack overflows on deeply nested trees. This PR reintroduces recursion for JSON entity extraction, which could hit the same issue on files like package-lock.json or pnpm-lock.yaml that can nest hundreds of levels deep. Could you convert the recursive traversal to an iterative worklist to stay consistent with the rest of the codebase? Also I know that the PR might have some long code, so would appreciate if there are things that can be cut down. |
Per @rs545837's request, convert the recursive `extract_entries_recursive` to iterative (matches the pattern in b9384a0). The new `extract_entries` uses a Vec<Frame> worklist with cursor-based mid-iteration suspension, so DFS pre-order is preserved bit-for-bit — entity Vec output is identical to the previous recursive version on all existing test inputs. Also fixes the two inspect bot findings on `extract_object_value`: the function now returns Some(slice) only on a closing '}' (a stray ']' no longer terminates extraction at depth 0), and the depth decrement uses saturating_sub on both arms. Trim pass per the maintainer's "things that can be cut down" request: - Drop JSON_SEMANTIC_DIFF_SPEC.md (-420 lines) - Three pointer-escape tests collapse into one table-driven test - Two scalar↔array transition tests collapse into one - Three root-document-edge tests collapse into one (with `{}` added) - Add malformed_input_does_not_panic covering 7 malformed inputs to pin down the saturating_sub safety property Net: -459 lines across the two files. 186 sem-core tests pass.
|
Pushed
The two inspect-bot findings on Trim pass: dropped On the spec doc — I removed it to cut down the MR but happy to bring it back, either as-is or in a trimmed form, whichever you prefer. Your call. 186 sem-core tests pass. |
There was a problem hiding this comment.
inspect review
Triage: 55 entities analyzed | 0 critical, 0 high, 15 medium, 40 low
Verdict: standard_review
Findings (2)
- [low]
extract_object_valuehas incorrect bracket-depth accounting: it incrementsdepthfor both{and[but decrements on both}and], and it returns only when seeing}withdepth == 0. This can return the wrong slice when arrays are nested inside the object value (or when brackets are mismatched), producing incorrectobj_strand thus wrong child extraction/hashes/line offsets. Evidence:
match ch {
'{' | '[' => depth += 1,
'}' => { depth = depth.saturating_sub(1); if depth == 0 { return Some(...); } }
']' => { depth = depth.saturating_sub(1); }
}- [low]
parent_namecan infinite-loop on cyclicparent_idchains because it traversesparent_idlinks withloop { ... pid = next ... }and has no visited-set / max-depth guard. Under malformed/corrupted entity graphs (e.g.,parent_idpoints to itself), this will never terminate. Evidence:
let mut pid = entity.parent_id.as_deref()?;
loop {
match by_id.get(pid) {
Some(parent) => match parent.parent_id.as_deref() {
Some(next) => pid = next,
None => break,
},
None => break,
}
}Reviewed by inspect | Entity-level triage found 0 high-risk changes
- extract_object_value: track brace and bracket depth separately, so a stray ']' can't terminate object extraction at brace_depth == 0. Returns Some(slice) only when both depths are zero on a closing '}'. - parent_name: guard the parent_id walk with a visited HashSet so a cyclic chain in malformed entity graphs can't loop forever. Adds parent_name_terminates_on_cyclic_parent_id to lock in the cycle guard. The bracket-depth fix is exercised by the existing tests that mix arrays with nested objects (top-level coverage); malformed inputs are covered by malformed_input_does_not_panic.
There was a problem hiding this comment.
inspect review
Triage: 57 entities analyzed | 0 critical, 0 high, 16 medium, 41 low
Verdict: standard_review
Findings (1)
- [low]
extract_object_valuecan misclassify string values containing{as objects because it finds the first{after the colon without string-awareness (let brace_offset = after_colon.find('{')?;). Example:"key": "{not an object}"would be treated as an object, causing incorrect nested entity extraction and wrong line offsets.
Reviewed by inspect | Entity-level triage found 0 high-risk changes
|
Thanks a lot for this PR @nminev, appreciate the effort. |
Why
Today the JSON parser is depth-2: it surfaces top-level keys and one layer of children, then treats everything deeper as opaque text. A
package.jsonedit toscripts.buildreports asscripts modifiedwith the entire scripts object as the before/after content — no signal about which script actually changed. For agent and CI consumers parsing the JSON output, this is essentially noise; in the diff-so-fancy integration discussed in #78 it's the difference between "a script changed" and "build was changed."Builds on the
parent_namefield merged in #97 and the precision guard merged in #98. Rebased onto current main after both landed.What changes
Parser
/scripts/build,/jest/config/testTimeout) is the stable identity.{file}::{pointer}instead of{file}::{type}::{pointer}. A key whose value type changes (scalar ↔ object ↔ array) keeps the same ID and matches Phase 1 as Modified instead of Deleted + Added. This is the only breaking output change.parent_nametraversalparent_id(e.g.jest::configfor an entity at/jest/config/testTimeout), instead of returning only the leaf segment of the immediate parent's ID.""as the root-project key, which previously rendered aspackages::::dependencies(double colon). The full pointer is still recoverable fromentity_id; only the displayed chain drops the empty segment.Suppression extends #98's precision check
\"object\"toCONTAINER_TYPESso a JSON parent isn't reported alongside a child change of its own. With recursion this matters more than before — without it, every leaf change cascades a chain of parent Modifieds.entity_typeto match across before/after, so scalar↔object value-type transitions keep the parent change (the type change itself is meaningful).old_parent_idis in the change set. This catches the case where a parent rename (e.g.scripts → tasks) couldn't be matched as Renamed because its children also changed, but the children themselves matched by structural hash and surfaced as Moved. Without this, the rename-failure surfaces both the parent Deleted/Added and the child Moves; with it, only the leaf Moves remain — and consumers can infer the parent rename from the children'sold_parent_id+ newparent_name.Spec doc
JSON_SEMANTIC_DIFF_SPEC.mddocuments the behavior end-to-end: entity extraction rules, change cases, display format, the matching algorithm overview, and known limitations (chiefly the parent-rename + content change case).Tests
38 BDD-style tests in
json.rscovering top-level changes, nested rename/add/delete, deep nesting, type transitions, array opacity, parent-rename fallbacks, document edge cases, and the empty-string-key case from package-lock.json. They usecompute_semantic_diff(the public pipeline) so they assert observable behavior rather than internal state.All 190 sem-core tests pass (188 pre-existing + 38 new − 36 reorganized).
Notes
Test plan