Skip to content

Commit 2999e44

Browse files
authored
fix(neo4j): carry module.source and span byte offsets so the graph can resolve text (#255)
Canonical decision D1 makes `module.source` the primary text and every narrower node's text a byte-slice of it — it dropped per-callable `code` from `analysis.json` on exactly that ground. The JSON projection obeys it. The graph projection inverted it: `:JModule` carried no `source`, while `:JCallable` kept the one derivation D1 deleted. The source was already in hand where the graph is written — `V2GraphProjector` calls `module.getSource()` to slice `code` out of it — so this was an omission rather than missing data. The flattened span was the other half of the same defect. `putLines` emitted `start_line` / `end_line` only, dropping the columns and the UTF-8 byte offsets, though the JSON schema defines `span.bytes` as "[from, to) UTF-8 offsets into module.source, so node text is an O(1) slice". Carrying `source` without the offsets would not have made anything sliceable. Together the two gaps meant nothing narrower than a callable resolved to text at all: python-sdk's reconstruction reports `source=""` and hardwires `_body_source` to `None`, both for want of these facts. - `:JModule` carries `source`, coalesced from null to `""` so it is always present — `RowBuilder.prune` drops nulls, and the projections would otherwise disagree about whether the property exists. - The flattened span carries `start_column`, `end_column`, `start_byte`, `end_byte` beside the line pair. These four names are new shared vocabulary: codeanalyzer-python and codeanalyzer-typescript both flatten to the line pair alone and must adopt these spellings rather than coin their own. - A conformance test asserts every `:JModule`'s `source` equals the `analysis.json` module source per module, not by presence, so a truncated or placeholder value fails. Verified on daytrader8: 141 of 141 modules carry `source`, each hashing to the `content_hash` its own row declares, which proves the value survives Cypher escaping; and `source[start_byte:end_byte]` equals the node's existing `code` byte-for-byte for all 662 callables where both are present, zero mismatches. `JCallable.code` is now provably redundant but stays — python-sdk reconstructs from it, so removing it is a separate breaking change. `schema_version` stays at the held 2.0.0 baseline, so this is not detectable from the payload. Closes #254
1 parent c3d0b68 commit 2999e44

4 files changed

Lines changed: 110 additions & 13 deletions

File tree

‎schema.neo4j.json‎

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@
2828
"id": "string",
2929
"file_key": "string",
3030
"package": "string",
31-
"content_hash": "string"
31+
"content_hash": "string",
32+
"source": "string"
3233
}
3334
},
3435
{
@@ -46,7 +47,11 @@
4647
"is_entrypoint": "boolean",
4748
"entrypoint_frameworks": "string[]",
4849
"start_line": "integer",
49-
"end_line": "integer"
50+
"start_column": "integer",
51+
"end_line": "integer",
52+
"end_column": "integer",
53+
"start_byte": "integer",
54+
"end_byte": "integer"
5055
}
5156
},
5257
{
@@ -72,7 +77,11 @@
7277
"is_entrypoint": "boolean",
7378
"entrypoint_frameworks": "string[]",
7479
"start_line": "integer",
75-
"end_line": "integer"
80+
"start_column": "integer",
81+
"end_line": "integer",
82+
"end_column": "integer",
83+
"start_byte": "integer",
84+
"end_byte": "integer"
7685
}
7786
},
7887
{
@@ -98,7 +107,11 @@
98107
"modifiers": "string[]",
99108
"docstring": "string",
100109
"start_line": "integer",
101-
"end_line": "integer"
110+
"start_column": "integer",
111+
"end_line": "integer",
112+
"end_column": "integer",
113+
"start_byte": "integer",
114+
"end_byte": "integer"
102115
}
103116
},
104117
{
@@ -111,7 +124,11 @@
111124
"type": "string",
112125
"initializer": "string",
113126
"start_line": "integer",
114-
"end_line": "integer"
127+
"start_column": "integer",
128+
"end_line": "integer",
129+
"end_column": "integer",
130+
"start_byte": "integer",
131+
"end_byte": "integer"
115132
}
116133
},
117134
{
@@ -157,7 +174,11 @@
157174
"var": "string",
158175
"call_node": "string",
159176
"start_line": "integer",
160-
"end_line": "integer"
177+
"start_column": "integer",
178+
"end_line": "integer",
179+
"end_column": "integer",
180+
"start_byte": "integer",
181+
"end_byte": "integer"
161182
}
162183
},
163184
{
@@ -214,7 +235,11 @@
214235
"value": "string",
215236
"references": "string[]",
216237
"start_line": "integer",
217-
"end_line": "integer"
238+
"start_column": "integer",
239+
"end_line": "integer",
240+
"end_column": "integer",
241+
"start_byte": "integer",
242+
"end_byte": "integer"
218243
}
219244
}
220245
],

