deepdebug-local-agent 1.0.23 → 1.2.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 (178) hide show
  1. package/.claude/context.md +76 -0
  2. package/.claude/skills/README.md +25 -0
  3. package/.claude/skills/tdd-debugging/SKILL.md +53 -0
  4. package/.claude/skills/tdd-debugging/references/anti-patterns.md +43 -0
  5. package/.claude/skills/tdd-debugging/references/command-reference.md +46 -0
  6. package/.claude/standards/README.md +51 -0
  7. package/.claude/standards/clean-code.md +75 -0
  8. package/.claude/standards/code-review-checklist.md +61 -0
  9. package/.claude/standards/layered-architecture.md +163 -0
  10. package/.claude/standards/node.md +45 -0
  11. package/.claude/standards/security-owasp.md +114 -0
  12. package/.claude/standards/simplicity.md +42 -0
  13. package/.claude/standards/solid.md +64 -0
  14. package/.claude/standards/testing.md +59 -0
  15. package/.cursor/rules/standards.mdc +17 -0
  16. package/.github/workflows/build-byoc-image.yml +315 -0
  17. package/.github/workflows/deploy-gcp.yml +102 -0
  18. package/.github/workflows/npm-publish.yml +76 -8
  19. package/AGENTS.md +21 -0
  20. package/CHANGELOG.md +76 -0
  21. package/CLAUDE.md +13 -0
  22. package/CLAUDE_CODE_AGENT_INFINITE.md +5 -3
  23. package/Dockerfile +4 -0
  24. package/Dockerfile.full +55 -3
  25. package/bin/install.js +16 -5
  26. package/cloudbuild-full.yaml +57 -0
  27. package/docker-compose.yml +5 -3
  28. package/docs/OBSERVABILITY.md +164 -0
  29. package/docs/byoc-agent-image.md +70 -0
  30. package/docs/dlp.md +78 -0
  31. package/docs/graphify-packaging-plan.md +133 -0
  32. package/docs/graphify-validation-run-2026-10-28.md +243 -0
  33. package/docs/releasing.md +37 -0
  34. package/docs/signed-commands.md +163 -0
  35. package/env.example +34 -0
  36. package/helm/Chart.yaml +2 -2
  37. package/helm/templates/deployment.yaml +13 -3
  38. package/helm/templates/networkpolicy.yaml +22 -0
  39. package/helm/templates/secret.yaml +1 -0
  40. package/helm/values.yaml +13 -14
  41. package/mcp-server.js +4 -1
  42. package/package.json +9 -4
  43. package/scripts/next-version.mjs +75 -0
  44. package/scripts/upgrade-observability-push.sh +227 -0
  45. package/src/ai-engine.js +22 -22
  46. package/src/config/trusted-signing-keys.json +3 -0
  47. package/src/detectors/build-tool-detector.js +50 -0
  48. package/src/detectors/multi-project-detector.js +22 -22
  49. package/src/detectors/service-detector.js +24 -24
  50. package/src/dlp/engine.js +111 -0
  51. package/src/dlp/index.js +137 -0
  52. package/src/dlp/rules.js +97 -0
  53. package/src/dlp/validators.js +123 -0
  54. package/src/exec-utils.js +26 -4
  55. package/src/git/base-git-provider.js +117 -117
  56. package/src/git/bitbucket-provider.js +2 -2
  57. package/src/git/git-provider-registry.js +18 -18
  58. package/src/git/github-provider.js +13 -13
  59. package/src/git/multi-repo-pr.js +161 -0
  60. package/src/mcp-http-server.js +32 -16
  61. package/src/observability/bundle-files.js +418 -0
  62. package/src/observability/detect.js +151 -0
  63. package/src/observability/provision.js +212 -0
  64. package/src/observability/render-cli.js +59 -0
  65. package/src/observability/routes.js +88 -0
  66. package/src/patch/patch-engine.js +28 -28
  67. package/src/patch-manager.js +85 -85
  68. package/src/patch_bkp.js +14 -14
  69. package/src/ports.js +4 -4
  70. package/src/routes/workspace.route.js +47 -16
  71. package/src/runtimes/base-runtime.js +75 -75
  72. package/src/runtimes/java/gradle-runtime.js +17 -17
  73. package/src/runtimes/java/java-integrations.js +25 -25
  74. package/src/runtimes/java/maven-runtime.js +34 -34
  75. package/src/runtimes/node/node-integrations.js +1 -1
  76. package/src/runtimes/node/npm-runtime.js +14 -14
  77. package/src/runtimes/node/yarn-runtime.js +4 -4
  78. package/src/runtimes/runtime-registry.js +25 -25
  79. package/src/server-local.js +4 -4
  80. package/src/server.js +1366 -333
  81. package/src/siblings/burst-suppressor.js +89 -0
  82. package/src/siblings/capabilities.js +273 -0
  83. package/src/siblings/env-compat.cjs +58 -0
  84. package/src/siblings/incident-forwarder.js +322 -0
  85. package/src/siblings/logger.js +15 -0
  86. package/src/siblings/metrics.js +94 -0
  87. package/src/utils/agent-auth.js +184 -0
  88. package/src/utils/approval-token.js +56 -0
  89. package/src/utils/bind-host.js +29 -0
  90. package/src/utils/branch-refspec.js +98 -0
  91. package/src/utils/child-env.js +34 -0
  92. package/src/utils/environment-diagnostics.js +55 -55
  93. package/src/utils/exec-policy.js +962 -0
  94. package/src/utils/exec-utils.js +15 -15
  95. package/src/utils/file-count.js +46 -0
  96. package/src/utils/fs-utils.js +10 -10
  97. package/src/utils/multi-repo-open.js +132 -0
  98. package/src/utils/open-gate.js +183 -0
  99. package/src/utils/ranged-read.js +65 -0
  100. package/src/utils/request-guards.js +77 -0
  101. package/src/utils/request-signing.js +342 -0
  102. package/src/utils/root-recovery.js +244 -0
  103. package/src/utils/safe-exec-args.js +58 -0
  104. package/src/utils/sandbox.js +182 -0
  105. package/src/utils/search-outcome.js +56 -0
  106. package/src/utils/workspace-root.js +80 -0
  107. package/src/validators/syntax-validator.js +149 -0
  108. package/src/workspace/detect-port.js +16 -16
  109. package/src/workspace/file-reader.js +1 -1
  110. package/src/workspace/process-manager.js +46 -46
  111. package/src/workspace-manager.js +86 -24
  112. package/test/adapters/registry.test.js +9 -2
  113. package/test/agent-auth.test.js +194 -0
  114. package/test/bind-host.test.js +26 -0
  115. package/test/branch-refspec.test.js +224 -0
  116. package/test/burst-suppressor.test.js +60 -0
  117. package/test/capabilities-access.test.js +47 -0
  118. package/test/capabilities.test.js +87 -0
  119. package/test/child-env.test.js +23 -0
  120. package/test/dlp/engine.test.js +173 -0
  121. package/test/dlp/fixtures.js +126 -0
  122. package/test/dlp/index.test.js +166 -0
  123. package/test/env-compat.test.js +71 -0
  124. package/test/exec-policy.test.js +463 -0
  125. package/test/file-count.test.js +64 -0
  126. package/test/incident-forwarder.test.js +231 -0
  127. package/test/multi-repo-open.test.js +180 -0
  128. package/test/multi-repo-pr.test.js +129 -0
  129. package/test/next-version.test.js +48 -0
  130. package/test/observability-detect.test.js +199 -0
  131. package/test/open-gate.test.js +341 -0
  132. package/test/ranged-read.test.js +60 -0
  133. package/test/request-guards.test.js +91 -0
  134. package/test/request-signing.test.js +284 -0
  135. package/test/root-recovery-sidecar.test.js +171 -0
  136. package/test/root-recovery.test.js +274 -0
  137. package/test/safe-exec-args.test.js +58 -0
  138. package/test/sandbox.test.js +98 -0
  139. package/test/search-outcome.test.js +50 -0
  140. package/test/workspace-root.test.js +64 -0
  141. package/tools/graphify-validation/README.md +92 -0
  142. package/tools/graphify-validation/config/questions.json +31 -0
  143. package/tools/graphify-validation/config/repos.json +9 -0
  144. package/tools/graphify-validation/config/symbols.json +6 -0
  145. package/tools/graphify-validation/fixtures/spring/expected.json +34 -0
  146. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/AppConfig.java +29 -0
  147. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/AuditLogger.java +15 -0
  148. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/Cache.java +7 -0
  149. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/CheckoutService.java +25 -0
  150. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/InMemoryCache.java +18 -0
  151. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/OrderController.java +22 -0
  152. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/PaymentGateway.java +5 -0
  153. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/PaypalGateway.java +11 -0
  154. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/RefundService.java +14 -0
  155. package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/StripeGateway.java +11 -0
  156. package/tools/graphify-validation/lib/affected_vs_rg.py +81 -0
  157. package/tools/graphify-validation/lib/audit_hook/sitecustomize.py +22 -0
  158. package/tools/graphify-validation/lib/graph_metrics.py +96 -0
  159. package/tools/graphify-validation/lib/gv_common.py +130 -0
  160. package/tools/graphify-validation/lib/licenses.py +47 -0
  161. package/tools/graphify-validation/lib/pip_audit_summary.py +18 -0
  162. package/tools/graphify-validation/lib/recall.py +115 -0
  163. package/tools/graphify-validation/lib/report.py +226 -0
  164. package/tools/graphify-validation/lib/run_all.py +237 -0
  165. package/tools/graphify-validation/lib/run_graphify.py +144 -0
  166. package/tools/graphify-validation/lib/rusage_helper.py +17 -0
  167. package/tools/graphify-validation/lib/sample_edges.py +246 -0
  168. package/tools/graphify-validation/lib/spring_fixture.py +81 -0
  169. package/tools/graphify-validation/requirements-py311.lock +508 -0
  170. package/tools/graphify-validation/requirements-tools.in +1 -0
  171. package/tools/graphify-validation/requirements-tools.lock +371 -0
  172. package/tools/graphify-validation/requirements.in +1 -0
  173. package/tools/graphify-validation/requirements.lock +502 -0
  174. package/tools/graphify-validation/run.sh +43 -0
  175. package/tools/graphify-validation/tests/test_harness.py +151 -0
  176. package/cloudbuild-agent-qa.yaml +0 -43
  177. package/cloudbuild-agent-staging.yaml +0 -40
  178. package/cloudbuild.yaml.deprecated +0 -42
