Repository navigation
Description
Found while testing whether the shard partitioner's emptiness guard actually fires, as part of #10046. Filed unassigned. This is a latent gap, not a live bug — the mitigations below are real and were clearly designed in; recording it because the residual hole is the exact shape (#4690) the surrounding machinery exists to prevent, and nothing currently names it.
What was measured
scripts/partition-test-shards.mjs's shape assertion works as its header advertises. Fed deliberately broken payloads it exits 1 and names the cause:
items key removed -> exit=1
items is an object -> exit=1
But an empty document and a populated one are indistinguishable to it, by design — both exit 0:
EMPTY (count 0, items []) -> exit=0, stderr "shard 1/3: 0/0 packages, weight 0", 0 stdout lines
NON-EMPTY (count 2, items 2) -> exit=0, stderr "shard 1/3: 1/2 packages, weight 414", 1 stdout line
Zero stdout lines then short-circuits the test step in ci.yml:
if [ ! -s "$RUNNER_TEMP/shard-packages.txt" ]; then
echo "No packages on this shard — nothing to test."
exit 0
fi— a green Test Core that ran nothing.
Why the partitioner is the wrong place to fix it
Zero is frequently the correct answer here, unlike #4690's scanner:
- a PR touching only
.github/,content/docs/orscripts/(with no declared cross-package glob hit) genuinely affects no package; - with 3 shards, any run with fewer than 3 packages legitimately leaves a shard empty.
So a blanket "red on empty" at the partitioner would be wrong. The header already states the contract correctly — zero lines means "nothing to run", NOT "no filter".
The mitigations that already exist
Worth stating so nobody re-derives them: every failing producer falls back to the full package list, never to an empty one. ci.yml has three such branches — unresolvable merge-base, a failed git diff, and a failed --union-into — each falling back to turbo ls --output=json over the whole workspace. Steps run under bash -e, so a non-zero turbo ls --affected fails the step outright. A crashing selection therefore cannot yield an empty shard.
The residual hole
What none of that covers is a producer that exits 0 with a wrong, plausible, empty answer. The concrete unchecked signal:
if ! git diff --name-only "$SCM_BASE" HEAD > "$RUNNER_TEMP/changed-files.txt" \
|| ! node scripts/check-cross-package-test-inputs.mjs ... ; thenOnly the exit status of that git diff is checked. Its emptiness is not. A PR whose merge-base resolved to something wrong-but-valid produces an empty changed-file list, which yields zero affected packages, which yields a green shard that tested nothing — and every log line along the way reads as a normal quiet PR.
An empty changed-files.txt on a pull_request event is not a legitimate state: a PR always differs from its merge base. That makes it a decidable signal, unlike shard emptiness itself, and it is the one place where "no packages selected" and "selection failed" can actually be told apart.
Suggested shape (deliberately not implemented here): treat an empty changed-files.txt on a pull_request event as a reason to take the existing full-package-list fallback, with a ::warning:: naming it — same posture as the merge-base fallback next to it (#6195), same reasoning: doubt costs minutes, not coverage.
Refs: #10046 (where this was measured), #4690 ("zero is a broken scan, not a clean repo"), #6195 (the merge-base fallback whose posture this would follow), #6082 (shard attestation), #7802.
Generated by Claude Code
Activity
claude commentedon Aug 21, 2026
Claim — PM dispatch (devx seat)
c970724d-303c-5614-9d20-a3f92205cfadclaude/issue-10057-empty-changed-filesPremise re-verified on
origin/main=47cd3ec1f8(fetched 2026-08-21T09:05:30Z):.github/workflows/ci.yml:460is stillif ! git diff --name-only "$SCM_BASE" HEAD > "$RUNNER_TEMP/changed-files.txt" || ! node scripts/check-cross-package-test-inputs.mjs … --changed "$RUNNER_TEMP/changed-files.txt"; then— only the exit status is tested; emptiness is not. The residual hole the card
describes is present at the current tip.
echo "::warning::Could not union cross-package scans into the affected set; falling back to the full package list for this shard."followed bypnpm exec turbo ls --output=json. The merge-base fallback immediately below does the sameand names ci.yml 的
TURBO_SCM_BASE吃同一个冻结 base.sha —— turbo --affected 在 merge ref 上把 main 漂移算成本 PR 改动(方向保守:多跑,不会少跑) #6195 in its warning. So this is an existing pattern to extend, not a new one toinvent.
The card's own discriminator is what makes this decidable and is why it is dispatchable
rather than a judgement call: an empty
changed-files.txton apull_requestevent is nota legitimate state — a PR always differs from its merge base. Shard emptiness is not
decidable that way (a docs-only PR genuinely affects no package, and with 6 shards a small
change legitimately leaves shards empty), which is exactly why the card is right that the
partitioner is the wrong place to fix it.
elsebranch's comment states that push andmerge-queue builds take the full-list path by design, so the new emptiness test must be
scoped to
pull_requestevents only, as the card specifies.ci.yml, which gates every PR in the repo. The brief requiresa minimal diff and an explicit demonstration that the new condition cannot fire on a normal
PR.
No collision with this seat's four in-flight agents — #10653 (
packages/lint), #10667(the
scripts/**entry-guard burn-down), #10490 (new gate +lint.yml) and #10616(
content/docs/**). #10490 readsci.yml's two aggregatorneeds:rosters but writes onlylint.yml, and this change lives in arun:block inside thetestjob rather than in anyneeds:list, so the two do not interact.Generated by Claude Code
claude commentedon Aug 21, 2026
os-dev report — issue #10057
{ "issue": 10057, "status": "done", "branch": "claude/issue-10057-empty-changed-files", "pr": "https://github.com/objectstack-ai/objectstack/pull/10708", "premise_still_valid": true, "summary": "Premise re-verified at origin/main 47cd3ec1f8: ci.yml's affected-set step checked only the exit status of `git diff --name-only \"$SCM_BASE\" HEAD`, never its emptiness. Added one `elif [ ! -s \"$RUNNER_TEMP/changed-files.txt\" ]; then` leg on the existing full-package-list fallback plus a ::warning:: naming it, extending the pattern already in that block rather than inventing a mechanism. 26 lines added, 0 removed, 0 existing lines modified. Scoping to pull_request needed no github.event_name test and I did not add one: SCM_BASE is assigned only inside the pull_request guard, so the enclosing `[ -n \"$SCM_BASE\" ]` branch is unreachable on push and merge_group, which keep the full-list path by design. That is also why the fix is complete rather than merely as-specified -- pull_request is the only event on which affected-set narrowing happens at all, so it is the only event that can carry this hole. Accepted cost, stated in the PR: a PR whose head is content-identical to its merge base has a legitimately empty diff and will now run the full package list instead of nothing -- minutes, not coverage, and loud rather than silent.", "tests": "Gate union re-run at final commit 4192b90088 (= PR head sha, confirmed via the API), all green, quoting each gate's own printed verdict line rather than a bare $?: check-shard-attestation '2 aggregate gate(s) count 3 declared leg(s) across 3 attesting job(s)' (the load-bearing one -- job `test` is still an attesting shard, not misclassified as a gate; the step's run: text contains no --verify substring); check-workflow-status-functions 'OK (scanned 26 workflow file(s), 49 job(s), 24 job-level if: expression(s))'; check-node-version 'OK (32 setup-node step(s) across 26 workflow(s), all on Node 22)'; check-required-contexts OK (self-test + full scan); check-cross-package-test-inputs 'OK: 13 package(s) read outside themselves, all declared'; check-nul-bytes 'OK (scanned 6219 text file(s) ... no raw ASCII control bytes)'; partition-test-shards.mjs --self-test 'self-test OK (71 measured packages, 6 shards, max/mean 1.00x <= 1.3x)'. Families derived by `node scripts/pm/dispatch-gates.mjs` with no paths (6 matched) plus the partitioner self-test reasoned in as the directly downstream consumer. First run of these gates was red for all three yaml-importing scripts with ERR_MODULE_NOT_FOUND 'Cannot find package yaml' -- environmental (no pnpm install in the fresh worktree), not the change; green after `pnpm install --frozen-lockfile` under scripts/pm/os-verify-lock.sh (VERDICT command-exit 0, waited 18s behind another agent's spec build). BEHAVIOURAL EVIDENCE: a harness parses the real `run:` block out of ci.yml, applies GitHub expression interpolation textually, and executes it under `bash -e` with git/pnpm stubbed but the REAL partition-test-shards.mjs downstream -- so the same driver measured the file before and after. On pull_request exactly one of five cases moved: the empty-changed-files case went from (no fallback, 0 packages on the shard, 0 warnings) to (fallback, 1 package, 1 warning); the normal-PR case and the three pre-existing failure branches were byte-identical. On push and merge_group all five cases were unchanged, confirming the condition cannot fire there. ABLATION on the boolean: inverting to `[ -s ... ]` moved exactly two rows in opposite directions as predicted -- the normal-PR case wrongly gained the fallback plus a spurious warning, and the empty case reverted to 0 packages and silence. Mutation confirmed on disk by anchor counts on both legs (correct form 1->0 and inverted 0->1 on the mutate leg; 0->1 and 1->0 on the restore leg, then `git status` clean and the restore-leg measurement re-verified identical to the pre-ablation one). No build/dist is involved, so there is no rebuild leg to state. Two harness self-corrections worth recording because both produced plausible-but-wrong numbers: (1) an early orig-vs-current comparison ran the pristine copy with cwd outside the repo, so partition-test-shards.mjs did not exist and every row read exit=1/0-packages -- I added an assert on the repo root rather than reading those numbers; (2) one restore-leg run was piped through `head -8`, whose SIGPIPE killed the driver before its final json.dump, so I briefly compared against the stale ablation JSON -- re-run unpiped, with the driver's exit status captured before any pipe. YAML re-parsed after the edit (yaml.safe_load, 10 jobs). Control-byte scan on the edited file clean (grep -naP over the C0 set, no matches); the added diff is pure ASCII.", "open_questions": [], "out_of_scope_findings": [ "filed as #10713: ci.yml's shard-classifier warning comment describes the pre-#6589 co-occurrence classifier -- its three stated mechanisms (joined run: text, bare-substring matching, comments count) are each explicitly repudiated by the current check-shard-attestation.mjs, which is per-step, adjacency-based and comment-dropping. Comment-only; labelled `finding`, unassigned, no pm:queue." ] }Generated by Claude Code