mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-10-21 05:59:35 +08:00
A new pr.yml job, test-impact-shadow, predicts from the code graph which C test suites a change can affect and uploads the prediction, so graph-based selection can be judged against what actually failed before anything gates on it. It is continue-on-error and not in ci-ok's needs: it can never block or pass a merge. scripts/ci/test-impact-shadow.sh --fetch-release downloads the latest published release archive for the host plus checksums.txt with the job's read-only token and verifies the SHA-256 (seconds instead of a build, and a PR that breaks the indexer cannot corrupt its own prediction); pr.yml builds the checkout only if that fails. --binary/--out then indexes the checkout into a throwaway HOME/cache with a private CBM_RUNTIME_DIR (short, under /tmp, so the daemon socket fits sun_path), runs detect_changes against the merge commit's base at depth 15 and hands it to predict.py. The graph is only consulted when C sources changed. scripts/test-impact/predict.py ports the replay's mapping: impacted test functions -> registered suites (perf excluded; nested RUN_SUITE children resolve to the registered suite that runs them), changed test files -> whole suite, fixtures -> suites that name them, grammars -> grammar suites, and run-all on the selector's full, a test header, test infrastructure, or a saturated/truncated impact set. It writes prediction.json and a short step-summary table. tests/test_test_impact_predict.sh (Step 0z5) pins the mapping on a synthetic tree; the wiring contract requires the job to stay continue-on-error; the script joins the venue-parity interface probes. Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
233 lines
11 KiB
Python
233 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""predict.py — map a detect_changes impact set to the C test suites it can
|
|
affect: the PR test-impact SHADOW (scripts/ci/test-impact-shadow.sh).
|
|
Report-only; nothing here decides a merge.
|
|
|
|
The mapping is the one the September 2026 test-impact replay measured
|
|
(detect_changes at depth 15 plus rule fallbacks, "dcF@15"):
|
|
- impacted test FUNCTIONS (graph rows in tests/*.c) map to the suites that
|
|
RUN_TEST them, resolved in their own file first; a nested suite (RUN_SUITE
|
|
inside another) resolves to the registered suite that runs it; only suites
|
|
registered in tests/test_main.c count, perf suites excluded;
|
|
- a changed tests/*.c file always selects its whole suite;
|
|
- a changed fixture (tests/fixtures/<key>/...) selects the suites whose test
|
|
files name <key>; a changed grammar selects the grammar suites;
|
|
- run-all: the lane selector's full, a changed test header or
|
|
test-infrastructure file, a saturated or truncated impact set, or a C
|
|
change the graph was not asked about -> every suite.
|
|
|
|
Usage: predict.py --root DIR --changed FILE --selection FILE [--detect FILE]
|
|
[--depth N] [--meta JSON] --out FILE [--summary FILE]
|
|
predict.py --needs-graph --changed FILE (prints yes|no)
|
|
"""
|
|
import argparse
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
|
|
GRAMMAR_SUITES = re.compile(r"^(grammar_|lang_contract|parse_coverage|extraction|matrix_|"
|
|
r"edge_types_probe|convergence_probe|ac$|language$)")
|
|
C_SOURCE = re.compile(r"\.(c|h|cc|cpp|inc)$")
|
|
SUITE_DEF = re.compile(r"^\s*(?:SUITE\((\w+)\)|void\s+suite_(\w+)\s*\(\s*void\s*\))\s*\{", re.M)
|
|
RUN_TEST = re.compile(r"\bRUN_TEST\((\w+)\)")
|
|
RUN_SUITE = re.compile(r"\bRUN_SUITE\((\w+)\)")
|
|
|
|
|
|
def needs_graph(changed):
|
|
"""A C source change the graph can place (prod or test code)."""
|
|
return any(C_SOURCE.search(p) and (p.startswith(("src/", "tests/")) or (
|
|
p.startswith("internal/cbm/") and not p.startswith("internal/cbm/vendored/")))
|
|
for p in changed)
|
|
|
|
|
|
def suitemap(root):
|
|
"""file -> suites defined there, test -> suites running it, suite universe,
|
|
and the resolver from any suite to the registered suites that run it."""
|
|
file_suites, test_suites, sources, parents = {}, {}, {}, {}
|
|
for directory, dirs, names in os.walk(os.path.join(root, "tests")):
|
|
dirs[:] = sorted(d for d in dirs if not (directory == os.path.join(root, "tests")
|
|
and d == "fixtures"))
|
|
for name in sorted(names):
|
|
if not name.endswith(".c"):
|
|
continue
|
|
path = os.path.join(directory, name)
|
|
rel = os.path.relpath(path, root).replace(os.sep, "/")
|
|
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
src = fh.read()
|
|
sources[rel] = src
|
|
for m in SUITE_DEF.finditer(src):
|
|
suite = m.group(1) or m.group(2)
|
|
start = depth = m.end() - 1
|
|
end = start
|
|
depth = 0
|
|
while end < len(src): # the body is the balanced { } block
|
|
if src[end] == "{":
|
|
depth += 1
|
|
elif src[end] == "}":
|
|
depth -= 1
|
|
if depth == 0:
|
|
break
|
|
end += 1
|
|
file_suites.setdefault(rel, []).append(suite)
|
|
for test in RUN_TEST.findall(src[start:end]):
|
|
test_suites.setdefault(test, [])
|
|
if suite not in test_suites[test]:
|
|
test_suites[test].append(suite)
|
|
for child in RUN_SUITE.findall(src[start:end]):
|
|
parents.setdefault(child, set()).add(suite)
|
|
main = sources.get("tests/test_main.c", "")
|
|
registered = set(re.findall(r"RUN_SELECTED_SUITE(?:_PERF)?\((\w+)\)", main))
|
|
universe = registered - set(re.findall(r"RUN_SELECTED_SUITE_PERF\((\w+)\)", main))
|
|
|
|
def runnable(suite, seen=frozenset()):
|
|
"""A registered suite runs itself; a nested one runs inside its parents."""
|
|
if suite in registered:
|
|
return {suite} & universe
|
|
out = set()
|
|
for parent in parents.get(suite, ()):
|
|
if parent not in seen:
|
|
out |= runnable(parent, seen | {suite})
|
|
return out
|
|
return file_suites, test_suites, universe, sources, runnable
|
|
|
|
|
|
def predict(root, changed, selection, detect, depth):
|
|
file_suites, test_suites, universe, sources, runnable = suitemap(root)
|
|
reasons, run_all = [], False
|
|
if selection.get("full"):
|
|
run_all = True
|
|
reasons.append(f"run-all: the lane selector ran everything ({selection.get('tier')})")
|
|
for p in changed:
|
|
if p.startswith("tests/") and not p.startswith("tests/fixtures/") and p.endswith(".h"):
|
|
run_all = True
|
|
reasons.append(f"run-all: test header {p}")
|
|
elif p.startswith("test-infrastructure/"):
|
|
run_all = True
|
|
reasons.append(f"run-all: test infrastructure {p}")
|
|
|
|
graph = {"consulted": detect is not None}
|
|
picked = {} # suite -> "ALL" | set(tests)
|
|
if detect is not None:
|
|
rows = detect.get("impacted", [])
|
|
total = detect.get("impacted_total", len(rows))
|
|
shown = detect.get("impacted_shown", len(rows))
|
|
graph.update(impacted_total=total, impacted_shown=shown,
|
|
saturated=bool(detect.get("engine_saturated")))
|
|
if graph["saturated"]:
|
|
run_all = True
|
|
reasons.append("run-all: detect_changes saturated")
|
|
if total > len(rows) or shown > len(rows):
|
|
run_all = True
|
|
reasons.append(f"run-all: impact set truncated ({len(rows)} of {total} rows)")
|
|
tests = 0
|
|
for r in rows:
|
|
fp, name = r.get("file", ""), r.get("qn", "").rsplit(".", 1)[-1]
|
|
if not fp.startswith("tests/") or r.get("label") != "Function" or r.get("hop", 0) > depth:
|
|
continue
|
|
local = file_suites.get(fp, [])
|
|
if name in local:
|
|
continue # the SUITE function itself
|
|
candidates = test_suites.get(name, [])
|
|
for suite in [s for s in candidates if s in local] or candidates:
|
|
for runner in runnable(suite):
|
|
picked.setdefault(runner, set()).add(name)
|
|
tests += 1
|
|
graph["test_rows"] = tests
|
|
elif needs_graph(changed):
|
|
run_all = True
|
|
reasons.append("run-all: C change but no impact set (graph not consulted)")
|
|
|
|
for p in changed:
|
|
if p.startswith("tests/fixtures/"): # fixture sources are data, not tests
|
|
key = p.split("/")[2] if p.count("/") >= 2 else p
|
|
for f, src in sources.items():
|
|
if key in src:
|
|
for suite in file_suites.get(f, []):
|
|
for runner in runnable(suite):
|
|
picked[runner] = "ALL"
|
|
reasons.append(f"changed fixture {p}: suites whose tests name '{key}'")
|
|
elif p.startswith("tests/") and p.endswith(".c"):
|
|
for suite in file_suites.get(p, []):
|
|
for runner in runnable(suite):
|
|
picked[runner] = "ALL"
|
|
reasons.append(f"changed test file {p}: suite {runner}")
|
|
elif p.startswith("internal/cbm/vendored/grammars/"):
|
|
for suite in universe:
|
|
if GRAMMAR_SUITES.match(suite):
|
|
picked[suite] = "ALL"
|
|
reasons.append(f"changed grammar {p}: grammar suites")
|
|
|
|
if run_all:
|
|
mode, suites = "all", {s: "ALL" for s in sorted(universe)}
|
|
else:
|
|
suites = {s: v if v == "ALL" else sorted(v) for s, v in sorted(picked.items())}
|
|
mode = "graph" if (graph["consulted"] or suites) else "none"
|
|
return {"version": 1, "mode": mode, "depth": depth,
|
|
"selector": {"tier": selection.get("tier"), "full": bool(selection.get("full"))},
|
|
"universe": len(universe), "selected_suites": len(suites), "suites": suites,
|
|
"graph": graph, "reasons": sorted(set(reasons))}
|
|
|
|
|
|
def summary(result, meta):
|
|
g = result["graph"]
|
|
whole = sum(1 for v in result["suites"].values() if v == "ALL")
|
|
tests = sum(len(v) for v in result["suites"].values() if v != "ALL")
|
|
lines = ["### Test-impact shadow (report-only, never gates)", "",
|
|
"| | |", "|---|---|",
|
|
f"| selector | {result['selector']['tier']} (full: {'yes' if result['selector']['full'] else 'no'}) |",
|
|
f"| prediction | **{result['mode']}**: {result['selected_suites']} of {result['universe']} "
|
|
f"suites ({whole} whole, {tests} tests in the rest) |"]
|
|
if g.get("consulted"):
|
|
lines.append(f"| graph | {g.get('impacted_shown')} of {g.get('impacted_total')} impacted rows at "
|
|
f"depth {result['depth']}, {g.get('test_rows')} test rows"
|
|
f"{', saturated' if g.get('saturated') else ''} |")
|
|
for key in sorted(meta):
|
|
lines.append(f"| {key} | {meta[key]} |")
|
|
if result["reasons"]:
|
|
lines += ["", "Reasons: " + "; ".join(result["reasons"][:12])]
|
|
if result["mode"] == "graph" and result["suites"]:
|
|
lines += ["", "Suites: " + ", ".join(result["suites"])[:1500]]
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
|
|
ap.add_argument("--needs-graph", action="store_true")
|
|
ap.add_argument("--root")
|
|
ap.add_argument("--changed", required=True)
|
|
ap.add_argument("--selection")
|
|
ap.add_argument("--detect")
|
|
ap.add_argument("--depth", type=int, default=15)
|
|
ap.add_argument("--meta", default="{}")
|
|
ap.add_argument("--out")
|
|
ap.add_argument("--summary")
|
|
a = ap.parse_args()
|
|
with open(a.changed, encoding="utf-8") as fh:
|
|
changed = sorted({line.strip() for line in fh if line.strip()})
|
|
if a.needs_graph:
|
|
print("yes" if needs_graph(changed) else "no")
|
|
return 0
|
|
if not (a.root and a.selection and a.out):
|
|
ap.error("--root, --selection and --out are required")
|
|
with open(a.selection, encoding="utf-8") as fh:
|
|
selection = json.load(fh)
|
|
detect = None
|
|
if a.detect:
|
|
with open(a.detect, encoding="utf-8") as fh:
|
|
detect = json.load(fh)
|
|
result = predict(a.root, changed, selection, detect, a.depth)
|
|
meta = json.loads(a.meta)
|
|
result["meta"] = meta
|
|
with open(a.out, "w", encoding="utf-8") as fh:
|
|
json.dump(result, fh, indent=1, sort_keys=True)
|
|
fh.write("\n")
|
|
if a.summary:
|
|
with open(a.summary, "w", encoding="utf-8") as fh:
|
|
fh.write(summary(result, meta))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|