Skip to content

[finding] Nothing distinguishes "zero packages affected" from "the affected-set computation silently produced nothing" — an empty changed-files.txt is never checked #10057

Closed

Description

@os-steve

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/ or scripts/ (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 ... ; then

Only 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.

⚠️ Note the shard attestation (#6082) does not cover this: it attests "shard N of 3 ran and every step passed", which is exactly what a shard that tested nothing does.

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

  1. added
    toolingFix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface
    domain:devxDomain lane — seat card indexed by label:pm:seat
    findingRecorded observation — held, not dispatchable until the findings triage round grades it
    on Aug 19, 2026
  2. removed
    findingRecorded observation — held, not dispatchable until the findings triage round grades it
    on Aug 20, 2026
  3. claude commented on Aug 21, 2026

    @claude
    Contributor

    Claim — PM dispatch (devx seat)

    • Session: c970724d-303c-5614-9d20-a3f92205cfad
    • Branch: claude/issue-10057-empty-changed-files

    Premise re-verified on origin/main = 47cd3ec1f8 (fetched 2026-08-21T09:05:30Z):

    • .github/workflows/ci.yml:460 is still
      if ! 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.
    • The posture the card asks to reuse is in the same block:
      echo "::warning::Could not union cross-package scans into the affected set; falling back to the full package list for this shard." followed by
      pnpm exec turbo ls --output=json. The merge-base fallback immediately below does the same
      and 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 to
      invent.

    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.txt on a pull_request event is not
    a 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.

    ⚠️ Scope note carried into the brief: the else branch's comment states that push and
    merge-queue builds take the full-list path by design, so the new emptiness test must be
    scoped to pull_request events only, as the card specifies.

    ⚠️ Blast radius: this edits ci.yml, which gates every PR in the repo. The brief requires
    a 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 reads ci.yml's two aggregator needs: rosters but writes only
    lint.yml, and this change lives in a run: block inside the test job rather than in any
    needs: list, so the two do not interact.


    Generated by Claude Code

  4. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Aug 21, 2026
  5. claude commented on Aug 21, 2026

    @claude
    Contributor

    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

  6. removed
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    on Aug 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

domain:devxDomain lane — seat card indexed by label:pm:seattoolingFix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions