deepdebug-local-agent 1.0.22 → 1.1.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.
- package/.claude/context.md +76 -0
- package/.claude/skills/README.md +25 -0
- package/.claude/skills/tdd-debugging/SKILL.md +53 -0
- package/.claude/skills/tdd-debugging/references/anti-patterns.md +43 -0
- package/.claude/skills/tdd-debugging/references/command-reference.md +46 -0
- package/.claude/standards/README.md +51 -0
- package/.claude/standards/clean-code.md +75 -0
- package/.claude/standards/code-review-checklist.md +61 -0
- package/.claude/standards/layered-architecture.md +163 -0
- package/.claude/standards/node.md +45 -0
- package/.claude/standards/security-owasp.md +114 -0
- package/.claude/standards/simplicity.md +42 -0
- package/.claude/standards/solid.md +64 -0
- package/.claude/standards/testing.md +59 -0
- package/.cursor/rules/standards.mdc +17 -0
- package/.github/workflows/build-byoc-image.yml +315 -0
- package/.github/workflows/deploy-gcp.yml +102 -0
- package/.github/workflows/npm-publish.yml +69 -8
- package/AGENTS.md +21 -0
- package/CHANGELOG.md +69 -0
- package/CLAUDE.md +13 -0
- package/CLAUDE_CODE_AGENT_INFINITE.md +5 -3
- package/Dockerfile +4 -0
- package/Dockerfile.full +55 -3
- package/README.md +81 -0
- package/bin/install.js +16 -5
- package/cloudbuild-full.yaml +57 -0
- package/docker-compose.yml +5 -3
- package/docs/OBSERVABILITY.md +164 -0
- package/docs/byoc-agent-image.md +70 -0
- package/docs/graphify-packaging-plan.md +133 -0
- package/docs/graphify-validation-run-2026-10-28.md +243 -0
- package/docs/legacy-connectors.md +268 -0
- package/docs/releasing.md +43 -0
- package/docs/signed-commands.md +163 -0
- package/env.example +30 -0
- package/helm/Chart.yaml +2 -2
- package/helm/templates/deployment.yaml +13 -3
- package/helm/templates/networkpolicy.yaml +22 -0
- package/helm/templates/secret.yaml +1 -0
- package/helm/values.yaml +13 -14
- package/mcp-server.js +4 -1
- package/package.json +12 -4
- package/scripts/sync-fixtures.mjs +246 -0
- package/scripts/upgrade-observability-push.sh +227 -0
- package/src/adapters/as400/as400-parser.js +12 -0
- package/src/adapters/as400/index.js +134 -0
- package/src/adapters/cobol/cobol-parser.js +37 -0
- package/src/adapters/cobol/index.js +151 -0
- package/src/adapters/ibmmq/ibmmq-parser.js +23 -0
- package/src/adapters/ibmmq/index.js +137 -0
- package/src/adapters/index.js +79 -0
- package/src/adapters/oracle-forms/index.js +145 -0
- package/src/adapters/oracle-forms/oracle-forms-parser.js +11 -0
- package/src/adapters/sap/index.js +146 -0
- package/src/adapters/sap/sap-parser.js +41 -0
- package/src/ai-engine.js +22 -22
- package/src/config/trusted-signing-keys.json +3 -0
- package/src/detectors/build-tool-detector.js +50 -0
- package/src/detectors/multi-project-detector.js +22 -22
- package/src/detectors/service-detector.js +24 -24
- package/src/exec-utils.js +26 -4
- package/src/git/base-git-provider.js +117 -117
- package/src/git/bitbucket-provider.js +2 -2
- package/src/git/git-provider-registry.js +18 -18
- package/src/git/github-provider.js +13 -13
- package/src/git/multi-repo-pr.js +161 -0
- package/src/mcp-http-server.js +32 -16
- package/src/observability/bundle-files.js +418 -0
- package/src/observability/detect.js +151 -0
- package/src/observability/provision.js +212 -0
- package/src/observability/render-cli.js +59 -0
- package/src/observability/routes.js +88 -0
- package/src/patch/patch-engine.js +28 -28
- package/src/patch-manager.js +85 -85
- package/src/patch_bkp.js +14 -14
- package/src/ports.js +4 -4
- package/src/routes/workspace.route.js +47 -16
- package/src/runtimes/base-runtime.js +75 -75
- package/src/runtimes/java/gradle-runtime.js +17 -17
- package/src/runtimes/java/java-integrations.js +25 -25
- package/src/runtimes/java/maven-runtime.js +34 -34
- package/src/runtimes/node/node-integrations.js +1 -1
- package/src/runtimes/node/npm-runtime.js +14 -14
- package/src/runtimes/node/yarn-runtime.js +4 -4
- package/src/runtimes/runtime-registry.js +25 -25
- package/src/server-local.js +4 -4
- package/src/server.js +1412 -335
- package/src/siblings/burst-suppressor.js +89 -0
- package/src/siblings/capabilities.js +273 -0
- package/src/siblings/env-compat.cjs +58 -0
- package/src/siblings/incident-forwarder.js +320 -0
- package/src/siblings/logger.js +15 -0
- package/src/siblings/metrics.js +94 -0
- package/src/utils/agent-auth.js +184 -0
- package/src/utils/approval-token.js +56 -0
- package/src/utils/bind-host.js +29 -0
- package/src/utils/branch-refspec.js +98 -0
- package/src/utils/child-env.js +34 -0
- package/src/utils/environment-diagnostics.js +55 -55
- package/src/utils/exec-policy.js +962 -0
- package/src/utils/exec-utils.js +15 -15
- package/src/utils/file-count.js +46 -0
- package/src/utils/fs-utils.js +10 -10
- package/src/utils/multi-repo-open.js +132 -0
- package/src/utils/open-gate.js +183 -0
- package/src/utils/ranged-read.js +65 -0
- package/src/utils/request-guards.js +77 -0
- package/src/utils/request-signing.js +342 -0
- package/src/utils/root-recovery.js +244 -0
- package/src/utils/safe-exec-args.js +58 -0
- package/src/utils/sandbox.js +182 -0
- package/src/utils/search-outcome.js +56 -0
- package/src/utils/workspace-root.js +80 -0
- package/src/validators/syntax-validator.js +149 -0
- package/src/workspace/detect-port.js +16 -16
- package/src/workspace/file-reader.js +1 -1
- package/src/workspace/process-manager.js +46 -46
- package/src/workspace-manager.js +86 -24
- package/test/adapters/as400/as400.test.js +121 -0
- package/test/adapters/cobol/cobol.test.js +217 -0
- package/test/adapters/ibmmq/ibmmq.test.js +122 -0
- package/test/adapters/oracle-forms/oracle-forms.test.js +180 -0
- package/test/adapters/registry.test.js +108 -0
- package/test/adapters/sap/sap.test.js +142 -0
- package/test/agent-auth.test.js +194 -0
- package/test/bind-host.test.js +26 -0
- package/test/branch-refspec.test.js +224 -0
- package/test/burst-suppressor.test.js +60 -0
- package/test/capabilities-access.test.js +47 -0
- package/test/capabilities.test.js +87 -0
- package/test/child-env.test.js +23 -0
- package/test/env-compat.test.js +71 -0
- package/test/exec-policy.test.js +463 -0
- package/test/file-count.test.js +64 -0
- package/test/incident-forwarder.test.js +231 -0
- package/test/multi-repo-open.test.js +180 -0
- package/test/multi-repo-pr.test.js +129 -0
- package/test/observability-detect.test.js +199 -0
- package/test/open-gate.test.js +341 -0
- package/test/ranged-read.test.js +60 -0
- package/test/request-guards.test.js +91 -0
- package/test/request-signing.test.js +284 -0
- package/test/root-recovery-sidecar.test.js +171 -0
- package/test/root-recovery.test.js +274 -0
- package/test/safe-exec-args.test.js +58 -0
- package/test/sandbox.test.js +98 -0
- package/test/search-outcome.test.js +50 -0
- package/test/workspace-root.test.js +64 -0
- package/tools/graphify-validation/README.md +92 -0
- package/tools/graphify-validation/config/questions.json +31 -0
- package/tools/graphify-validation/config/repos.json +9 -0
- package/tools/graphify-validation/config/symbols.json +6 -0
- package/tools/graphify-validation/fixtures/spring/expected.json +34 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/AppConfig.java +29 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/AuditLogger.java +15 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/Cache.java +7 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/CheckoutService.java +25 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/InMemoryCache.java +18 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/OrderController.java +22 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/PaymentGateway.java +5 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/PaypalGateway.java +11 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/RefundService.java +14 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/StripeGateway.java +11 -0
- package/tools/graphify-validation/lib/affected_vs_rg.py +81 -0
- package/tools/graphify-validation/lib/audit_hook/sitecustomize.py +22 -0
- package/tools/graphify-validation/lib/graph_metrics.py +96 -0
- package/tools/graphify-validation/lib/gv_common.py +130 -0
- package/tools/graphify-validation/lib/licenses.py +47 -0
- package/tools/graphify-validation/lib/pip_audit_summary.py +18 -0
- package/tools/graphify-validation/lib/recall.py +115 -0
- package/tools/graphify-validation/lib/report.py +226 -0
- package/tools/graphify-validation/lib/run_all.py +237 -0
- package/tools/graphify-validation/lib/run_graphify.py +144 -0
- package/tools/graphify-validation/lib/rusage_helper.py +17 -0
- package/tools/graphify-validation/lib/sample_edges.py +246 -0
- package/tools/graphify-validation/lib/spring_fixture.py +81 -0
- package/tools/graphify-validation/requirements-py311.lock +508 -0
- package/tools/graphify-validation/requirements-tools.in +1 -0
- package/tools/graphify-validation/requirements-tools.lock +371 -0
- package/tools/graphify-validation/requirements.in +1 -0
- package/tools/graphify-validation/requirements.lock +502 -0
- package/tools/graphify-validation/run.sh +43 -0
- package/tools/graphify-validation/tests/test_harness.py +151 -0
- package/cloudbuild-agent-qa.yaml +0 -43
- package/cloudbuild-agent-staging.yaml +0 -40
- package/cloudbuild.yaml.deprecated +0 -42
- package/src/local-agent-source.tar.gz +0 -0
|
@@ -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)
|