‎src/main/java/com/ibm/cldk/neo4j/V2GraphProjector.java‎

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,13 @@ public static GraphRows project(Analysis analysis, String appName) {
118118
mp.put("file_key", fileKey);
119119
mp.put("package", module.getPackageName());
120120
mp.put("content_hash", module.getContentHash());
121+
// The whole file, as `analysis.json` carries it. Canonical decision D1 makes
122+
// `module.source` the primary text and every narrower node's text a byte-slice of it, so
123+
// dropping it here left the graph holding only the ONE derivation it happens to cache
124+
// (`JCallable.code`) and nothing to slice for a body node, field, parameter or the module
125+
// itself. Never null: `analysis.json` requires `source`, and `prune` would silently drop
126+
// a null, so the two projections would disagree on whether the property exists.
127+
mp.put("source", module.getSource() == null ? "" : module.getSource());
121128
mp.put("_module", fileKey);
122129
NodeRef mod = b.node(Arrays.asList("JModule"), "id", module.getId(), RowBuilder.prune(mp));
123130
b.edge("J_HAS_MODULE", app, mod);
@@ -717,12 +724,33 @@ private static String slice(String source, Span span) {
717724
return new String(bytes, start, end - start, StandardCharsets.UTF_8);
718725
}
719726

727+
/**
728+
* Flatten a {@code span} the way {@code analysis.json} carries it: line, column and UTF-8 byte
729+
* offset for both ends. The byte offsets are the load-bearing pair — the JSON schema defines them
730+
* as "[from, to) UTF-8 offsets into module.source, so node text is an O(1) slice", which is only
731+
* true on the graph now that {@code :JModule} carries {@code source}. Emitting the line pair
732+
* alone left every span resolvable to a line range and to no text, and left a column-precise
733+
* position (two nodes on one line) unrepresentable.
734+
*/
720735
private static void putLines(Map<String, Object> p, Span span) {
721-
if (span != null && span.getStart() != null && span.getStart().length > 0) {
736+
if (span == null) {
737+
return;
738+
}
739+
if (span.getStart() != null && span.getStart().length > 0) {
722740
p.put("start_line", span.getStart()[0]);
741+
if (span.getStart().length > 1) {
742+
p.put("start_column", span.getStart()[1]);
743+
}
723744
}
724-
if (span != null && span.getEnd() != null && span.getEnd().length > 0) {
745+
if (span.getEnd() != null && span.getEnd().length > 0) {
725746
p.put("end_line", span.getEnd()[0]);
747+
if (span.getEnd().length > 1) {
748+
p.put("end_column", span.getEnd()[1]);
749+
}
750+
}
751+
if (span.getBytes() != null && span.getBytes().length > 1) {
752+
p.put("start_byte", span.getBytes()[0]);
753+
p.put("end_byte", span.getBytes()[1]);
726754
}
727755
}
728756

‎src/main/java/com/ibm/cldk/neo4j/V2SchemaCatalog.java‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,19 @@ Map<String, String> done() {
8888
}
8989
}
9090

91+
/**
92+
* The flattened {@code span} every located node carries. Mirrors {@code analysis.json}'s
93+
* {@code span} in full: both ends as line, column and UTF-8 byte offset. {@code start_byte} /
94+
* {@code end_byte} are what make a span resolvable to text — the JSON schema defines them as
95+
* "[from, to) UTF-8 offsets into module.source, so node text is an O(1) slice" — and they are
96+
* only useful because {@code :JModule} now carries {@code source}. codeanalyzer-python and
97+
* codeanalyzer-typescript both flatten to the line pair alone today; these four property names
98+
* are the ones they must adopt.
99+
*/
91100
private static Map<String, String> lines(P p) {
92-
return p.put("start_line", "integer").put("end_line", "integer").done();
101+
return p.put("start_line", "integer").put("start_column", "integer")
102+
.put("end_line", "integer").put("end_column", "integer")
103+
.put("start_byte", "integer").put("end_byte", "integer").done();
93104
}
94105

95106
public static final List<NodeLabel> NODE_LABELS = buildNodeLabels();
@@ -113,9 +124,13 @@ private static List<NodeLabel> buildNodeLabels() {
113124
.put("entrypoint_frameworks", "string[]")
114125
.put("entrypoint_report_json", "string").done()));
115126

127+
// `source` is the whole file, matching `analysis.json`'s required `module.source`. It is the
128+
// primary text under canonical decision D1 (per-callable `code` was dropped from the JSON on
129+
// the grounds that the SDK slices `module.source[span.bytes]`), so the graph must carry it or
130+
// no span narrower than a callable can be resolved to text at all.
116131
n.add(node("JModule", "JModule", "id",
117132
new P().put("id", "string").put("file_key", "string").put("package", "string")
118-
.put("content_hash", "string").done()));
133+
.put("content_hash", "string").put("source", "string").done()));
119134

