From 05076300001ec71bc78de330cebd04000476636d Mon Sep 17 00:00:00 2001 From: chamsechan Date: Sun, 4 Oct 2026 23:30:58 +0800 Subject: [PATCH] fix(tooling): update tooling contracts, doc link parsing, and snapshot benchmarks - scripts/check_doc_links.py: support single-quote/parenthesized titles, angle-bracket targets with spaces, and balanced/escaped parens - src/cli/alg_pipeline_tool.cpp: surface validation diagnostics and tooling hints in edit command - scripts/git_branch_upload.sh: verify remote PR head matches verified commit and use --match-head-commit - dev_support/benchmarks/control_snapshots.*: align with current slots DTO, remove legacy baseline comparison - tests & docs: update quality gate contracts and tooling tests --- .github/workflows/ci.yml | 2 +- CONTRIBUTING.md | 4 +- dev_support/README.md | 11 +++ dev_support/benchmarks/control_snapshots.cpp | 2 +- dev_support/benchmarks/control_snapshots.py | 78 +++++++-------- doc/CHANGELOG.md | 6 ++ scripts/check_doc_links.py | 88 ++++++++++++++++- scripts/git_branch_upload.sh | 20 +++- src/cli/alg_pipeline_tool.cpp | 21 +++- .../test_quality_gate_contract.py | 97 +++++++++++++++++-- tests/tooling/test_pipeline_studio.py | 43 ++++++++ 11 files changed, 303 insertions(+), 69 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 91e6f98f..ee8be336 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -143,7 +143,7 @@ jobs: - name: Run CI Runtime Sanitizer Suite run: | ccache -z - LLM_EDGEFLOW_LINKER=mold LLM_EDGEFLOW_JOBS=4 LLM_EDGEFLOW_SANITIZERS=address,undefined \ + LLM_EDGEFLOW_LINKER=mold LLM_EDGEFLOW_SANITIZERS=address,undefined \ ./scripts/run_sanitizers.sh --ci-runtime ccache -s diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f460f408..1c1e97cc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,7 +33,9 @@ working branch or merge other working branches into it. If main advances, explic the branch onto the latest main or recreate it there with only the current PR's changes, then revalidate. Published history rewrites require explicit authorization and coordination with other users of the branch. The delivery script checks this history before the local gate and -again before an authorized merge; it never rebases automatically. +again before an authorized merge; it never rebases automatically. Before merging, the remote +PR head must match the locally verified commit; the script validates that exact SHA and binds +the merge to it so a concurrent branch update stops delivery. The branch itself is not evidence of quality; it provides isolation and a reviewable diff. diff --git a/dev_support/README.md b/dev_support/README.md index 65b668cb..d3674369 100644 --- a/dev_support/README.md +++ b/dev_support/README.md @@ -17,3 +17,14 @@ runner compiles these examples directly. No starter file is linked into a produc `benchmarks/` and `node_authoring/benchmark/` contain opt-in development measurements. They are separate from the default runtime and correctness suites. + +After running `./scripts/run_all_tests.sh`, measure the current template and rule snapshot +Nodes, with and without concurrent Control updates, using an empty output directory: + +```bash +python3 dev_support/benchmarks/control_snapshots.py --output-dir /tmp/edgeflow-control-snapshots --rounds 1 +``` + +The script saves request timing, Control allocation counts, command logs and median summaries +in that directory. It compiles only current sources; historical source comparisons and the +`--baseline` option have been removed. diff --git a/dev_support/benchmarks/control_snapshots.cpp b/dev_support/benchmarks/control_snapshots.cpp index 97d568af..98fe4f10 100644 --- a/dev_support/benchmarks/control_snapshots.cpp +++ b/dev_support/benchmarks/control_snapshots.cpp @@ -113,7 +113,7 @@ int main(int argc, char** argv) { } else { auto* out = ctx.Read("matches"); if (!out || out->size() != 50 || (*out)[0].data.category != "GREETING" || - (*out)[0].data.captures.at("tail") != "world") + (*out)[0].data.slots.at("tail") != "world") return 6; } } diff --git a/dev_support/benchmarks/control_snapshots.py b/dev_support/benchmarks/control_snapshots.py index ba116eaa..edc159a7 100644 --- a/dev_support/benchmarks/control_snapshots.py +++ b/dev_support/benchmarks/control_snapshots.py @@ -1,11 +1,11 @@ #!/usr/bin/env python3 -"""Compare configuration snapshot node implementations on an otherwise idle machine. +"""Measure current configuration snapshot nodes on an otherwise idle machine. Run the canonical gate/build first. This script reuses its Ninja node-runner -runtime objects and libraries without building the repository. Only the two node -translation units are replaced for the baseline; this is not a full historical -checkout benchmark. Both versions use C++17, -O3 and -DNDEBUG. Each invocation -processes 2,000 requests of 50 samples; writer updates are spaced by 100 us. +runtime objects and libraries without building the repository. The benchmark +and the two current node translation units use C++17, -O3 and -DNDEBUG; historical +sources are not compiled. Each invocation processes 2,000 requests of 50 samples; +writer updates are spaced by 100 us. Ordinary C++ allocation counts are measured separately from request timing. """ @@ -60,7 +60,6 @@ def main(): parser.add_argument("--build-dir", type=Path, default=ROOT / "build") parser.add_argument("--output-dir", type=Path, required=True, help="empty temporary directory for binaries and all evidence") - parser.add_argument("--baseline", default="7a6ca02") parser.add_argument("--rounds", type=positive_integer, default=7) args = parser.parse_args() build = args.build_dir.resolve() @@ -91,7 +90,6 @@ def run(command, cwd=ROOT): environment = "".join(run(command) for command in ( ["uname", "-a"], ["c++", "--version"], ["lscpu"], ["git", "rev-parse", "HEAD"], ["git", "status", "--short"], - ["git", "rev-parse", args.baseline], )) environment += f"\narguments: {vars(args)}\n" (output / "environment.txt").write_text(environment) @@ -105,54 +103,46 @@ def run(command, cwd=ROOT): )) benchmark_object = output / "bench.o" run(flags + ["-c", Path(__file__).with_suffix(".cpp"), "-o", benchmark_object]) - for version in ("baseline", "current"): - objects = [] - for node in NODES: - relative_source = f"src/common_nodes/{node}.cpp" - source = output / f"{version}_{node}.cpp" - source.write_text( - run(["git", "show", f"{args.baseline}:{relative_source}"]) - if version == "baseline" else (ROOT / relative_source).read_text() - ) - obj = output / f"{version}_{node}.o" - run(flags + ["-c", source, "-o", obj]) - objects.append(str(obj)) - command = [token for token in link if not any( - token.endswith(f"/{node}.cpp.o") for node in NODES - )] - command[command.index("-o") + 1] = str(output / f"bench_{version}") - # Put replacement objects ahead of static libraries for normal linkers. - command[command.index("-o"):command.index("-o")] = objects + [str(benchmark_object)] - run(command, cwd=build) + objects = [] + for node in NODES: + source = ROOT / f"src/common_nodes/{node}.cpp" + obj = output / f"{node}.o" + run(flags + ["-c", source, "-o", obj]) + objects.append(str(obj)) + command = [token for token in link if not any( + token.endswith(f"/{node}.cpp.o") for node in NODES + )] + executable = output / "bench_current" + command[command.index("-o") + 1] = str(executable) + # Put replacement objects ahead of static libraries for normal linkers. + command[command.index("-o"):command.index("-o")] = objects + [str(benchmark_object)] + run(command, cwd=build) records = [] for round_index in range(args.rounds): for node in ("template", "rules"): for concurrent in (0, 1): - versions = ("baseline", "current") if round_index % 2 == 0 else ("current", "baseline") - for version in versions: - stdout = run([output / f"bench_{version}", node, concurrent]) - (output / f"{round_index}_{node}_{concurrent}_{version}.log").write_text(stdout) - result = next(line.split() for line in stdout.splitlines() if line.startswith("RESULT ")) - allocation = next(line.split() for line in stdout.splitlines() if line.startswith("ALLOC ")) - records.append(dict( - round=round_index, node=node, concurrent=concurrent, version=version, - us=float(result[3]), updates=int(result[4]), - allocations=int(allocation[1]), bytes=int(allocation[2]), - )) - (output / "results.json").write_text(json.dumps(records, indent=2) + "\n") + stdout = run([executable, node, concurrent]) + (output / f"{round_index}_{node}_{concurrent}.log").write_text(stdout) + result = next(line.split() for line in stdout.splitlines() if line.startswith("RESULT ")) + allocation = next(line.split() for line in stdout.splitlines() if line.startswith("ALLOC ")) + records.append(dict( + round=round_index, node=node, concurrent=concurrent, + us=float(result[3]), updates=int(result[4]), + allocations=int(allocation[1]), bytes=int(allocation[2]), + )) + (output / "results.json").write_text(json.dumps(records, indent=2) + "\n") print(f"Completed round {round_index + 1}/{args.rounds}", flush=True) summary = [] for node in ("template", "rules"): for concurrent in (0, 1): - medians = {version: statistics.median( - record["us"] for record in records + selected = [record for record in records if record["node"] == node and record["concurrent"] == concurrent - and record["version"] == version - ) for version in ("baseline", "current")} - change = 100 * (medians["current"] / medians["baseline"] - 1) - summary.append(f"{node} concurrent={concurrent}: {medians}, change_pct={change}\n") + ] + medians = {key: statistics.median(record[key] for record in selected) + for key in ("us", "allocations", "bytes")} + summary.append(f"{node} concurrent={concurrent}: medians={medians}\n") (output / "summary.txt").write_text("".join(summary)) print("".join(summary), end="") diff --git a/doc/CHANGELOG.md b/doc/CHANGELOG.md index 7cb8d6e4..708275fa 100644 --- a/doc/CHANGELOG.md +++ b/doc/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +开发工具修复:Markdown 链接检查支持单引号与圆括号标题、带空格的尖括号目标及平衡或转义的 +目标圆括号;生产版 `alg_pipeline_tool edit` 根据 `validation.diagnostics` 提示构建变体与测试工具。 +交付脚本要求远端 PR head 与已验证提交一致,以该 SHA 复核历史,并通过 `--match-head-commit` +阻止检查后的并发更新进入合并。Control 快照基准改用当前 `slots` DTO,仅测量当前源码,删除历史 +源码对比和 `--baseline` 参数。 + 架构审查回归修复:Map 的回调通过移动交给运行时,支持捕获 `unique_ptr` 等不可复制状态; 流契约错误由 Validator 提供生产者、消费者、有效端口契约与推导出的数量形状,CLI 据此解释 节点输入、业务出口及 IO 边界错误,保留拆分来源并正确区分逐项配对与出口数量要求。 diff --git a/scripts/check_doc_links.py b/scripts/check_doc_links.py index 63409b5e..c0f5b68e 100755 --- a/scripts/check_doc_links.py +++ b/scripts/check_doc_links.py @@ -4,6 +4,7 @@ import argparse from pathlib import Path import re +import string import subprocess import tempfile import unicodedata @@ -11,7 +12,9 @@ ATX_HEADING = re.compile(r"^(#{1,6})\s+(.*?)\s*#*\s*$") -INLINE_LINK = re.compile(r"\]\(\s*]+)>?(?:\s+\"[^\"]*\")?\s*\)") +INLINE_LINK_START = re.compile(r"\]\(") +INLINE_LINK_END = re.compile( + r'''(?:[ \t]+(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\((?:\\.|[^()\\])*\)))?[ \t]*\)''') HTML_TARGET = re.compile(r"\b(?:href|src)=\"([^\"]+)\"") HTML_ANCHOR = re.compile(r"": + return "".join(destination), position + 1 + if char in "<\r\n": + return None + elif char <= " " or char == "\x7f": + break + elif char == "(": + depth += 1 + elif char == ")": + if depth == 0: + break + depth -= 1 + destination.append(char) + position += 1 + if angle_delimited or depth: + return None + return "".join(destination), position + + +def inline_link_targets(text): + """Yield single-line inline destinations, excluding optional link titles.""" + position = 0 + while match := INLINE_LINK_START.search(text, position): + position = match.end() + start = position + while start < len(text) and text[start] in " \t": + start += 1 + parsed = link_destination(text, start) + if parsed is None: + continue + destination, end = parsed + suffix = INLINE_LINK_END.match(text, end) + if suffix: + position = suffix.end() + yield destination + + def slugify(heading): """GitHub heading anchor: lowercase, drop punctuation/symbols, spaces to '-'.""" text = re.sub(r"`([^`]*)`", r"\1", heading) @@ -90,7 +143,7 @@ def check(root): relative = path.relative_to(root) for number, line in prose_lines(path): prose = INLINE_CODE.sub("", line) - for target in INLINE_LINK.findall(prose) + HTML_TARGET.findall(prose): + for target in [*inline_link_targets(prose), *HTML_TARGET.findall(prose)]: if URL_SCHEME.match(target) or target.startswith("//"): continue counters["links"] += 1 @@ -144,12 +197,39 @@ def write(root, name, text): # Seven inline links and one img src; the https link is skipped. assert counters == {"files": 2, "links": 8, "anchor_links": 4}, counters - write(root, "broken.md", "[x](guide/missing.md) [y](guide/target.md#nope) [z](../outside.md)\n") + write(root, "guide/guide_(old).md", "# Heading\n") + write(root, "guide/guide_(old_(nested_(v1))).md", "# Heading\n") + write(root, "guide/file space.md", "# Heading\n") + valid_links = [ + '[x](guide/guide_(old).md#heading)', + '[x](guide/guide_(old_(nested_(v1))).md#heading)', + r'[x](guide/guide_\(old\).md#heading)', + "[x]( 'caption')", + "[x](guide/target.md 'caption')", + '[x](guide/target.md (caption))', + r'''[x](guide/target.md "say \"hello\"")''', + '[x](guide/target.md "title [skip](missing.md)")', + ] + write(root, "syntax.md", "\n".join(valid_links) + "\n" + "[literal](guide_(unbalanced.md)\n" + "[literal]('missing separator')\n" + "[literal](guide/target.md (nested(title)))\n") + errors, counters = check(root) + assert errors == [], errors + assert counters["links"] == 8 + len(valid_links), counters + + write(root, "broken.md", + "[x](guide/missing.md) [y](guide/target.md#nope) [z](../outside.md)\n" + "[single](missing.md 'caption') [paren](missing.md (caption))\n" + "[balanced](missing_(old).md) [angle]( 'caption')\n") errors, _ = check(root) - assert len(errors) == 3, errors + assert len(errors) == 7, errors assert "missing link target" in errors[0], errors assert "missing heading anchor" in errors[1], errors assert "leaves the repository" in errors[2], errors + for error, target in zip(errors[3:], + ("missing.md", "missing.md", "missing_(old).md", "missing file.md")): + assert error.endswith("missing link target: " + target), errors with tempfile.TemporaryDirectory(prefix="doc-links-empty-") as directory: errors, _ = check(Path(directory)) diff --git a/scripts/git_branch_upload.sh b/scripts/git_branch_upload.sh index 6b3fd6c5..5c513736 100755 --- a/scripts/git_branch_upload.sh +++ b/scripts/git_branch_upload.sh @@ -65,15 +65,16 @@ case "${BRANCH_NAME}" in esac verify_branch_history() { + local checked_head="${1:-HEAD}" git fetch origin main - if ! git merge-base --is-ancestor origin/main HEAD; then + if ! git merge-base --is-ancestor origin/main "${checked_head}"; then echo "Error: origin/main is not an ancestor of ${BRANCH_NAME}." echo "Explicitly rebase onto origin/main or recreate the branch there with only this PR's changes." echo "Do not merge main into the working branch; this script will not rewrite history." exit 1 fi local branch_merges - branch_merges="$(git rev-list --merges origin/main..HEAD)" + branch_merges="$(git rev-list --merges "origin/main..${checked_head}")" if [[ -n "${branch_merges}" ]]; then echo "Error: ${BRANCH_NAME} contains merge commits above origin/main." echo "Recreate or explicitly rebase the branch with only this PR's linear commits, then rerun." @@ -107,6 +108,7 @@ if git diff --quiet origin/main...HEAD; then fi echo "[4/5] Pushing branch and creating or reusing its PR..." +VERIFIED_HEAD="$(git rev-parse HEAD)" git push -u origin "${BRANCH_NAME}" if ! gh pr view "${BRANCH_NAME}" --json number >/dev/null 2>&1; then gh pr create \ @@ -140,9 +142,19 @@ if [[ "${DELIVERY_MODE}" == "--pr-only" ]]; then fi PR_NUMBER="$(gh pr view "${BRANCH_NAME}" --json number --jq '.number')" +if ! PR_HEAD_SHA="$(gh pr view "${PR_NUMBER}" --json headRefOid --jq '.headRefOid')" || \ + [[ ! "${PR_HEAD_SHA}" =~ ^[0-9a-fA-F]{40}$ ]]; then + echo "Error: cannot confirm the head SHA of PR #${PR_NUMBER}; merge was not attempted." + exit 1 +fi +if [[ "${PR_HEAD_SHA}" != "${VERIFIED_HEAD}" ]]; then + echo "Error: PR #${PR_NUMBER} head changed since the verified branch was pushed." + echo "Fetch the updated branch and rerun verification before merging." + exit 1 +fi echo "Rechecking branch history against the latest origin/main before merge..." -verify_branch_history -gh pr merge "${PR_NUMBER}" --merge --delete-branch +verify_branch_history "${PR_HEAD_SHA}" +gh pr merge "${PR_NUMBER}" --merge --delete-branch --match-head-commit "${PR_HEAD_SHA}" if ! MERGE_SHA="$(gh pr view "${PR_NUMBER}" --json mergeCommit --jq '.mergeCommit.oid // empty')" || \ [[ -z "${MERGE_SHA}" ]]; then echo "Error: cannot confirm the merge SHA of PR #${PR_NUMBER}; main CI was not verified." diff --git a/src/cli/alg_pipeline_tool.cpp b/src/cli/alg_pipeline_tool.cpp index b420b2ea..2816bbc0 100644 --- a/src/cli/alg_pipeline_tool.cpp +++ b/src/cli/alg_pipeline_tool.cpp @@ -67,12 +67,21 @@ void PrintRegistrationHint(const nlohmann::json& response, auto unknown_registration = [](const std::string& code) { return code == "UNKNOWN_MODEL_TYPE" || code == "UNKNOWN_BACKEND"; }; - bool needs_hint = unknown_registration(source_code); - auto diagnostics = response.find("diagnostics"); - if (diagnostics != response.end() && diagnostics->is_array()) { + auto has_unknown_registration = [&](const nlohmann::json& report) { + auto diagnostics = report.find("diagnostics"); + if (diagnostics == report.end() || !diagnostics->is_array()) return false; for (const auto& diagnostic : *diagnostics) { - if (unknown_registration(diagnostic.value("code", ""))) needs_hint = true; + if (diagnostic.is_object() && + unknown_registration(diagnostic.value("code", ""))) + return true; } + return false; + }; + bool needs_hint = + unknown_registration(source_code) || has_unknown_registration(response); + auto validation = response.find("validation"); + if (validation != response.end() && validation->is_object()) { + needs_hint = needs_hint || has_unknown_registration(*validation); } if (needs_hint) { std::cerr << "提示:当前 alg_pipeline_tool 只包含本次构建启用的生产注册。\n" @@ -717,7 +726,9 @@ int main(int argc, char* argv[]) { } try { auto result = llm_edgeflow::PipelineAuthoring::ApplyRequest(request); - std::cout << result.ToJson().dump(2) << std::endl; + auto response = result.ToJson(); + std::cout << response.dump(2) << std::endl; + PrintRegistrationHint(response); return result.ok ? 0 : 1; } catch (const std::exception& error) { std::cout << ToolError("AUTHORING_ERROR", error.what()).dump(2) diff --git a/tests/contract/architecture/test_quality_gate_contract.py b/tests/contract/architecture/test_quality_gate_contract.py index 7bfcbe93..f2c57363 100644 --- a/tests/contract/architecture/test_quality_gate_contract.py +++ b/tests/contract/architecture/test_quality_gate_contract.py @@ -32,8 +32,9 @@ def check_delivery_contract(root): mode = os.environ["EDGEFLOW_DELIVERY_MODE"] if name == "git": if args == ["branch", "--show-current"]: print("fix/delivery-test") + if args == ["rev-parse", "HEAD"]: print("b" * 40) if args == ["diff", "--quiet", "origin/main...HEAD"]: sys.exit(1) - if args == ["merge-base", "--is-ancestor", "origin/main", "HEAD"] and mode == "main-advanced": + if args[:3] == ["merge-base", "--is-ancestor", "origin/main"] and mode == "main-advanced": calls = sum(json.loads(line)[:3] == ["git", "fetch", "origin"] for line in log.read_text().splitlines()) if calls > 1: sys.exit(1) elif name == "gh": @@ -41,9 +42,20 @@ def check_delivery_contract(root): fields = args[args.index("--json") + 1] if fields == "number": print("94") elif fields == "statusCheckRollup": print("6") + elif fields == "headRefOid": + assert args[2] == "94", "query the stable PR number" + if mode == "head-query-failure": sys.exit(1) + print("" if mode == "missing-head" else "invalid" if mode == "invalid-head" + else ("c" if mode == "head-changed" else "b") * 40) elif fields == "mergeCommit": assert args[2] == "94", "query the stable PR number after branch deletion" print("" if mode == "missing-sha" else "a" * 40) + elif args[:2] == ["pr", "merge"]: + assert "--match-head-commit" in args, args + assert args[args.index("--match-head-commit") + 1] == "b" * 40, args + if mode == "head-race": + print("PR head changed before merge", file=sys.stderr) + sys.exit(1) elif args[:2] == ["run", "list"]: for flag, value in [("--workflow", "ci.yml"), ("--branch", "main"), ("--event", "push"), ("--commit", "a" * 40)]: @@ -62,7 +74,9 @@ def check_delivery_contract(root): path.write_text(mock) path.chmod(0o755) log = root / "delivery" / "commands.jsonl" - for mode in ("pr-only", "success", "failure", "cancelled", "missing-run", "missing-sha", "unconfirmed", "main-advanced"): + before_merge_failures = ("main-advanced", "head-changed", "missing-head", "invalid-head", "head-query-failure") + for mode in ("pr-only", "success", "failure", "cancelled", "missing-run", "missing-sha", + "unconfirmed", *before_merge_failures, "head-race"): log.write_text("") env = {**os.environ, "PATH": str(binary) + os.pathsep + os.environ["PATH"], "EDGEFLOW_DELIVERY_COMMANDS": str(log), "EDGEFLOW_DELIVERY_MODE": mode} @@ -74,11 +88,24 @@ def check_delivery_contract(root): assert sum(command[0] == "run_all_tests.sh" for command in commands) == 1 run_lists = [command for command in commands if command[:3] == ["gh", "run", "list"]] watches = [command for command in commands if command[:3] == ["gh", "run", "watch"]] - if mode in ("pr-only", "main-advanced"): + merges = [command for command in commands if command[:3] == ["gh", "pr", "merge"]] + for merge in merges: + assert merge == ["gh", "pr", "merge", "94", "--merge", "--delete-branch", + "--match-head-commit", "b" * 40], merge + assert ["git", "merge-base", "--is-ancestor", "origin/main", "b" * 40] in commands + assert ["git", "rev-list", "--merges", "origin/main.." + "b" * 40] in commands + if mode == "pr-only" or mode in before_merge_failures: assert not run_lists and not watches - assert not any(command[:3] == ["gh", "pr", "merge"] for command in commands) + assert not merges if mode == "main-advanced": assert "origin/main is not an ancestor" in result.stdout + elif mode == "head-changed": + assert "head changed since the verified branch was pushed" in result.stdout + elif mode in ("missing-head", "invalid-head", "head-query-failure"): + assert "cannot confirm the head SHA" in result.stdout + elif mode == "head-race": + assert len(merges) == 1 and not run_lists and not watches + assert "PR head changed before merge" in result.stderr elif mode == "missing-sha": assert not run_lists and not watches assert "cannot confirm the merge SHA" in result.stdout @@ -118,15 +145,35 @@ def commit_file(name, content): git("commit", "-qm", name) mock = '''#!/usr/bin/env python3 -import json, os, sys +import json, os, subprocess, sys from pathlib import Path name, args = Path(sys.argv[0]).name, sys.argv[1:] with open(os.environ["EDGEFLOW_DELIVERY_COMMANDS"], "a") as stream: stream.write(json.dumps([name, *args]) + "\\n") -if name == "gh" and args[:2] == ["pr", "view"]: - fields = args[args.index("--json") + 1] - if fields == "number": print("94") - elif fields == "statusCheckRollup": print("1") +mode = os.environ.get("EDGEFLOW_HISTORY_HEAD_MODE") +def remote_head(): + return subprocess.check_output(["git", "ls-remote", "origin", + "refs/heads/" + os.environ["EDGEFLOW_HISTORY_BRANCH"]], + text=True).split()[0] +def advance_head(): + subprocess.run(["git", "push", "origin", os.environ["EDGEFLOW_HISTORY_UPDATED_HEAD"] + + ":refs/heads/" + os.environ["EDGEFLOW_HISTORY_BRANCH"]], + check=True, capture_output=True, text=True) +if name == "gh": + if args[:2] == ["pr", "view"]: + fields = args[args.index("--json") + 1] + if fields == "number": print("94") + elif fields == "statusCheckRollup": print("1") + elif fields == "headRefOid": print(remote_head()) + elif args[:2] == ["pr", "checks"] and mode in ("linear-update", "merge-update"): + advance_head() + elif args[:2] == ["pr", "merge"]: + if mode == "merge-race": advance_head() + expected = args[args.index("--match-head-commit") + 1] if "--match-head-commit" in args else None + if expected is not None and expected != remote_head(): + print("PR head does not match checked commit", file=sys.stderr) + sys.exit(1) + print("MOCK_MERGE_ACCEPTED") ''' scripts = project / "scripts" scripts.mkdir() @@ -195,6 +242,38 @@ def deliver(accepted, error=None): git("rebase", "origin/main") deliver(True) + for mode in ("linear-update", "merge-update", "merge-race"): + git("switch", "-c", "fix/" + mode, "origin/main") + commit_file(mode + ".txt", "verified change\n") + checked_head = git("rev-parse", "HEAD") + side = git("commit-tree", "HEAD^{tree}", "-p", checked_head, "-m", "concurrent update") + updated_head = side if mode == "linear-update" else git( + "commit-tree", "HEAD^{tree}", "-p", checked_head, "-p", side, "-m", "forbidden merge") + if mode != "linear-update": + assert git("rev-list", "--merges", "origin/main.." + updated_head) + log.write_text("") + original_main = git("ls-remote", "origin", "refs/heads/main") + branch = git("branch", "--show-current") + race_env = {**env, "EDGEFLOW_HISTORY_HEAD_MODE": mode, + "EDGEFLOW_HISTORY_BRANCH": branch, "EDGEFLOW_HISTORY_UPDATED_HEAD": updated_head} + result = run([str(scripts / "git_branch_upload.sh"), "fix(ci): PR head contract", "fix", "--merge"], + cwd=project, env=race_env, timeout=15) + assert result.returncode != 0, (mode, result.stdout, result.stderr) + commands = [json.loads(line) for line in log.read_text().splitlines()] + assert sum(command[0] == "run_all_tests.sh" for command in commands) == 1 + merges = [command for command in commands if command[:3] == ["gh", "pr", "merge"]] + assert len(merges) == int(mode == "merge-race"), (mode, commands) + if mode == "merge-race": + assert merges[0][-2:] == ["--match-head-commit", checked_head], merges + assert "PR head does not match checked commit" in result.stderr + else: + assert "head changed since the verified branch was pushed" in result.stdout + assert "MOCK_MERGE_ACCEPTED" not in result.stdout + assert not any(command[:3] == ["gh", "run", "list"] for command in commands) + assert git("rev-parse", "HEAD") == checked_head + assert git("ls-remote", "origin", "refs/heads/" + branch).split()[0] == updated_head + assert git("ls-remote", "origin", "refs/heads/main") == original_main + MOCK_COMMAND = '''#!/usr/bin/env python3 import json, os, sys from pathlib import Path diff --git a/tests/tooling/test_pipeline_studio.py b/tests/tooling/test_pipeline_studio.py index 91d5ced4..0e8de463 100644 --- a/tests/tooling/test_pipeline_studio.py +++ b/tests/tooling/test_pipeline_studio.py @@ -598,6 +598,49 @@ def test_production_tool_explains_unknown_registrations(self): self.assertTrue(json.loads(test_process.stdout)["ok"]) self.assertNotIn("提示:", test_process.stderr) + def test_edit_explains_unknown_registrations(self): + production = Path(os.environ.get("LLM_EDGEFLOW_SELECTION_TOOL", ROOT / "build/alg_pipeline_tool")) + fixture = json.loads((ROOT / "demo/fixtures/mock/pipeline_entity_extract_custom.json").read_text()) + for require_valid in (False, True): + with self.subTest(require_valid=require_valid): + request = { + "schema_version": 1, "pipeline": fixture, "require_valid": require_valid, + "operation": {"kind": "rename_node", "node_id": "custom_prompt", + "new_id": "renamed_prompt"}, + } + process = subprocess.run([str(production), "edit", "--stdin"], cwd=ROOT, + input=json.dumps(request), text=True, capture_output=True) + payload = json.loads(process.stdout) + self.assertEqual(process.returncode, int(require_valid), payload) + self.assertEqual(payload["ok"], not require_valid) + self.assertEqual("pipeline" in payload, not require_valid) + self.assertFalse(payload["validation"]["ok"]) + self.assertEqual([d["code"] for d in payload["validation"]["diagnostics"]], + ["UNKNOWN_MODEL_TYPE", "UNKNOWN_BACKEND"]) + self.assertIn("alg_pipeline_tool_test", process.stderr) + self.assertIn("构建变体", process.stderr) + self.assertEqual(process.stderr.count("提示:"), 1) + test_process = subprocess.run([str(PIPELINE_TOOL), "edit", "--stdin"], cwd=ROOT, + input=json.dumps(request), text=True, capture_output=True) + test_payload = json.loads(test_process.stdout) + self.assertEqual(test_process.returncode, 0, test_payload) + self.assertTrue(test_payload["validation"]["ok"]) + self.assertNotIn("提示:", test_process.stderr) + + keyword = json.loads((ROOT / "configs/pipeline_keyword_match_rules.json").read_text()) + node_id = keyword["pipeline"][0]["id"] + for operation in ({"kind": "rename_node", "node_id": node_id, "new_id": "renamed_rule"}, + {"kind": "remove_node", "node_id": node_id}): + with self.subTest(operation=operation): + request = {"schema_version": 1, "pipeline": keyword, "require_valid": False, + "operation": operation} + process = subprocess.run([str(production), "edit", "--stdin"], cwd=ROOT, + input=json.dumps(request), text=True, capture_output=True) + payload = json.loads(process.stdout) + self.assertEqual(process.returncode, 0, payload) + self.assertEqual(payload["validation"]["ok"], operation["kind"] == "rename_node") + self.assertNotIn("提示:", process.stderr) + def test_removed_dependency_repair_command_is_rejected_without_writing(self): original = (ROOT / "configs/pipeline_keyword_match_rules.json").read_bytes() with tempfile.TemporaryDirectory() as directory: