deepdebug-local-agent 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/.github/workflows/deploy-gcp.yml +58 -26
  2. package/CHANGELOG.md +6 -0
  3. package/docs/la-protocol-v2.md +68 -0
  4. package/package.json +2 -1
  5. package/scripts/sync-la-protocol-v2-contract.mjs +58 -0
  6. package/src/la-protocol-v2/chunks.js +45 -0
  7. package/src/la-protocol-v2/config.js +27 -0
  8. package/src/la-protocol-v2/content.js +57 -0
  9. package/src/la-protocol-v2/contract/CONTRACT_SOURCE.json +81 -0
  10. package/src/la-protocol-v2/contract/README.md +103 -0
  11. package/src/la-protocol-v2/contract/ack.schema.json +51 -0
  12. package/src/la-protocol-v2/contract/alert.schema.json +62 -0
  13. package/src/la-protocol-v2/contract/approval.decision.schema.json +57 -0
  14. package/src/la-protocol-v2/contract/audit.event.schema.json +86 -0
  15. package/src/la-protocol-v2/contract/config.update.schema.json +73 -0
  16. package/src/la-protocol-v2/contract/content.request.schema.json +110 -0
  17. package/src/la-protocol-v2/contract/content.response.schema.json +90 -0
  18. package/src/la-protocol-v2/contract/defs.schema.json +191 -0
  19. package/src/la-protocol-v2/contract/envelope.schema.json +89 -0
  20. package/src/la-protocol-v2/contract/error.schema.json +59 -0
  21. package/src/la-protocol-v2/contract/heartbeat.schema.json +100 -0
  22. package/src/la-protocol-v2/contract/hello.schema.json +111 -0
  23. package/src/la-protocol-v2/contract/incident.candidate.schema.json +102 -0
  24. package/src/la-protocol-v2/contract/index.json +166 -0
  25. package/src/la-protocol-v2/contract/job.assign.schema.json +212 -0
  26. package/src/la-protocol-v2/contract/job.cancel.schema.json +52 -0
  27. package/src/la-protocol-v2/contract/job.event.schema.json +115 -0
  28. package/src/la-protocol-v2/contract/job.result.schema.json +100 -0
  29. package/src/la-protocol-v2/contract/log.stats.schema.json +79 -0
  30. package/src/la-protocol-v2/contract/ready.schema.json +106 -0
  31. package/src/la-protocol-v2/contract/register.schema.json +92 -0
  32. package/src/la-protocol-v2/contract/scan.upload.schema.json +59 -0
  33. package/src/la-protocol-v2/contract/scan.uploaded.schema.json +51 -0
  34. package/src/la-protocol-v2/contract/webhook.forward.schema.json +69 -0
  35. package/src/la-protocol-v2/contract.js +90 -0
  36. package/src/la-protocol-v2/errors.js +34 -0
  37. package/src/la-protocol-v2/index.js +8 -0
  38. package/src/la-protocol-v2/link.js +93 -0
  39. package/src/la-protocol-v2/runtime.js +72 -0
  40. package/src/la-protocol-v2/session.js +650 -0
  41. package/src/la-protocol-v2/strict-json.js +47 -0
  42. package/src/server.js +22 -4
  43. package/test/contract/la-protocol-v2/vectors/ack.vectors.json +149 -0
  44. package/test/contract/la-protocol-v2/vectors/alert.vectors.json +101 -0
  45. package/test/contract/la-protocol-v2/vectors/approval.decision.vectors.json +165 -0
  46. package/test/contract/la-protocol-v2/vectors/audit.event.vectors.json +156 -0
  47. package/test/contract/la-protocol-v2/vectors/config.update.vectors.json +189 -0
  48. package/test/contract/la-protocol-v2/vectors/content.request.vectors.json +325 -0
  49. package/test/contract/la-protocol-v2/vectors/content.response.vectors.json +413 -0
  50. package/test/contract/la-protocol-v2/vectors/cross-type.vectors.json +260 -0
  51. package/test/contract/la-protocol-v2/vectors/error.vectors.json +179 -0
  52. package/test/contract/la-protocol-v2/vectors/heartbeat.vectors.json +109 -0
  53. package/test/contract/la-protocol-v2/vectors/hello.vectors.json +224 -0
  54. package/test/contract/la-protocol-v2/vectors/incident.candidate.vectors.json +186 -0
  55. package/test/contract/la-protocol-v2/vectors/job.assign.vectors.json +631 -0
  56. package/test/contract/la-protocol-v2/vectors/job.cancel.vectors.json +133 -0
  57. package/test/contract/la-protocol-v2/vectors/job.event.vectors.json +233 -0
  58. package/test/contract/la-protocol-v2/vectors/job.result.vectors.json +137 -0
  59. package/test/contract/la-protocol-v2/vectors/log.stats.vectors.json +137 -0
  60. package/test/contract/la-protocol-v2/vectors/ready.vectors.json +177 -0
  61. package/test/contract/la-protocol-v2/vectors/register.vectors.json +399 -0
  62. package/test/contract/la-protocol-v2/vectors/scan.upload.vectors.json +137 -0
  63. package/test/contract/la-protocol-v2/vectors/scan.uploaded.vectors.json +101 -0
  64. package/test/contract/la-protocol-v2/vectors/traces/invalid-agent-mismatch.json +140 -0
  65. package/test/contract/la-protocol-v2/vectors/traces/invalid-chunk-after-last.json +200 -0
  66. package/test/contract/la-protocol-v2/vectors/traces/invalid-chunk-byte-length-mismatch.json +174 -0
  67. package/test/contract/la-protocol-v2/vectors/traces/invalid-chunk-last-flag.json +200 -0
  68. package/test/contract/la-protocol-v2/vectors/traces/invalid-chunk-out-of-order.json +200 -0
  69. package/test/contract/la-protocol-v2/vectors/traces/invalid-chunk-total-changes.json +200 -0
  70. package/test/contract/la-protocol-v2/vectors/traces/invalid-clock-skew.json +46 -0
  71. package/test/contract/la-protocol-v2/vectors/traces/invalid-event-before-ready.json +72 -0
  72. package/test/contract/la-protocol-v2/vectors/traces/invalid-first-frame-not-register.json +48 -0
  73. package/test/contract/la-protocol-v2/vectors/traces/invalid-frame-over-48kib.json +141 -0
  74. package/test/contract/la-protocol-v2/vectors/traces/invalid-la-sends-job-assign.json +173 -0
  75. package/test/contract/la-protocol-v2/vectors/traces/invalid-params-raw-differs-from-payload.json +172 -0
  76. package/test/contract/la-protocol-v2/vectors/traces/invalid-payload-above-limit.json +174 -0
  77. package/test/contract/la-protocol-v2/vectors/traces/invalid-resume-restarts-seq.json +144 -0
  78. package/test/contract/la-protocol-v2/vectors/traces/invalid-seq-gap.json +166 -0
  79. package/test/contract/la-protocol-v2/vectors/traces/invalid-seq-repeated.json +166 -0
  80. package/test/contract/la-protocol-v2/vectors/traces/invalid-sig-tenant-mismatch.json +172 -0
  81. package/test/contract/la-protocol-v2/vectors/traces/invalid-signature-warn-with-enforce-required.json +77 -0
  82. package/test/contract/la-protocol-v2/vectors/traces/invalid-tenant-mismatch.json +140 -0
  83. package/test/contract/la-protocol-v2/vectors/traces/invalid-type-outside-allowlist.json +140 -0
  84. package/test/contract/la-protocol-v2/vectors/traces/valid-content-chunks-in-order.json +224 -0
  85. package/test/contract/la-protocol-v2/vectors/traces/valid-handshake-and-job.json +266 -0
  86. package/test/contract/la-protocol-v2/vectors/traces/valid-resume-continues-seq.json +168 -0
  87. package/test/contract/la-protocol-v2/vectors/traces/valid-retransmit-is-idempotent.json +193 -0
  88. package/test/contract/la-protocol-v2/vectors/webhook.forward.vectors.json +170 -0
  89. package/test/la-protocol-v2/contract.test.js +131 -0
  90. package/test/la-protocol-v2/helpers/la-rig.js +83 -0
  91. package/test/la-protocol-v2/helpers/reference-cp.js +349 -0
  92. package/test/la-protocol-v2/helpers/spawn-agent.js +35 -0
  93. package/test/la-protocol-v2/link.test.js +141 -0
  94. package/test/la-protocol-v2/session-inbound.test.js +279 -0
  95. package/test/la-protocol-v2/session-outbound.test.js +382 -0
  96. package/test/la-protocol-v2/traces.test.js +270 -0
  97. package/test/la-protocol-v2/tunnel-e2e.test.js +124 -0
  98. package/tools/graphify-validation/extras/README.md +19 -0
  99. package/tools/graphify-validation/extras/bymethod.py +35 -0
  100. package/tools/graphify-validation/extras/docker/Dockerfile +16 -0
  101. package/tools/graphify-validation/extras/docker/inner.sh +7 -0
  102. package/tools/graphify-validation/extras/g10.py +59 -0
  103. package/tools/graphify-validation/extras/g3aug.py +49 -0
  104. package/tools/graphify-validation/extras/hybrid.py +34 -0
  105. package/tools/graphify-validation/extras/truth2.py +44 -0
  106. package/tools/interop/LaV2NodeInteropTest.java +266 -0
  107. package/tools/interop/README.md +21 -0
  108. package/tools/interop/la-interop-client.mjs +97 -0