120135
n.add(node("JType", "JSymbol", "id",
121136
lines(new P().put("id", "string").put("name", "string").put("kind", "string")

‎src/test/java/com/ibm/cldk/neo4j/V2Neo4jSchemaConformanceTest.java‎

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ public class V2Neo4jSchemaConformanceTest {
7777
static Path ARTIFACT_TMP;
7878

7979
private static GraphRows rows;
80+
private static Analysis analysis;
8081

8182
private static final Map<String, NodeLabel> BY_LABEL = new HashMap<>();
8283
private static final Map<String, String> MERGE_OF = new HashMap<>();
@@ -121,10 +122,11 @@ static void project() throws Exception {
121122
}
122123
}
123124

124-
Analysis analysis = V2Emitter.emit(
125+
Analysis emitted = V2Emitter.emit(
125126
APP_NAME, 3, modules, "test", l2.callGraph(), l2.externalSymbols(),
126127
sdg.paramIn, sdg.paramOut, artifacts, dependencies);
127-
rows = V2GraphProjector.project(analysis, APP_NAME);
128+
rows = V2GraphProjector.project(emitted, APP_NAME);
129+
analysis = emitted;
128130
}
129131

130132
private static String specificLabel(List<String> labels) {
@@ -282,6 +284,33 @@ void l4OverlayProjectsParamAndSummaryEdges() {
282284
// Repository-artifact layer (Task 7): Artifact/Package/ConfigKey.
283285
// ------------------------------------------------------------------------------------------
284286

287+
/**
288+
* Every {@code :JModule} carries the same whole-file {@code source} that {@code analysis.json}
289+
* carries, because canonical decision D1 makes {@code module.source} the primary text and every
290+
* narrower node's text a byte-slice of it. The graph once held only the one derivation it caches
291+
* ({@code JCallable.code}) and dropped the primary, which left a body node, field or parameter
292+
* span with nothing to resolve against. Asserted per module rather than by presence so the
293+
* inversion cannot come back as a truncated or placeholder value.
294+
*/
295+
@Test
296+
void everyModuleCarriesTheSameSourceAnalysisJsonCarries() {
297+
Map<String, JModule> jsonModules = analysis.getApplication().getSymbolTable();
298+
assertFalse(jsonModules.isEmpty(), "fixture projected no modules");
299+
300+
int checked = 0;
301+
for (JModule m : jsonModules.values()) {
302+
NodeRow row = findNode("JModule", m.getId());
303+
assertNotNull(row, "no :JModule row for " + m.getId());
304+
Object projected = row.props.get("source");
305+
assertNotNull(projected, "no `source` on :JModule " + m.getId()
306+
+ " -- analysis.json requires module.source, so the graph must carry it too");
307+
assertEquals(m.getSource(), projected,
308+
"graph `source` differs from analysis.json module.source for " + m.getId());
309+
checked++;
310+
}
311+
assertEquals(jsonModules.size(), checked);
312+
}
313+
285314
@Test
286315
void artifactLayerNodesAreEmitted() {
287316
boolean sawArtifact = false;

0 commit comments

Comments
 (0)