@@ -0,0 +1,144 @@
1
+ """Run a command and measure wall time, peak memory and (optionally) block/audit the network.
2
+
3
+ Memory: two numbers are reported. `peak_tree_rss_mb` samples the summed RSS of
4
+ the process and all its descendants every 250 ms; it can double-count pages
5
+ shared between workers and can miss peaks between samples, so treat it as an
6
+ estimate. `max_child_rss_mb` is the kernel ru_maxrss of the largest single
7
+ descendant, measured by a fresh helper process per run (rusage_helper.py).
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import platform
13
+ import subprocess
14
+ import sys
15
+ import threading
16
+ import time
17
+ from pathlib import Path
18
+
19
+ HERE = Path(__file__).resolve().parent
20
+ AUDIT_DIR = HERE / "audit_hook"
21
+ NET_BLOCK_PROFILE = "(version 1)(allow default)(deny network*)"
22
+
23
+
24
+ def _tree_rss_kb(root_pid):
25
+ try:
26
+ out = subprocess.run(["ps", "-axo", "pid=,ppid=,rss="], capture_output=True, text=True, timeout=10).stdout
27
+ except Exception:
28
+ return 0
29
+ kids, rss = {}, {}
30
+ for line in out.splitlines():
31
+ parts = line.split()
32
+ if len(parts) != 3:
33
+ continue
34
+ pid, ppid, kb = int(parts[0]), int(parts[1]), int(parts[2])
35
+ kids.setdefault(ppid, []).append(pid)
36
+ rss[pid] = kb
37
+ total, stack, seen = 0, [root_pid], set()
38
+ while stack:
39
+ p = stack.pop()
40
+ if p in seen:
41
+ continue
42
+ seen.add(p)
43
+ total += rss.get(p, 0)
44
+ stack.extend(kids.get(p, []))
45
+ return total
46
+
47
+
48
+ def network_wrapper():
49
+ """Return (argv_prefix, mechanism) that denies all network, or (None, reason)."""
50
+ system = platform.system()
51
+ if system == "Darwin" and Path("/usr/bin/sandbox-exec").exists():
52
+ return ["/usr/bin/sandbox-exec", "-p", NET_BLOCK_PROFILE], "sandbox-exec (deny network*)"
53
+ if system == "Linux":
54
+ for prefix in (["unshare", "-rn"], ["unshare", "-n"]):
55
+ try:
56
+ if subprocess.run(prefix + ["true"], capture_output=True, timeout=10).returncode == 0:
57
+ return prefix, " ".join(prefix)
58
+ except Exception:
59
+ continue
60
+ return None, "unshare -n not permitted here (needs user namespaces or root)"
61
+ return None, "no network sandbox available on %s" % system
62
+
63
+
64
+ def verify_block(prefix, python):
65
+ """Prove the wrapper really blocks sockets: a TCP connect must fail inside it."""
66
+ code = ("import socket,sys\n"
67
+ "try:\n s=socket.create_connection(('1.1.1.1',53),timeout=3)\n print('CONNECTED')\n"
68
+ "except OSError as e:\n print('BLOCKED',type(e).__name__)\n")
69
+ res = subprocess.run(prefix + [python, "-c", code], capture_output=True, text=True, timeout=30)
70
+ return res.stdout.strip().startswith("BLOCKED"), (res.stdout + res.stderr).strip()[:200]
71
+
72
+
73
+ def measure(argv, cwd, env, wrapper=None, audit_file=None, timeout=1800):
74
+ env = dict(env)
75
+ if audit_file:
76
+ Path(audit_file).write_text("")
77
+ env["GV_AUDIT_FILE"] = str(audit_file)
78
+ env["PYTHONPATH"] = str(AUDIT_DIR)
79
+ helper = [sys.executable, "-I", str(HERE / "rusage_helper.py"), "--"] + list(argv)
80
+ full = (list(wrapper) if wrapper else []) + helper
81
+ t0 = time.perf_counter()
82
+ proc = subprocess.Popen(full, cwd=str(cwd), env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
83
+ peak = [0]
84
+ stop = threading.Event()
85
+
86
+ def sampler():
87
+ while not stop.is_set():
88
+ peak[0] = max(peak[0], _tree_rss_kb(proc.pid))
89
+ stop.wait(0.25)
90
+
91
+ th = threading.Thread(target=sampler, daemon=True)
92
+ th.start()
93
+ try:
94
+ out, _ = proc.communicate(timeout=timeout)
95
+ except subprocess.TimeoutExpired:
96
+ proc.kill()
97
+ out, _ = proc.communicate()
98
+ stop.set()
99
+ th.join(timeout=2)
100
+ wall = time.perf_counter() - t0
101
+ max_child = 0
102
+ kept = []
103
+ for ln in (out or "").splitlines():
104
+ if ln.startswith("GV_MAXRSS_BYTES "):
105
+ max_child = int(ln.split()[1])
106
+ else:
107
+ kept.append(ln)
108
+ out = "\n".join(kept)
109
+ res = {
110
+ "argv": list(argv),
111
+ "exit_code": proc.returncode,
112
+ "wall_s": round(wall, 2),
113
+ "peak_tree_rss_mb": round(peak[0] / 1024, 1),
114
+ "max_child_rss_mb": round(max_child / (1024 * 1024), 1),
115
+ "output_tail": (out or "").strip().splitlines()[-12:],
116
+ }
117
+ if audit_file:
118
+ res["audit"] = parse_audit(audit_file)
119
+ return res
120
+
121
+
122
+ def parse_audit(path):
123
+ events = []
124
+ for line in Path(path).read_text().splitlines():
125
+ parts = line.split("\t", 2)
126
+ if len(parts) == 3:
127
+ events.append({"pid": parts[0], "event": parts[1], "args": parts[2]})
128
+ sock = [e for e in events if e["event"].startswith("socket.")]
129
+ popen = [e for e in events if e["event"] == "subprocess.Popen"]
130
+ return {
131
+ "socket_events": len(sock),
132
+ "socket_event_names": sorted({e["event"] for e in sock}),
133
+ "socket_samples": sock[:5],
134
+ "subprocess_popen": len(popen),
135
+ "subprocess_samples": [e["args"] for e in popen[:5]],
136
+ "other_exec_events": len([e for e in events if e["event"].startswith(("os.system", "os.exec", "os.posix_spawn", "os.spawn"))]),
137
+ }
138
+
139
+
140
+ if __name__ == "__main__": # tiny manual entry point: run_graphify.py <cwd> -- cmd...
141
+ cwd = sys.argv[1]
142
+ cmd = sys.argv[sys.argv.index("--") + 1:]
143
+ import json
144
+ print(json.dumps(measure(cmd, cwd, dict(os.environ)), indent=2))
@@ -0,0 +1,17 @@
1
+ """Run a command in a fresh process and report the kernel max RSS of its largest descendant.
2
+
3
+ RUSAGE_CHILDREN is cumulative per process, so each measurement needs its own helper process,
4
+ otherwise the figure would carry the maximum of every earlier run.
5
+ Usage: rusage_helper.py -- cmd args... (prints the child's output, then `GV_MAXRSS_BYTES <n>` on stderr)
6
+ """
7
+ import platform
8
+ import resource
9
+ import subprocess
10
+ import sys
11
+
12
+ cmd = sys.argv[sys.argv.index("--") + 1:]
13
+ rc = subprocess.call(cmd)
14
+ ru = resource.getrusage(resource.RUSAGE_CHILDREN).ru_maxrss
15
+ factor = 1 if platform.system() == "Darwin" else 1024 # bytes on macOS, KiB on Linux
16
+ sys.stderr.write("GV_MAXRSS_BYTES %d\n" % (ru * factor))
17
+ sys.exit(rc)
@@ -0,0 +1,246 @@
1
+ """Stratified sample of INFERRED edges + an automatic oracle (G2 precision).
2
+
3
+ The oracle is a *proxy*, not a human review. For Java `calls` edges it checks
4
+ the call site against the receiver's declared type in the same file. Verdicts:
5
+ ok the receiver type (or this/super/static import) is compatible with the
6
+ class that owns the edge's target method
7
+ wrong the call site contradicts the target (different class, name absent)
8
+ unknown cannot be decided mechanically (chained call, var, lambda param ...)
9
+ `unknown` rows go to the CSV for manual review; a filled `manual_verdict`
10
+ column (ok|wrong) overrides the oracle when the sheet is passed back in.
11
+ Sampling is seeded, so the same graph yields the same sample.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import csv
16
+ import random
17
+ import re
18
+ from collections import Counter, defaultdict
19
+ from pathlib import Path
20
+
21
+ from graph_metrics import class_index, edges_of
22
+ from gv_common import is_test_path
23
+
24
+ _DECL_CACHE = {}
25
+
26
+
27
+ def _read_lines(workdir, rel):
28
+ key = (str(workdir), rel)
29
+ if key not in _DECL_CACHE:
30
+ try:
31
+ _DECL_CACHE[key] = (Path(workdir) / rel).read_text(encoding="utf-8", errors="replace").splitlines()
32
+ except OSError:
33
+ _DECL_CACHE[key] = []
34
+ return _DECL_CACHE[key]
35
+
36
+
37
+ def _declared_types(lines, ident):
38
+ pat = re.compile(r"\b([A-Z][A-Za-z0-9_]*(?:\.[A-Z][A-Za-z0-9_]*)*)(?:<[^;(){}=]*>)?(?:\[\])?\s+" + re.escape(ident) + r"\s*(?:[;=,)]|$)")
39
+ types = set()
40
+ for ln in lines:
41
+ for m in pat.finditer(ln):
42
+ types.add(m.group(1))
43
+ return types
44
+
45
+
46
+ def _closure(name, supers):
47
+ seen, stack = set(), [name]
48
+ while stack:
49
+ cur = stack.pop()
50
+ for s in supers.get(cur, ()):
51
+ if s not in seen:
52
+ seen.add(s)
53
+ stack.append(s)
54
+ return seen
55
+
56
+
57
+ def _compatible(receiver_type, owner, supers):
58
+ if receiver_type == owner:
59
+ return True
60
+ if owner in _closure(receiver_type, supers):
61
+ return True # receiver is a subtype of the owner
62
+ if receiver_type in _closure(owner, supers):
63
+ return True # receiver is an interface/base of the owner (resolved to an implementation)
64
+ return False
65
+
66
+
67
+ def _typed(declared, owner, owner_stem, src_text, src_stem, supers):
68
+ """Tri-state compatibility of a declared (possibly dotted) type with the owner class."""
69
+ segs = declared.split(".")
70
+ last = segs[-1]
71
+ if len(segs) > 1:
72
+ if last == owner:
73
+ return "ok" if segs[0] == owner_stem else "wrong"
74
+ return "ok" if _compatible(last, owner, supers) else "wrong"
75
+ if last == owner:
76
+ if owner_stem == owner or owner_stem == src_stem:
77
+ return "ok"
78
+ # nested class referred to by its simple name: only trust it when the source names the outer class
79
+ return "ok" if re.search(r"\b%s\.%s\b" % (re.escape(owner_stem), re.escape(owner)), src_text) else "unknown"
80
+ return "ok" if _compatible(last, owner, supers) else "wrong"
81
+
82
+
83
+ def java_oracle(edge, ns, owner_of, supers, workdir):
84
+ src_file = edge.get("source_file", "")
85
+ loc = edge.get("source_location", "L0")
86
+ try:
87
+ line_no = int(loc.lstrip("L"))
88
+ except ValueError:
89
+ return "unknown", "no line"
90
+ lines = _read_lines(workdir, src_file)
91
+ if not lines or line_no < 1 or line_no > len(lines):
92
+ return "unknown", "line out of range"
93
+ tgt = ns[edge["target"]]
94
+ name = tgt["label"].lstrip(".").rstrip("()")
95
+ window = " ".join(lines[line_no - 1:line_no + 2])
96
+ owner_node = ns.get(owner_of.get(edge["target"], ""), {})
97
+ owner = owner_node.get("label", "")
98
+ src_owner = ns.get(owner_of.get(edge["source"], ""), {}).get("label", "")
99
+ pat = re.compile(r"(?:([A-Za-z_][A-Za-z0-9_]*|\))\s*\.\s*)?\b" + re.escape(name) + r"\s*\(")
100
+ m = pat.search(window)
101
+ if not m:
102
+ return "unknown", "callee name not at call site"
103
+ recv = m.group(1)
104
+ if recv == ")":
105
+ return "unknown", "chained call"
106
+ if recv is None or recv in ("this", "super"):
107
+ allowed = {src_owner} | _closure(src_owner, supers)
108
+ if owner in allowed or _compatible(src_owner, owner, supers):
109
+ return "ok", "unqualified/this call inside owner hierarchy"
110
+ text = "\n".join(lines)
111
+ if re.search(r"import\s+static\s+[\w.]*\b%s\b\.(?:\*|%s)\s*;" % (re.escape(owner), re.escape(name)), text):
112
+ return "ok", "static import"
113
+ return "unknown", "unqualified call to a class outside the hierarchy (outer class or static import?)"
114
+ if recv[0].isupper():
115
+ return ("ok", "static call on owner") if _compatible(recv, owner, supers) else ("wrong", "static receiver %s != owner %s" % (recv, owner))
116
+ types = _declared_types(lines, recv)
117
+ if not types:
118
+ return "unknown", "receiver type not declared in file (var, lambda, inherited field)"
119
+ owner_stem = Path(owner_node.get("source_file", "")).stem
120
+ src_stem = Path(src_file).stem
121
+ src_text = "\n".join(lines)
122
+ verdicts = [_typed(t, owner, owner_stem, src_text, src_stem, supers) for t in sorted(types)]
123
+ if "ok" in verdicts:
124
+ return "ok", "receiver type %s compatible with owner %s" % (sorted(types)[0], owner)
125
+ if "unknown" in verdicts:
126
+ return "unknown", "nested owner %s reached through simple type name %s" % (owner, sorted(types)[0])
127
+ return "wrong", "receiver types %s incompatible with owner %s (%s)" % (sorted(types), owner, owner_stem)
128
+
129
+
130
+ def weak_oracle(edge, ns, workdir):
131
+ """JS/TS/Python: only checks that the callee name sits on the call line and the file is reachable."""
132
+ src_file = edge.get("source_file", "")
133
+ loc = edge.get("source_location", "L0")
134
+ try:
135
+ line_no = int(loc.lstrip("L"))
136
+ except ValueError:
137
+ return "unknown", "no line"
138
+ lines = _read_lines(workdir, src_file)
139
+ if not lines or line_no < 1 or line_no > len(lines):
140
+ return "unknown", "line out of range"
141
+ tgt = ns[edge["target"]]
142
+ name = tgt["label"].lstrip(".").rstrip("()")
143
+ window = " ".join(lines[max(0, line_no - 1):line_no + 1])
144
+ if not re.search(r"\b" + re.escape(name) + r"\b", window):
145
+ return "wrong", "callee name not on call line"
146
+ tgt_file = tgt.get("source_file", "")
147
+ if tgt_file == src_file:
148
+ return "ok", "same file"
149
+ base = Path(tgt_file).stem
150
+ text = "\n".join(lines)
151
+ if re.search(r"(from|require\(|import\()\s*['\"][^'\"]*%s[^'\"]*['\"]" % re.escape(base), text):
152
+ return "ok", "callee name on line and target module imported"
153
+ return "unknown", "callee name on line but target module not imported by name"
154
+
155
+
156
+ def build_sample(g, workdir, seed=20261028, n_ambiguous=20, n_other=40, ambiguous_min_defs=5):
157
+ ns, owner_of, supers = class_index(g)
158
+ defs = Counter(n["label"] for n in g["nodes"] if n["label"].startswith(".") and n["id"] in owner_of)
159
+ pool = defaultdict(list)
160
+ for e in edges_of(g):
161
+ if e.get("confidence") != "INFERRED" or e.get("relation") != "calls":
162
+ continue
163
+ if is_test_path(e.get("source_file", "")):
164
+ continue
165
+ tgt = ns.get(e["target"])
166
+ if not tgt:
167
+ continue
168
+ stratum = "ambiguous" if defs.get(tgt["label"], 0) >= ambiguous_min_defs else "other"
169
+ pool[stratum].append(e)
170
+ rng = random.Random(seed)
171
+ chosen = []
172
+ for stratum, k in (("ambiguous", n_ambiguous), ("other", n_other)):
173
+ items = sorted(pool[stratum], key=lambda e: (e["source"], e["target"], e.get("source_location", "")))
174
+ rng.shuffle(items)
175
+ for e in items[:k]:
176
+ chosen.append((stratum, e))
177
+ rows = []
178
+ for stratum, e in chosen:
179
+ src_file = e.get("source_file", "")
180
+ is_java = src_file.endswith(".java")
181
+ verdict, reason = java_oracle(e, ns, owner_of, supers, workdir) if is_java else weak_oracle(e, ns, workdir)
182
+ line_no = int(e.get("source_location", "L0").lstrip("L") or 0)
183
+ lines = _read_lines(workdir, src_file)
184
+ rows.append({
185
+ "stratum": stratum,
186
+ "source": ns[e["source"]]["label"],
187
+ "target": ns[e["target"]]["label"],
188
+ "target_owner": ns.get(owner_of.get(e["target"], ""), {}).get("label", ""),
189
+ "site": "%s:%s" % (src_file, e.get("source_location")),
190
+ "line_text": (lines[line_no - 1].strip()[:160] if 0 < line_no <= len(lines) else ""),
191
+ "oracle": verdict,
192
+ "reason": reason,
193
+ "oracle_kind": "java-receiver-type" if is_java else "weak-name-and-import",
194
+ "manual_verdict": "",
195
+ })
196
+ return rows, {"pool_ambiguous": len(pool["ambiguous"]), "pool_other": len(pool["other"]), "seed": seed}
197
+
198
+
199
+ def write_csv(rows, path):
200
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
201
+ cols = ["stratum", "source", "target", "target_owner", "site", "line_text", "oracle", "oracle_kind", "reason", "manual_verdict"]
202
+ with open(path, "w", newline="", encoding="utf-8") as fh:
203
+ w = csv.DictWriter(fh, fieldnames=cols)
204
+ w.writeheader()
205
+ w.writerows(rows)
206
+
207
+
208
+ def merge_manual(rows, manual_csv):
209
+ if not manual_csv or not Path(manual_csv).exists():
210
+ return rows
211
+ with open(manual_csv, newline="", encoding="utf-8") as fh:
212
+ manual = {(r["site"], r["source"], r["target"]): r.get("manual_verdict", "").strip().lower() for r in csv.DictReader(fh)}
213
+ for r in rows:
214
+ v = manual.get((r["site"], r["source"], r["target"]))
215
+ if v in ("ok", "wrong"):
216
+ r["manual_verdict"] = v
217
+ return rows
218
+
219
+
220
+ def summarize(rows, meta):
221
+ out = dict(meta)
222
+ out["sample_size"] = len(rows)
223
+ for scope, key in (("oracle", "oracle"),):
224
+ c = Counter(r[key] for r in rows)
225
+ out["oracle_counts"] = dict(c)
226
+ decided = c.get("ok", 0) + c.get("wrong", 0)
227
+ out["oracle_decided"] = decided
228
+ out["oracle_precision_pct"] = round(100.0 * c.get("ok", 0) / decided, 1) if decided else None
229
+ out["oracle_worst_case_pct"] = round(100.0 * c.get("ok", 0) / len(rows), 1) if rows else None
230
+ by = {}
231
+ for st in ("ambiguous", "other"):
232
+ sub = [r for r in rows if r["stratum"] == st]
233
+ c = Counter(r["oracle"] for r in sub)
234
+ dec = c.get("ok", 0) + c.get("wrong", 0)
235
+ by[st] = {"n": len(sub), "ok": c.get("ok", 0), "wrong": c.get("wrong", 0), "unknown": c.get("unknown", 0),
236
+ "precision_pct": round(100.0 * c.get("ok", 0) / dec, 1) if dec else None}
237
+ out["by_stratum"] = by
238
+ manual = [r for r in rows if r["manual_verdict"] in ("ok", "wrong")]
239
+ out["manual_reviewed"] = len(manual)
240
+ if manual:
241
+ # manual verdict wins; oracle verdict fills the rest when it is decided
242
+ final = [r["manual_verdict"] or (r["oracle"] if r["oracle"] in ("ok", "wrong") else "") for r in rows]
243
+ decided = [v for v in final if v]
244
+ out["combined_decided"] = len(decided)
245
+ out["combined_precision_pct"] = round(100.0 * decided.count("ok") / len(decided), 1) if decided else None
246
+ return out
@@ -0,0 +1,81 @@
1
+ """Spring/DI fixture check (G3): build the graph of the synthetic fixture and compare with expected.json."""
2
+ from __future__ import annotations
3
+
4
+ from collections import defaultdict, deque
5
+ from pathlib import Path
6
+
7
+ from graph_metrics import edges_of
8
+ from gv_common import load_json
9
+
10
+
11
+ def _index(g):
12
+ by_sel = defaultdict(list)
13
+ for n in g["nodes"]:
14
+ sf = n.get("source_file") or ""
15
+ by_sel["%s::%s" % (Path(sf).name, n["label"])].append(n["id"])
16
+ return by_sel
17
+
18
+
19
+ def _resolve(by_sel, sel):
20
+ return by_sel.get(sel, [])
21
+
22
+
23
+ def check(g, expected):
24
+ by_sel = _index(g)
25
+ es = edges_of(g)
26
+ fwd = defaultdict(list)
27
+ rev = defaultdict(list)
28
+ for e in es:
29
+ fwd[e["source"]].append(e)
30
+ rev[e["target"]].append(e)
31
+
32
+ def reaches(frm_ids, to_ids, depth):
33
+ # reverse traversal from `to` (who is affected) must contain `frm`
34
+ seen, q = set(to_ids), deque((t, 0) for t in to_ids)
35
+ while q:
36
+ cur, d = q.popleft()
37
+ if cur in frm_ids:
38
+ return True
39
+ if d >= depth:
40
+ continue
41
+ for e in rev[cur]:
42
+ if e["source"] not in seen:
43
+ seen.add(e["source"])
44
+ q.append((e["source"], d + 1))
45
+ return False
46
+
47
+ results = {}
48
+ for group, items in expected["groups"].items():
49
+ out = []
50
+ for it in items:
51
+ frm = set(_resolve(by_sel, it["from"]))
52
+ to = set(_resolve(by_sel, it["to"]))
53
+ if not frm or not to:
54
+ found, why = False, "node not found: %s" % (it["from"] if not frm else it["to"])
55
+ elif it["kind"] == "edge":
56
+ rels = set(it["relations"])
57
+ hit = [e for f in frm for e in fwd[f] if e["target"] in to and e.get("relation") in rels]
58
+ found, why = bool(hit), (hit[0]["relation"] + "/" + hit[0]["confidence"]) if hit else "no edge with relation in %s" % sorted(rels)
59
+ elif it["kind"] == "reaches":
60
+ found = reaches(frm, to, it["depth"])
61
+ why = "reachable" if found else "not reachable within %d hops" % it["depth"]
62
+ elif it["kind"] == "not_reaches":
63
+ companion = next((o for o in out if o["id"] == it.get("requires")), None)
64
+ if companion is not None and not companion["found"]:
65
+ found, why = False, "vacuous: companion check %s failed, so absence proves nothing" % it["requires"]
66
+ else:
67
+ found = not reaches(frm, to, it["depth"])
68
+ why = "correctly not reachable" if found else "reachable (Qualifier ignored)"
69
+ else:
70
+ found, why = False, "unknown kind"
71
+ out.append({"id": it["id"], "kind": it["kind"], "found": found, "detail": why})
72
+ n = len(out)
73
+ ok = sum(1 for o in out if o["found"])
74
+ results[group] = {"found": ok, "expected": n, "pct": round(100.0 * ok / n, 1) if n else None, "items": out}
75
+ head = results[expected["headline_group"]]
76
+ return {"groups": results, "headline_group": expected["headline_group"], "headline_pct": head["pct"],
77
+ "threshold_pct": expected["threshold_pct"], "pass": (head["pct"] or 0) >= expected["threshold_pct"]}
78
+
79
+
80
+ def load_expected(path):
81
+ return load_json(path)