@@ -0,0 +1,270 @@
1
+ // F3-05b: the 24 whole-session traces of the contract (F3-22), reproduced in Node. "Traces are language
2
+ // neutral; the LA must reproduce each expect.error." Three things are shown for them:
3
+ // 1. the reference CP (test helper) reproduces every trace: same error at the same frame (so the model the
4
+ // other tests use is the model of the contract);
5
+ // 2. the two traces whose error is in a CP->LA frame (params_mismatch, tenant_mismatch of the signature)
6
+ // are reproduced by the REAL LA session, same error at the same frame;
7
+ // 3. for the others (an LA frame that the CP refuses) the LA's own send path refuses to produce the same
8
+ // frame, with the same code; the valid traces are replayed with the LA as the LA.
9
+ import { test } from "node:test";
10
+ import assert from "node:assert/strict";
11
+ import fs from "node:fs";
12
+ import path from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+ import { ReferenceCp, uuid, waitFor } from "./helpers/reference-cp.js";
15
+ import { inMemory, signingFor } from "./helpers/la-rig.js";
16
+ import { V2Session } from "../../src/la-protocol-v2/session.js";
17
+
18
+ const DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "../contract/la-protocol-v2/vectors/traces");
19
+ const traces = fs.readdirSync(DIR).filter((f) => f.endsWith(".json")).sort().map((f) => ({ file: f, name: f.replace(".json", ""), ...JSON.parse(fs.readFileSync(path.join(DIR, f), "utf8")) }));
20
+
21
+ test("the suite has all 24 traces (20 invalid, 4 valid)", () => {
22
+ assert.equal(traces.length, 24);
23
+ assert.equal(traces.filter((t) => t.expect.ok).length, 4);
24
+ });
25
+
26
+ const textOf = (fr) => {
27
+ let text = JSON.stringify(fr.frame);
28
+ if (fr.inflateBytesTo) text += " ".repeat(Math.max(0, fr.inflateBytesTo - Buffer.byteLength(text)));
29
+ return text;
30
+ };
31
+
32
+ /** The reference CP in the state the trace starts from (a resumed session when the trace says so). */
33
+ function cpFor(trace) {
34
+ const s = trace.session;
35
+ const cp = new ReferenceCp({ tenantId: s.tenantId, agentId: s.agentId, requireEnforce: s.requireEnforce, now: () => Date.parse(s.now) });
36
+ const ready = trace.frames.map((f) => f.frame).find((f) => f.type === "ready");
37
+ const reg = trace.frames.map((f) => f.frame).find((f) => f.type === "register");
38
+ if (ready && ready.payload.resumed) {
39
+ cp.sessionStarted = true;
40
+ cp.sessionId = ready.payload.sessionId;
41
+ cp.laSeq = ready.payload.peerLastSeq;
42
+ cp.cpSeq = reg.payload.resume.lastRecvSeq;
43
+ }
44
+ cp.attach(() => {}, () => {});
45
+ return cp;
46
+ }
47
+
48
+ // ------------------------------------------------------------------------------------------ 1. the reference CP
49
+
50
+ for (const t of traces) {
51
+ const cpSideError = t.expect.error && t.frames[t.expect.atFrame].dir === "cp->la";
52
+ test(`reference CP reproduces ${t.name}${cpSideError ? " (n/a: the error is in a CP frame, see 2)" : ""}`, { skip: cpSideError }, () => {
53
+ const cp = cpFor(t);
54
+ let failed = null;
55
+ for (let i = 0; i < t.frames.length && !failed; i++) {
56
+ const fr = t.frames[i];
57
+ if (fr.dir === "cp->la") { cp.cpSeq = Math.max(cp.cpSeq, fr.frame.seq); continue; }
58
+ const r = cp.feed(textOf(fr));
59
+ if (r) failed = { error: r.code, atFrame: i };
60
+ }
61
+ if (t.expect.ok) {
62
+ assert.equal(failed, null, JSON.stringify(failed));
63
+ if (t.expect.duplicateFrames) assert.equal(cp.duplicates.length, t.expect.duplicateFrames.length);
64
+ } else {
65
+ assert.deepEqual(failed, { error: t.expect.error, atFrame: t.expect.atFrame });
66
+ }
67
+ });
68
+ }
69
+
70
+ // ------------------------------------------------------------------------------------------ 2. errors in CP frames, by the LA
71
+
72
+ for (const name of ["invalid-params-raw-differs-from-payload", "invalid-sig-tenant-mismatch"]) {
73
+ test(`the LA reproduces ${name}: ${traces.find((t) => t.name === name).expect.error} at frame ${traces.find((t) => t.name === name).expect.atFrame}`, async () => {
74
+ const t = traces.find((x) => x.name === name);
75
+ const cp = new ReferenceCp({ tenantId: t.session.tenantId, agentId: t.session.agentId });
76
+ // the trace's signatures are placeholders: mode off skips the cryptography, the structural checks still run
77
+ const env = inMemory(cp, { mode: "off", tenantId: t.session.tenantId, agentId: t.session.agentId });
78
+ env.session.attach(env.transport);
79
+ await waitFor(() => env.session.state === "ready");
80
+ const bad = t.frames[t.expect.atFrame].frame;
81
+ env.session.setHandler(bad.type, () => ({ accept: true }));
82
+ env.session.handleText(JSON.stringify(bad), env.transport);
83
+ await waitFor(() => cp.peerErrors.length > 0);
84
+ assert.deepEqual(cp.peerErrors, [t.expect.error]);
85
+ assert.equal(env.session.stats.dataOut, 0);
86
+ env.session.close();
87
+ });
88
+ }
89
+
90
+ // ------------------------------------------------------------------------------------------ 3. the LA never produces them
91
+
92
+ const LA_FRAMES = (t) => t.frames.map((f, i) => ({ ...f, i })).filter((f) => f.dir === "la->cp");
93
+
94
+ async function readyLa() {
95
+ const cp = new ReferenceCp();
96
+ const env = inMemory(cp).start();
97
+ await waitFor(() => env.session.state === "ready");
98
+ return { cp, ...env };
99
+ }
100
+
101
+ for (const name of ["invalid-chunk-after-last", "invalid-chunk-byte-length-mismatch", "invalid-chunk-last-flag", "invalid-chunk-out-of-order", "invalid-chunk-total-changes", "invalid-payload-above-limit"]) {
102
+ test(`the LA refuses to send the chunks of ${name}: same code at the same frame`, async () => {
103
+ const t = traces.find((x) => x.name === name);
104
+ const { cp, session } = await readyLa();
105
+ let got = null;
106
+ for (const fr of LA_FRAMES(t)) {
107
+ if (fr.frame.type !== "content.response") continue;
108
+ try {
109
+ await session.enqueue("content.response", fr.frame.payload, { requestId: fr.frame.requestId, chunk: fr.frame.chunk }).then((h) => h.acked);
110
+ } catch (e) { got = { error: e.code, atFrame: fr.i }; break; }
111
+ }
112
+ // the oversized trace chunk is refused by the schema (payload maxLength), which the CP reports as payload_too_large
113
+ assert.deepEqual(got, { error: t.expect.error, atFrame: t.expect.atFrame });
114
+ assert.deepEqual(cp.errorsSent, [], "nothing invalid reached the CP");
115
+ session.close();
116
+ });
117
+ }
118
+
119
+ test("the LA never produces invalid-event-before-ready / invalid-first-frame-not-register: register and hello first, no data before ready", async () => {
120
+ const cp = new ReferenceCp();
121
+ const sent = [];
122
+ const env = inMemory(cp);
123
+ const swallow = { send: (t) => sent.push(JSON.parse(t)), close() {}, bufferedAmount: () => 0 };
124
+ env.session.attach(swallow);
125
+ assert.deepEqual(sent.map((f) => f.type), ["register", "hello"], "the first frame is register, then hello");
126
+ await assert.rejects(env.session.enqueue("job.event", { jobId: "c3d4e5f6-a7b8-4c9d-8e0f-2a3b4c5d6e7f", state: "running" }), (e) => e.code === "not_ready");
127
+ assert.equal(sent.length, 2);
128
+ env.session.close();
129
+ });
130
+
131
+ test("the LA never produces invalid-clock-skew: register.ts is the clock of the LA, hello carries clockEpochMs", async () => {
132
+ const cp = new ReferenceCp();
133
+ const sent = [];
134
+ const env = inMemory(cp);
135
+ env.session.attach({ send: (t) => sent.push(JSON.parse(t)), close() {}, bufferedAmount: () => 0 });
136
+ assert.ok(Math.abs(Date.parse(sent[0].ts) - Date.now()) < 2000);
137
+ assert.ok(Math.abs(sent[1].payload.clockEpochMs - Date.now()) < 2000);
138
+ env.session.close();
139
+ });
140
+
141
+ test("the LA never produces invalid-signature-warn-with-enforce-required: hello.signatureMode is the real mode, and a CP that requires enforce says so", async () => {
142
+ const cp = new ReferenceCp({ requireEnforce: true });
143
+ const env = inMemory(cp, { mode: "warn" }).start();
144
+ await waitFor(() => cp.errorsSent.length > 0);
145
+ assert.deepEqual(cp.errorsSent, ["signature_mode_insufficient"]);
146
+ assert.equal(env.session.state, "await_ready"); // not ready, no data possible
147
+ assert.equal(cp.hellos.length, 0);
148
+ env.session.close();
149
+ });
150
+
151
+ test("the LA never produces invalid-agent-mismatch / invalid-tenant-mismatch: every frame carries the configured ids, never one from a payload", async () => {
152
+ const { cp, session } = await readyLa();
153
+ await session.send("job.event", { jobId: "c3d4e5f6-a7b8-4c9d-8e0f-2a3b4c5d6e7f", state: "running" });
154
+ for (const f of cp.received) {
155
+ assert.equal(f.tenantId, cp.tenantId);
156
+ assert.equal(f.agentId, cp.agentId);
157
+ }
158
+ // there is no way to pass an identity to enqueue(); a payload that tries is refused by the closed schema
159
+ await assert.rejects(session.enqueue("job.event", { jobId: "c3d4e5f6-a7b8-4c9d-8e0f-2a3b4c5d6e7f", state: "running", tenantId: "99999999-5a4d-4e7b-9c21-0a1b2c3d4e5f" }), (e) => e.code === "schema_violation");
160
+ session.close();
161
+ });
162
+
163
+ test("the LA never produces invalid-la-sends-job-assign / invalid-type-outside-allowlist", async () => {
164
+ const { cp, session } = await readyLa();
165
+ await assert.rejects(session.enqueue("job.assign", {}), (e) => e.code === "wrong_direction");
166
+ await assert.rejects(session.enqueue("shell.exec", {}), (e) => e.code === "unknown_type");
167
+ assert.deepEqual(cp.errorsSent, []);
168
+ session.close();
169
+ });
170
+
171
+ test("the LA never produces invalid-seq-gap / invalid-seq-repeated / invalid-resume-restarts-seq: consecutive seq, one msgId per seq, a resume continues", async () => {
172
+ const { cp, session } = await readyLa();
173
+ for (let i = 0; i < 4; i++) await session.send("job.event", { jobId: "c3d4e5f6-a7b8-4c9d-8e0f-2a3b4c5d6e7f", state: "running", iteration: i });
174
+ assert.deepEqual(cp.received.map((f) => f.seq), [1, 2, 3, 4]);
175
+ assert.equal(new Set(cp.received.map((f) => f.msgId)).size, 4);
176
+ assert.deepEqual(cp.errorsSent, []);
177
+ session.close();
178
+ });
179
+
180
+ test("the LA never produces invalid-frame-over-48kib: frames are compact JSON and the size is checked before sending", async () => {
181
+ const { cp, session } = await readyLa();
182
+ await assert.rejects(session.enqueue("job.event", { jobId: "c3d4e5f6-a7b8-4c9d-8e0f-2a3b4c5d6e7f", state: "running", phase: "x".repeat(60_000) }), (e) => ["frame_too_large", "payload_too_large", "schema_violation"].includes(e.code));
183
+ // and the same frame as the trace has it (padded to 50000 bytes) is refused on the way IN as frame_too_large
184
+ const t = traces.find((x) => x.name === "invalid-frame-over-48kib");
185
+ const fr = t.frames[t.expect.atFrame];
186
+ const { transport } = { transport: session.transport };
187
+ session.handleText(textOf(fr), transport);
188
+ await waitFor(() => cp.peerErrors.length > 0);
189
+ assert.deepEqual(cp.peerErrors, ["frame_too_large"]);
190
+ session.close();
191
+ });
192
+
193
+ // ------------------------------------------------------------------------------------------ valid traces, LA as the LA
194
+
195
+ test("valid-handshake-and-job: CP job.assign -> LA acks it (ackSeq 1) and then reports job.event x2 + job.result with seq 1..3", async () => {
196
+ const t = traces.find((x) => x.name === "valid-handshake-and-job");
197
+ const cp = new ReferenceCp({ tenantId: t.session.tenantId, agentId: t.session.agentId, requireEnforce: true });
198
+ const env = inMemory(cp, { tenantId: t.session.tenantId, agentId: t.session.agentId }).start();
199
+ await waitFor(() => env.session.state === "ready");
200
+ const assign = t.frames.find((f) => f.frame.type === "job.assign").frame;
201
+ env.session.setHandler("job.assign", (ctx) => { assert.deepEqual(ctx.params, assign.payload); return { accept: true }; });
202
+ cp.sendData("job.assign", assign.payload, { workspaceId: assign.workspaceId, requestId: assign.requestId });
203
+ await waitFor(() => cp.acksFromLa.length === 1);
204
+ assert.deepEqual(cp.acksFromLa[0], { ackSeq: 1, status: "accepted", errorCode: undefined });
205
+ for (const fr of LA_FRAMES(t).filter((f) => ["job.event", "job.result"].includes(f.frame.type))) {
206
+ await env.session.send(fr.frame.type, fr.frame.payload, { workspaceId: fr.frame.workspaceId });
207
+ }
208
+ assert.deepEqual(cp.received.map((f) => [f.type, f.seq]), [["job.event", 1], ["job.event", 2], ["job.result", 3]]);
209
+ assert.deepEqual(cp.errorsSent, []);
210
+ env.session.close();
211
+ });
212
+
213
+ test("valid-content-chunks-in-order: the three chunks of the trace go out in order and the CP accepts them", async () => {
214
+ const t = traces.find((x) => x.name === "valid-content-chunks-in-order");
215
+ const cp = new ReferenceCp({ tenantId: t.session.tenantId, agentId: t.session.agentId });
216
+ const env = inMemory(cp, { tenantId: t.session.tenantId, agentId: t.session.agentId }).start();
217
+ await waitFor(() => env.session.state === "ready");
218
+ for (const fr of LA_FRAMES(t).filter((f) => f.frame.type === "content.response")) {
219
+ await env.session.send("content.response", fr.frame.payload, { requestId: fr.frame.requestId, chunk: fr.frame.chunk, workspaceId: fr.frame.workspaceId });
220
+ }
221
+ assert.deepEqual(cp.received.map((f) => [f.seq, f.chunk.index]), [[1, 0], [2, 1], [3, 2]]);
222
+ assert.deepEqual(cp.errorsSent, []);
223
+ env.session.close();
224
+ });
225
+
226
+ test("valid-retransmit-is-idempotent: an ack lost -> the same frame again -> the CP re-acks and counts one event", async () => {
227
+ const cp = new ReferenceCp({ limits: { ackTimeoutMs: 100 } });
228
+ const env = inMemory(cp).start();
229
+ await waitFor(() => env.session.state === "ready");
230
+ cp.dropAcks = 1;
231
+ await env.session.send("job.event", { jobId: "c3d4e5f6-a7b8-4c9d-8e0f-2a3b4c5d6e7f", state: "running" });
232
+ await env.session.send("job.event", { jobId: "c3d4e5f6-a7b8-4c9d-8e0f-2a3b4c5d6e7f", state: "succeeded" });
233
+ assert.deepEqual(cp.duplicates, [1]);
234
+ assert.deepEqual(cp.received.map((f) => f.seq), [1, 2]);
235
+ assert.deepEqual(cp.errorsSent, []);
236
+ env.session.close();
237
+ });
238
+
239
+ test("valid-resume-continues-seq: after a resumed ready the LA continues at peerLastSeq+1", async () => {
240
+ const t = traces.find((x) => x.name === "valid-resume-continues-seq");
241
+ const cp = new ReferenceCp({ tenantId: t.session.tenantId, agentId: t.session.agentId });
242
+ const env = inMemory(cp, { tenantId: t.session.tenantId, agentId: t.session.agentId });
243
+ // first life: 5 events delivered
244
+ env.session.attach(env.transport);
245
+ await waitFor(() => env.session.state === "ready");
246
+ for (let i = 0; i < 5; i++) await env.session.send("job.event", { jobId: "c3d4e5f6-a7b8-4c9d-8e0f-2a3b4c5d6e7f", state: "running", iteration: i });
247
+ // the CP also sent 4 frames
248
+ env.session.setHandler("job.cancel", () => ({ accept: true }));
249
+ for (let i = 0; i < 4; i++) cp.sendData("job.cancel", { jobId: "c3d4e5f6-a7b8-4c9d-8e0f-2a3b4c5d6e7f", reason: "user" });
250
+ await waitFor(() => env.session.lastRecvSeq === 4);
251
+ // second life: same session object (the LA process kept it), new connection
252
+ const t2 = { send: (x) => { env.wire.toCp.push(x); cp.feed(x); }, close() {}, bufferedAmount: () => 0 };
253
+ env.session.detach(env.transport);
254
+ cp.attach((x) => queueMicrotask(() => env.session.handleText(x, t2)), () => {});
255
+ env.session.attach(t2);
256
+ await waitFor(() => env.session.state === "ready");
257
+ assert.deepEqual(cp.registers.at(-1).resume, { sessionId: cp.sessionId, lastRecvSeq: 4 });
258
+ await env.session.send("job.event", { jobId: "c3d4e5f6-a7b8-4c9d-8e0f-2a3b4c5d6e7f", state: "running", iteration: 5 });
259
+ assert.equal(cp.received.at(-1).seq, 6, "continues at peerLastSeq + 1");
260
+ assert.deepEqual(cp.errorsSent, []);
261
+ env.session.close();
262
+ });
263
+
264
+ test("V2Session is exported and constructible without side effects (no timers left running)", () => {
265
+ const cp = new ReferenceCp();
266
+ const s = new V2Session({ tenantId: cp.tenantId, agentId: cp.agentId, signing: signingFor({ keyId: cp.keyId, publicKeyRaw: cp.publicKeyRaw }), laVersion: "1.3.0" });
267
+ assert.equal(s.state, "idle");
268
+ s.close();
269
+ assert.equal(uuid().length, 36);
270
+ });
@@ -0,0 +1,124 @@
1
+ // F3-05b end to end: the real agent process (src/server.js) against the reference CP.
2
+ // - flag unset / false: the v1 client, nothing of v2 on the wire (characterization; the frames are the v1 ones)
3
+ // - flag true: v2 handshake with a signed ready, v1 commands still answered on the same connection
4
+ // - flag true against a gateway without v2: falls back to the v1 register on the same connection
5
+ // - flag true without an agent id: stays v1
6
+ import { test } from "node:test";
7
+ import assert from "node:assert/strict";
8
+ import { ReferenceCpServer, TENANT, AGENT, waitFor } from "./helpers/reference-cp.js";
9
+ import { runAgent } from "./helpers/spawn-agent.js";
10
+
11
+ const baseEnv = (srv) => ({
12
+ TENANT_ID: TENANT,
13
+ LOCAL_AGENT_SIGNATURE_MODE: "enforce",
14
+ LOCAL_AGENT_SIGNING_PUBLIC_KEYS: `${srv.keyId}=${srv.publicKeyRaw}`,
15
+ });
16
+
17
+ for (const flag of [undefined, "false", "off", "0", "bogus"]) {
18
+ test(`flag ${flag === undefined ? "unset" : JSON.stringify(flag)}: the v1 client, exactly: legacy register, no v2 frame, v1 command answered`, async () => {
19
+ const srv = await new ReferenceCpServer({}).start();
20
+ try {
21
+ await runAgent({
22
+ // everything v2 needs IS configured (UUID tenant, agent id, trusted key): only the flag keeps it off
23
+ srv, cfg: { authorizationId: AGENT, tenantId: TENANT }, env: { ...baseEnv(srv), LOCAL_AGENT_PROTOCOL_V2: flag },
24
+ until: async () => {
25
+ if (srv.ws && srv.legacy.some((f) => f.type === "register") && !srv.sent) {
26
+ srv.sent = true;
27
+ srv.ws.send(srv.cp.v1Command("health", {}));
28
+ }
29
+ return srv.legacy.some((f) => f.command === "health");
30
+ },
31
+ });
32
+ const reg = srv.legacy.find((f) => f.type === "register");
33
+ assert.deepEqual(Object.keys(reg).sort(), ["port", "tenantId", "type", "workspacePath"]);
34
+ assert.equal(reg.tenantId, TENANT);
35
+ const health = srv.legacy.find((f) => f.command === "health");
36
+ assert.equal(health.status, "ok");
37
+ assert.equal(srv.cp.rawIn.length, 0, "no v2 frame was ever sent");
38
+ assert.equal(srv.cp.registers.length, 0);
39
+ assert.equal(srv.textsFromLa.some((t) => t.includes("schemaVersion")), false);
40
+ } finally { await srv.stop(); }
41
+ });
42
+ }
43
+
44
+ test("flag true: v2 handshake (register, hello, signed ready), no v1 register, and a v1 command is still answered on the same connection", async () => {
45
+ const srv = await new ReferenceCpServer({ requireEnforce: true }).start();
46
+ try {
47
+ await runAgent({
48
+ srv, cfg: { authorizationId: AGENT, tenantId: TENANT },
49
+ env: { ...baseEnv(srv), LOCAL_AGENT_PROTOCOL_V2: "true" },
50
+ until: async () => {
51
+ if (srv.cp.state === "ready" && !srv.sent) { srv.sent = true; srv.ws.send(srv.cp.v1Command("health", {})); }
52
+ return srv.legacy.some((f) => f.command === "health");
53
+ },
54
+ });
55
+ assert.equal(srv.connections, 1);
56
+ const reg = srv.cp.registers[0];
57
+ assert.equal(reg.protocol, 2);
58
+ assert.equal(reg.signatureMode, "enforce");
59
+ assert.match(reg.laVersion, /^\d+\.\d+\.\d+$/);
60
+ assert.deepEqual(reg.capabilities, ["chunk.base64", "protocol.v2", "v1.commands"]);
61
+ assert.ok(reg.workspaces.every((w) => /^[0-9a-f]{64}$/.test(w.pathSha256)), "only hashes of paths");
62
+ assert.equal(JSON.stringify(srv.cp.rawIn).includes("/tmp") || JSON.stringify(srv.cp.rawIn).includes("v2e2e-ws"), false, "no workspace path on the wire");
63
+ assert.equal(srv.cp.hellos[0].signatureMode, "enforce");
64
+ assert.equal(srv.legacy.some((f) => f.type === "register"), false, "no v1 register: v2 owns the registration");
65
+ assert.deepEqual(srv.cp.errorsSent, []);
66
+ assert.equal(srv.legacy.find((f) => f.command === "health").status, "ok", "v1 command on a v2 connection");
67
+ } finally { await srv.stop(); }
68
+ });
69
+
70
+ test("flag true, the CP sends a signed v2 job.cancel: acked `rejected protocol_unsupported` (no handler yet), connection stays up", async () => {
71
+ const srv = await new ReferenceCpServer({ requireEnforce: true }).start();
72
+ try {
73
+ await runAgent({
74
+ srv, cfg: { authorizationId: AGENT, tenantId: TENANT },
75
+ env: { ...baseEnv(srv), LOCAL_AGENT_PROTOCOL_V2: "true" },
76
+ until: async () => {
77
+ if (srv.cp.state === "ready" && !srv.sent) { srv.sent = true; srv.cp.sendData("job.cancel", { jobId: "c3d4e5f6-a7b8-4c9d-8e0f-2a3b4c5d6e7f", reason: "user" }); }
78
+ return srv.cp.acksFromLa.length > 0;
79
+ },
80
+ });
81
+ assert.deepEqual(srv.cp.acksFromLa, [{ ackSeq: 1, status: "rejected", errorCode: "protocol_unsupported" }]);
82
+ assert.deepEqual(srv.cp.errorsSent, []);
83
+ } finally { await srv.stop(); }
84
+ });
85
+
86
+ test("flag true against a gateway that does not speak v2: after the handshake timeout the v1 register goes out on the same connection", async () => {
87
+ const srv = await new ReferenceCpServer({ v2Disabled: true }).start();
88
+ try {
89
+ await runAgent({
90
+ srv, cfg: { authorizationId: AGENT, tenantId: TENANT },
91
+ env: { ...baseEnv(srv), LOCAL_AGENT_PROTOCOL_V2: "true", LOCAL_AGENT_V2_HANDSHAKE_MS: "300" },
92
+ until: async () => srv.legacy.some((f) => f.type === "register"),
93
+ });
94
+ const reg = srv.legacy.find((f) => f.type === "register");
95
+ assert.deepEqual(Object.keys(reg).sort(), ["port", "tenantId", "type", "workspacePath"]);
96
+ assert.equal(srv.connections, 1, "same connection");
97
+ } finally { await srv.stop(); }
98
+ });
99
+
100
+ test("flag true without an agent id (no authorizationId): v1 only, nothing of v2 on the wire", async () => {
101
+ const srv = await new ReferenceCpServer({}).start();
102
+ try {
103
+ await runAgent({
104
+ srv, env: { ...baseEnv(srv), LOCAL_AGENT_PROTOCOL_V2: "true" },
105
+ until: async () => srv.legacy.some((f) => f.type === "register"),
106
+ });
107
+ assert.equal(srv.cp.rawIn.length, 0);
108
+ assert.ok(srv.legacy.some((f) => f.type === "register"));
109
+ } finally { await srv.stop(); }
110
+ });
111
+
112
+ test("flag true with a tenant id that is not a UUID: v1 only", async () => {
113
+ const srv = await new ReferenceCpServer({}).start();
114
+ try {
115
+ await runAgent({
116
+ srv, cfg: { authorizationId: AGENT },
117
+ env: { ...baseEnv(srv), TENANT_ID: "tenant-test", LOCAL_AGENT_SIGNATURE_MODE: "off", LOCAL_AGENT_PROTOCOL_V2: "true" },
118
+ until: async () => srv.legacy.some((f) => f.type === "register"),
119
+ });
120
+ assert.equal(srv.cp.rawIn.length, 0);
121
+ } finally { await srv.stop(); }
122
+ });
123
+
124
+ test("sanity: waitFor is exported", () => assert.equal(typeof waitFor, "function"));
@@ -0,0 +1,19 @@
1
+ # Graphify validation extras (T4-06, round 2, 2026-10-28)
2
+
3
+ Evidence scripts used for `10 Library/Plans/Fecho Dados 2026-10-28 T4.md`. They sit beside the harness and read its
4
+ output; nothing here is part of the agent runtime. Layout expected under `$GV_T4_ROOT`:
5
+ `harness/` (a `git archive` of this repo), `out/run1/` (a `run.sh --out` directory: `venv/`, `work/`, `results/`),
6
+ `bin/rg` (ripgrep wrapper), `g10/home/` (empty HOME), `pub/` (shallow clones of public Go/Terraform repos).
7
+
8
+ | Script | What it measures |
9
+ |---|---|
10
+ | `g10.py` | cold run, network denied, determinism and per-language file coverage for extra repos (Python, TS, Go, Terraform, SQL) |
11
+ | `truth2.py` | `affected` recall against a code-only ground truth (comment, import and test lines removed), class level and class+methods |
12
+ | `bymethod.py` | `affected` by class versus by method, against `rg -w` as the harness does |
13
+ | `hybrid.py` | recall@8 of graph, lexical, reciprocal-rank fusion and lexical-first interleave |
14
+ | `g3aug.py` | G3 Spring fixture with a wrapper-level "call via interface reaches implementations" post-process |
15
+ | `docker/` | G9: image from `requirements-py311.lock` (build context `tools/graphify-validation`), `inner.sh` measures wall time and cgroup peak |
16
+
17
+ Docker run used for the numbers: `docker run --rm --read-only --network none --user 10001:10001 --cpus=2 --memory=4g
18
+ --tmpfs /tmp -e HOME=/data/home -v DATA:/data -v inner.sh:/inner.sh:ro --entrypoint /bin/sh IMAGE /inner.sh REPO RUN`.
19
+ Public repos are only parsed (tree-sitter), never executed. No customer repository is involved.
@@ -0,0 +1,35 @@
1
+ import os
2
+ import sys, json, pathlib, subprocess, os
3
+ SP=pathlib.Path(os.environ['GV_T4_ROOT'])
4
+ sys.path.insert(0,str(SP/'harness/tools/graphify-validation/lib'))
5
+ os.environ['RG']=str(SP/'bin/rg')
6
+ import affected_vs_rg as A, gv_common as gc, graph_metrics as gm
7
+ S=json.load(open(SP/'harness/tools/graphify-validation/config/symbols.json'))
8
+ env=gc.clean_env(SP/'g10/home',str(SP/'out/run1/venv/bin')); G=str(SP/'out/run1/venv/bin/graphify')
9
+ out={}
10
+ for repo in ['vh-api-gateway','visionhammer-agents','deepdebug-local-agent']:
11
+ wd=SP/'out/run1/work'/repo; g=gm.load_graph(wd)
12
+ syms=S[repo] if isinstance(S,dict) else S
13
+ rows=[]
14
+ for sym in syms:
15
+ cls=[n for n in g['nodes'] if n.get('label')==sym and n.get('source_file') and not n['label']==n['source_file'].rsplit('/',1)[-1]]
16
+ if not cls: rows.append({'symbol':sym,'status':'nodef'}); continue
17
+ defs={n['source_file'] for n in cls}
18
+ ids=[n['id'] for n in cls]
19
+ for n in g['nodes']:
20
+ if n.get('source_file') in defs and n.get('label','').startswith('.') and n['label'].endswith('()'):
21
+ ids.append(n['id'])
22
+ files=set()
23
+ for i in ids:
24
+ r=subprocess.run([G,'affected',i,'--depth','1'],cwd=str(wd),env=env,capture_output=True,text=True,timeout=300)
25
+ files|=A.parse_affected(r.stdout)
26
+ files-=defs
27
+ rgf=gc.files_with_word(wd,sym,include_tests=True)-defs
28
+ m=lambda s:{f for f in s if not gc.is_test_path(f)}
29
+ gf,rf=m(files),m(rgf)
30
+ rows.append({'symbol':sym,'methods':len(ids)-len(cls),'graph':len(gf),'rg':len(rf),'recall':round(100*len(gf&rf)/len(rf),1) if rf else None,'precision':round(100*len(gf&rf)/len(gf),1) if gf else None})
31
+ rr=[r for r in rows if r.get('recall') is not None]
32
+ tot_rg=sum(r['rg'] for r in rr); tot_both=sum(round(r['recall']*r['rg']/100) for r in rr)
33
+ out[repo]={'rows':rows,'agg_recall_pct':round(100*tot_both/tot_rg,1) if tot_rg else None}
34
+ print(repo,out[repo]['agg_recall_pct'],[(r['symbol'],r.get('methods'),r.get('recall')) for r in rows])
35
+ json.dump(out,open(SP/'g10/bymethod.json','w'),indent=1)
@@ -0,0 +1,16 @@
1
+ # T4-06 G9 test image: mirrors the python slice of the LA Dockerfile.full (node:20-slim = Debian 12, python3 3.11) + packaging plan 2.2
2
+ FROM node:20-slim AS base
3
+ RUN apt-get update && apt-get install -y --no-install-recommends git python3 python3-pip \
4
+ && rm -rf /var/lib/apt/lists/*
5
+
6
+ FROM base AS graphify
7
+ RUN apt-get update && apt-get install -y --no-install-recommends python3-venv ripgrep \
8
+ && rm -rf /var/lib/apt/lists/*
9
+ COPY requirements-py311.lock /tmp/graphify.lock
10
+ RUN python3 -m venv /opt/vh-graphify \
11
+ && /opt/vh-graphify/bin/pip install --no-cache-dir --disable-pip-version-check \
12
+ --require-hashes --no-deps --only-binary=:all: -r /tmp/graphify.lock \
13
+ && /opt/vh-graphify/bin/python -m compileall -q /opt/vh-graphify/lib \
14
+ && rm -f /opt/vh-graphify/bin/graphify-mcp /tmp/graphify.lock
15
+ RUN useradd -u 10001 -m -d /nonexistent-home vhga 2>/dev/null || true
16
+ ENV PATH=/opt/vh-graphify/bin:$PATH PYTHONDONTWRITEBYTECODE=1 PYTHONHASHSEED=0
@@ -0,0 +1,7 @@
1
+ #!/bin/sh
2
+ # runs inside the container: $1 = repo dir under /data/ws
3
+ S=$(date +%s.%N)
4
+ graphify update /data/ws/$1 > /data/log-$1-$2.txt 2>&1; RC=$?
5
+ E=$(date +%s.%N)
6
+ PEAK=$(cat /sys/fs/cgroup/memory.peak 2>/dev/null)
7
+ echo "repo=$1 run=$2 rc=$RC wall=$(echo "$E $S" | awk '{printf "%.1f",$1-$2}')s cgroup_peak_mb=$(echo $PEAK | awk '{printf "%.0f",$1/1048576}')"
@@ -0,0 +1,59 @@
1
+ import os
2
+ import sys, os, json, subprocess, shutil, pathlib, collections
3
+ LIB=os.path.join(os.environ['GV_T4_ROOT'],'harness/tools/graphify-validation/lib')
4
+ sys.path.insert(0, LIB)
5
+ import run_graphify as rgf, graph_metrics as gm, gv_common as gc
6
+ SP=pathlib.Path(os.environ['GV_T4_ROOT'])
7
+ VENV=SP/'out/run1/venv'; BIN=str(VENV/'bin'); G=str(VENV/'bin/graphify')
8
+ W=SP/'g10/work'; W.mkdir(exist_ok=True)
9
+ HOME=SP/'g10/home'; HOME.mkdir(exist_ok=True)
10
+ BASE='/Users/eduardooliveira/Documents/visionhammer projects/'
11
+ targets=[
12
+ ('vh-litellm', BASE+'vh-litellm','origin/main'),
13
+ ('visionhammer-pentest', BASE+'../x','') ,
14
+ ('vision-heart-glow','%svision-heart-glow-3478cd8d'%BASE,'origin/main'),
15
+ ('go-chi-chi', str(SP/'pub/chi'),'HEAD'),
16
+ ('spf13-cobra', str(SP/'pub/cobra'),'HEAD'),
17
+ ('tf-aws-vpc', str(SP/'pub/terraform-aws-vpc'),'HEAD'),
18
+ ('tf-provider-random', str(SP/'pub/terraform-provider-random'),'HEAD'),
19
+ ]
20
+ targets=[t for t in targets if t[2]]
21
+ targets.append(('visionhammer-pentest', str(pathlib.Path(os.environ.get('GV_PENTEST_CLONE','/nonexistent'))),'HEAD'))
22
+ wrap,mech=rgf.network_wrapper()
23
+ ok,why=rgf.verify_block(wrap,sys.executable)
24
+ print('network block',mech,ok,why)
25
+ EXTS={'.java':'java','.ts':'ts','.tsx':'ts','.js':'js','.jsx':'js','.mjs':'js','.py':'python','.go':'go','.tf':'terraform','.sql':'sql'}
26
+ res={}
27
+ for name,repo,ref in targets:
28
+ d=W/name
29
+ if d.exists(): shutil.rmtree(d)
30
+ d.mkdir(parents=True)
31
+ a=subprocess.Popen(['git','-C',repo,'archive',ref],stdout=subprocess.PIPE)
32
+ subprocess.run(['tar','-x','-C',str(d)],stdin=a.stdout,check=True); a.wait()
33
+ shutil.rmtree(d/'graphify-out',ignore_errors=True)
34
+ env=gc.clean_env(HOME,BIN)
35
+ audit=SP/('g10/audit-%s.tsv'%name)
36
+ r1=rgf.measure([G,'update','.'],d,env,wrapper=wrap,audit_file=audit)
37
+ g=gm.load_graph(d)
38
+ sig1=gm.signature(g)
39
+ # second cold
40
+ shutil.rmtree(d/'graphify-out')
41
+ r2=rgf.measure([G,'update','.'],d,env)
42
+ sig2=gm.signature(gm.load_graph(d))
43
+ # per-language coverage (own: includes sql), files with >=1 symbol
44
+ with_sym,_=gm.file_node_labels(g)
45
+ cnt=collections.defaultdict(lambda:[0,0])
46
+ for p in d.rglob('*'):
47
+ if p.is_file() and p.suffix in EXTS and 'node_modules' not in p.parts and 'graphify-out' not in p.parts and '.git' not in p.parts:
48
+ rel=str(p.relative_to(d)); lang=EXTS[p.suffix]
49
+ if gc.is_test_path(rel) and lang!='go': pass
50
+ cnt[lang][0]+=1
51
+ if rel in with_sym: cnt[lang][1]+=1
52
+ cov={k:{'files':v[0],'with_symbols':v[1],'pct':round(100*v[1]/v[0],1) if v[0] else None} for k,v in cnt.items()}
53
+ g2=gm.g2_edges(g)
54
+ rel=collections.Counter(e.get('relation') for e in gm.edges_of(g))
55
+ res[name]={'ref':ref,'cold':{k:r1[k] for k in ('exit_code','wall_s','peak_tree_rss_mb','max_child_rss_mb')},
56
+ 'network_events':r1.get('audit'),'deterministic':sig1==sig2,'signature':sig1,'coverage_by_lang':cov,
57
+ 'g1_harness':gm.g1_extraction(g,d),'inferred_pct':g2['inferred_pct'],'inferred_by_relation':g2['inferred_by_relation'],'relations_top':dict(rel.most_common(8))}
58
+ print(name, json.dumps({k:res[name][k] for k in ('cold','deterministic','coverage_by_lang','inferred_pct')}))
59
+ json.dump(res,open(SP/'g10/g10.json','w'),indent=1)
@@ -0,0 +1,49 @@
1
+ import os
2
+ import sys, json, pathlib, os, copy, collections
3
+ SP=pathlib.Path(os.environ['GV_T4_ROOT'])
4
+ sys.path.insert(0,str(SP/'harness/tools/graphify-validation/lib'))
5
+ import spring_fixture as sf, graph_metrics as gm
6
+ wd=SP/'out/run1/work/spring-fixture'
7
+ g=gm.load_graph(wd)
8
+ exp=json.load(open(SP/'harness/tools/graphify-validation/fixtures/spring/expected.json'))
9
+ base=sf.check(g,exp)
10
+ print('baseline',{k:(v['found'],v['expected']) for k,v in base['groups'].items()})
11
+ def augment(g):
12
+ g=copy.deepcopy(g); es=gm.edges_of(g)
13
+ byid={n['id']:n for n in g['nodes']}
14
+ # class of a method node = node in same file with non-method label (first)
15
+ file_cls={}
16
+ for n in g['nodes']:
17
+ sfile=n.get('source_file'); lab=n.get('label','')
18
+ if sfile and not lab.startswith('.') and lab!=os.path.basename(sfile): file_cls.setdefault(sfile,n['id'])
19
+ impls=collections.defaultdict(list) # interface class id -> impl class ids
20
+ for e in es:
21
+ if e.get('relation') in ('implements','inherits','extends'): impls[e['target']].append(e['source'])
22
+ methods=collections.defaultdict(dict) # class id -> {label: id}
23
+ for n in g['nodes']:
24
+ lab=n.get('label','')
25
+ if lab.startswith('.') and n.get('source_file') in file_cls: methods[file_cls[n['source_file']]][lab]=n['id']
26
+ new=[]
27
+ for e in es:
28
+ if e.get('relation')!='calls': continue
29
+ t=byid.get(e['target']);
30
+ if not t or t.get('source_file') not in file_cls: continue
31
+ iface=file_cls[t['source_file']]
32
+ for c in impls.get(iface,[]):
33
+ m=methods[c].get(t['label'])
34
+ if m: new.append({**e,'target':m,'relation':'calls','confidence':'INFERRED','note':'via-interface (wrapper)'})
35
+ # field-type edges (references) + implements: class A references interface I => A reaches impl classes (depth)
36
+ for e in es:
37
+ if e.get('relation')=='references' and e['target'] in impls:
38
+ for c in impls[e['target']]: new.append({**e,'target':c,'relation':'references','confidence':'INFERRED'})
39
+ g['links']=es+new if 'links' in g else g.get('edges',[])+new
40
+ if 'edges' in g and 'links' not in g: g['edges']=es+new
41
+ return g,len(new)
42
+ g2,n=augment(g)
43
+ aug=sf.check(g2,exp)
44
+ print('augmented (+%d synthetic edges)'%n,{k:(v['found'],v['expected']) for k,v in aug['groups'].items()})
45
+ print('interface_impl pct',base['groups']['interface_impl']['pct'],'->',aug['groups']['interface_impl']['pct'])
46
+ for k,v in aug['groups'].items():
47
+ for it in v['items']:
48
+ if not it['found']: print(' still failing',k,it['id'],it['detail'])
49
+ json.dump({'baseline':{k:v['pct'] for k,v in base['groups'].items()},'augmented':{k:v['pct'] for k,v in aug['groups'].items()},'synthetic_edges':n},open(SP/'g10/g3aug.json','w'))
@@ -0,0 +1,34 @@
1
+ import os
2
+ import sys, json, pathlib
3
+ SP=pathlib.Path(os.environ['GV_T4_ROOT'])
4
+ sys.path.insert(0,str(SP/'harness/tools/graphify-validation/lib'))
5
+ import recall as R, gv_common as gc
6
+ import os
7
+ os.environ['RG']=str(SP/'bin/rg')
8
+ Q=json.load(open(SP/'harness/tools/graphify-validation/config/questions.json'))
9
+ env=gc.clean_env(SP/'g10/home',str(SP/'out/run1/venv/bin'))
10
+ G=str(SP/'out/run1/venv/bin/graphify')
11
+ def rrf(*ranks,k=60):
12
+ s={}
13
+ for r in ranks:
14
+ for i,f in enumerate(R.clean(r)): s[f]=s.get(f,0)+1/(k+i+1)
15
+ return [f for f,_ in sorted(s.items(),key=lambda kv:-kv[1])]
16
+ def interleave(a,b):
17
+ out=[]
18
+ for x,y in zip(R.clean(a),R.clean(b)):
19
+ for z in (y,x):
20
+ if z not in out: out.append(z)
21
+ return out + [f for f in R.clean(a)+R.clean(b) if f not in out]
22
+ res={}
23
+ for repo,wd in [('vh-api-gateway',SP/'out/run1/work/vh-api-gateway'),('visionhammer-agents',SP/'out/run1/work/visionhammer-agents'),('deepdebug-local-agent',SP/'out/run1/work/deepdebug-local-agent')]:
24
+ rows=[]
25
+ for q in Q[repo]:
26
+ g,_=R.rank_graph(G,wd,q['question'],env)
27
+ l=R.rank_lexical(wd,R.auto_keywords(q['question']),whole_word=False,ignore_case=True)
28
+ rows.append({'kind':q['kind'],'graph':R.recall_at(g,q['truth'])[0],'lex':R.recall_at(l,q['truth'])[0],
29
+ 'rrf':R.recall_at(rrf(g,l),q['truth'])[0],'inter':R.recall_at(interleave(l,g),q['truth'])[0]})
30
+ def avg(sel,k): return round(100*sum(r[k] for r in sel)/len(sel),1) if sel else None
31
+ res[repo]={kind:{k:avg([r for r in rows if kind=='all' or r['kind']==kind],k) for k in ('graph','lex','rrf','inter')}|{'n':len([r for r in rows if kind=='all' or r['kind']==kind])} for kind in ('all','symbolic','conceptual')}
32
+ print(repo,json.dumps(res[repo]))
33
+ tot=[];
34
+ json.dump(res,open(SP/'g10/hybrid.json','w'),indent=1)