Skip to content

finding(ci): hook-selftests.yml hand-enumerates the self-tests it claims to be the standing caller for — a new matrix ships uncalled #6906

Description

@claude

Found while mirroring the origin/main enumeration guard for objectstack-ai/objectstack#13305 (objectui PR for that card); out of scope for it, filed rather than redesigned there.

The gap

.github/workflows/hook-selftests.yml names itself "The standing caller for .claude/hooks/*.selftest.sh". It is not one. Its steps are a hand-kept enumeration, so a new self-test ships uncalled until someone remembers to edit this file:

      - name: guard-main-checkout-bash self-test
        run: .claude/hooks/guard-main-checkout-bash.selftest.sh

      - name: guard-shared-stash self-test
        run: .claude/hooks/guard-shared-stash.selftest.sh

objectstack's twin does not have this failure mode. Its collector in lint.yml discovers:

mapfile -t selftests < <(find .claude/hooks -type f -name '*.selftest.sh' | sort)
if [ "${#selftests[@]}" -eq 0 ]; then
  echo "... DISCOVERED NOTHING under .claude/hooks/."
  exit 1        # verifying nothing is a failure, not a pass
fi

That shape adds a new matrix with no workflow edit, and it fails loudly if the search ever stops matching — so it cannot silently degrade to a no-op.

Measured

On the card's branch, adding guard-tree-enum.selftest.sh to both repos:

repo runner shape new self-test picked up?
objectstack find .claude/hooks -name '*.selftest.sh' in lint.yml yes, automatically — 4 discovered, no workflow edit
objectui two hardcoded run: steps in hook-selftests.yml no — had to be added by hand

Why this is worth a card rather than a shrug

This workflow's own header argues at length that it is a RUNNER whose matrices "are meant to grow in .claude/** WITHOUT this file being touched", and it removed per-step case counts specifically to keep that independence safe. The enumeration in its steps: is the remaining half of exactly the drift it set out to avoid: the independence holds for cases inside an existing matrix, and breaks for a new matrix file.

It also interacts with #6451 (guard-main-checkout.sh has no self-test at all): whoever writes that missing matrix must remember this file too, or their new self-test is added and never run. Distinct mechanism though — #6451 is a missing self-test FILE, this is the runner that would not call it. Not a duplicate.

Suggested fix

Port objectstack's collector, including its two load-bearing properties: the empty-discovery branch is RED (a step that verified nothing is not a pass), and the loop tolerates-and-collects so one red matrix does not hide the others. scripts/dependabot-merge-gate.mjs's OPTIONAL_CONTEXTS description of this workflow should stay count-free (it was de-counted on the #13305 branch for exactly this reason).

Unassigned and unlabelled — grading and domain:* are triage's.


Generated by Claude Code

Activity

  1. zhuangjianguo commented on Aug 31, 2026

    @zhuangjianguo
    Collaborator

    Triage (objectstack skills seat, cross-lane finding round): p2 · pm:queue · domain:ui. Measured runner gap with a live consequence class (a new self-test matrix ships uncalled — it already required a hand edit on the #13305 branch), the fix is a proven port (objectstack's collector, with both load-bearing properties named: RED on empty discovery, tolerate-and-collect), and it interacts with #6451 — whoever writes that missing matrix needs this runner fixed first or their work silently never runs. Dispatch note: keep dependabot-merge-gate.mjs's description count-free, as the card says.


    Generated by Claude Code

  2. claude commented on Aug 31, 2026

    @claude
    ContributorAuthor

    Claiming this card.

    • Session: session_013hfmP9hoMd3dJwTh85J4yB
    • Branch: claude/issue-6906-hook-selftest-collector
    • Repo: objectstack-ai/objectui

    Planned file surface (declared up front so parallel agents can serialize):

    • .github/workflows/hook-selftests.yml — replace the hand-kept step enumeration with the sibling repo collector (discovery via find, RED on empty discovery, tolerate-and-collect).
    • .changeset/*.md — changeset for the change.

    ⛔ Not touching .claude/** (governed surface) and not touching scripts/dependabot-merge-gate.mjs unless its description turns out to carry a case count.


    Generated by Claude Code

  3. claude commented on Aug 31, 2026

    @claude
    ContributorAuthor

    os-dev-report

    {
      "issue": 6906,
      "status": "done",
      "branch": "claude/issue-6906-hook-selftest-collector",
      "pr": "https://github.com/objectstack-ai/objectui/pull/7019",
      "pr_url": "https://github.com/objectstack-ai/objectui/pull/7019",
      "premise_still_valid": true,
      "head_sha": "91eb09282",
      "base_sha_recorded_at_branch_creation": "b458300cae2330600296173bf463d458d2d36071",
      "files_changed": [
        ".github/workflows/hook-selftests.yml",
        "content/docs/guide/ci-cd-pipeline.md",
        ".changeset/6906-hook-selftests-collector.md"
      ],
      "touched_dot_claude": false,
      "touched_dot_claude_evidence": "git diff --name-only b458300ca...HEAD | grep -c '^\\.claude/' -> 0 (three-dot against the base sha recorded at branch creation, not a moving origin/main). Independently: node scripts/check-governed-queue-guard.mjs --test on all three paths -> exit 0, 'NOT GOVERNED - 3 path(s) checked against 5 governed surface(s); none matched.'",
      "discovery_census": {
        "command": "find .claude/hooks -type f -name '*.selftest.sh' | sort",
        "found": [
          ".claude/hooks/guard-main-checkout-bash.selftest.sh",
          ".claude/hooks/guard-shared-stash.selftest.sh",
          ".claude/hooks/guard-tree-enum.selftest.sh"
        ],
        "is_superset_of_hardcoded": true,
        "note": "Exact equality, so a superset in the non-strict sense: nothing dropped, nothing added today. The card quoted TWO hardcoded steps; main already carries THREE - guard-tree-enum.selftest.sh was hand-added, which is the card's own evidence, not a changed premise."
      },
      "selftest_results": [
        {
          "name": ".claude/hooks/guard-main-checkout-bash.selftest.sh",
          "exit_code": 0,
          "tail": "121 passed, 0 failed"
        },
        {
          "name": ".claude/hooks/guard-shared-stash.selftest.sh",
          "exit_code": 0,
          "tail": "41 passed, 0 failed"
        },
        {
          "name": ".claude/hooks/guard-tree-enum.selftest.sh",
          "exit_code": 0,
          "tail": "36 passed, 0 failed"
        },
        {
          "name": "all three through the NEW collector, from the repo root",
          "exit_code": 0,
          "tail": "discovered 3 hook self-test(s) ... Hook self-tests - all 3 ran and passed"
        }
      ],
      "empty_discovery_leg": {
        "how_exercised": "The step's real run: block was extracted from the YAML by a parser (PyYAML), not retyped, and executed under bash -e from two SCRATCH directories - never by deleting or moving anything under the repo's .claude/. Leg A: a scratch dir containing an EMPTY .claude/hooks/. Leg B: a scratch dir with no .claude/ at all (find itself errors).",
        "observed_exit_code": 1,
        "observed_exit_code_leg_b": 1,
        "is_red": true,
        "message_printed": "Hook self-tests - DISCOVERED NOTHING under .claude/hooks/. / This step verified nothing, which is a failure and not a pass"
      },
      "tolerate_and_collect": {
        "how_exercised": "Scratch .claude/hooks/ with three stubs: aaa-deliberately-red (exit 1), mmm-deliberately-red (exit 3), zzz-green (exit 0, prints LATER-MATRIX-RAN). Same extracted run: block, same bash -e.",
        "later_test_ran": true,
        "later_test_evidence": "grep -c LATER-MATRIX-RAN in the collector log = 1; the log shows 'PASS .claude/hooks/zzz-green.selftest.sh' after two FAIL lines.",
        "overall_failed": true,
        "overall_exit_code": 1,
        "both_failures_named": true,
        "counter_proof": "The identical three stubs under a naive `for f in \"${selftests[@]}\"; do \"$f\"; done` -> exit 1 and LATER-MATRIX-RAN occurrences = 0. bash -e aborts at the first red matrix, so the collector shape is load-bearing, not decoration.",
        "extra": "find (not a flat glob) also proven: a stub at .claude/hooks/nested/deep.selftest.sh in the same scratch tree is discovered."
      },
      "dependabot_gate_touched": false,
      "dependabot_gate_note": "scripts/dependabot-merge-gate.mjs was READ (OPTIONAL_CONTEXTS entry for 'Hook Self-Tests', line ~195). It is already count-free - it says 'the PreToolUse guard self-test matrices must pass' with no case count - and its paths sentence stays accurate. Changed nothing there, as the card instructed. The workflow header now records WHY it must stay count-free.",
      "coverage_not_reduced_evidence": "The hardcoded run: commands were parsed out of the workflow AS IT EXISTS ON THE BASE SHA (git show b458300ca:.github/workflows/hook-selftests.yml, then PyYAML over the steps) and set-differenced against discovery on HEAD. comm -23 hardcoded discovered = 0 lines (nothing dropped); comm -13 = 0 lines (no net additions). All three still execute, and each was run individually to exit 0. No self-test was skipped, disabled, quarantined, moved or renamed.",
      "checks_run": [
        {
          "name": "YAML parse + trigger/permission invariance",
          "command": "python3 -c 'yaml.safe_load(...)' over .github/workflows/hook-selftests.yml",
          "result": "PASS - parses; on:/concurrency/permissions/runs-on/timeout-minutes unchanged from base; 2 steps (checkout + collector). No actionlint or yamllint in this container."
        },
        {
          "name": "shell syntax of the collector",
          "command": "bash -n on the run: block extracted from the YAML",
          "result": "PASS - SYNTAX OK"
        },
        {
          "name": "scripts test suite",
          "command": "os-verify-lock.sh -c 'NODE_OPTIONS=--max-old-space-size=4096 pnpm exec vitest run scripts/__tests__/ --maxWorkers=2'",
          "result": "PASS - Test Files 90 passed (90), Tests 2572 passed (2572). Covers ci-cd-pipeline-doc, merge-queue-reporting, dependabot-merge-gate, lint-workflow, check-changeset-{presence,no-major,overwrite,fixed}, check-control-bytes, check-doc-*."
        },
        {
          "name": "check:control-bytes",
          "command": "node scripts/check-control-bytes.mjs",
          "result": "PASS - 'check-control-bytes: OK (scanned 5820 tracked text file(s); skipped 85 binary)'"
        },
        {
          "name": "control-byte self-scan of the changed files",
          "command": "grep -naP '[\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f]' on all 3 changed files",
          "result": "PASS - no matches"
        },
        {
          "name": "check:doc-fences",
          "command": "node scripts/check-doc-fence-languages.mjs",
          "result": "PASS - 'every TypeScript block in 224 document(s) is fenced ts/tsx/typescript'"
        },
        {
          "name": "check:doc-types",
          "command": "pnpm check:doc-types",
          "result": "PASS - 'Every documented component type is registered.'"
        },
        {
          "name": "check:doc-links",
          "command": "node scripts/check-doc-links.mjs",
          "result": "PASS - 'Links are valid across 17 scan roots.'"
        },
        {
          "name": "check:shell-escape-residue",
          "command": "pnpm check:shell-escape-residue",
          "result": "PASS - 205 file(s), 1320 fenced block(s) examined"
        },
        {
          "name": "check:skills-paths",
          "command": "pnpm check:skills-paths",
          "result": "PASS - 95/96 stated paths resolve, 1 baselined"
        },
        {
          "name": "check:action-forward-parity",
          "command": "pnpm check:action-forward-parity",
          "result": "PASS"
        },
        {
          "name": "check:governed-queue-guard",
          "command": "pnpm check:governed-queue-guard",
          "result": "PASS - 132 self-test cases pass"
        },
        {
          "name": "check:entry-guard",
          "command": "pnpm check:entry-guard",
          "result": "PASS - 57 scripts/ files, no entry guard outside the baseline"
        },
        {
          "name": "changeset presence",
          "command": "node scripts/check-changeset-presence.mjs",
          "result": "PASS - '2 file(s) changed, 0 of them published source ... 1 changeset(s) added' -> no changeset owed, one added anyway per this workflow's own convention (empty frontmatter, like #5754 and #6089)"
        },
        {
          "name": "check:doc-snippets",
          "command": "pnpm check:doc-snippets",
          "result": "NOT MEASURED - exit 2, 'PRECONDITION NOT MET ... This is I could not run, NOT I ran and found errors'. It needs every package built. The diff adds/removes ZERO fenced blocks in the doc (git diff base...HEAD | grep -c '^[+-].*```' = 0), so it has nothing here to judge. CI runs it regardless."
        }
      ],
      "narrowings_declared": [
        "Repo-wide `pnpm lint` (turbo eslint farm) narrowed away. Population read from eslint.config.js itself: every files: selector in it is .ts/.tsx. This diff's .ts/.tsx count is 0 (3 files: one .yml, two .md), and it touches no eslint.config.* and no tsconfig*, so no untouched file's verdict can move either. CI runs the farm regardless.",
        "`pnpm check:doc-snippets` left at PRECONDITION NOT MET rather than building all 21 packages to judge a diff with zero code fences (see checks_run).",
        "pnpm install was run OUTSIDE the shared verify lock. Justification: os-verify-lock.sh --status states in its own text that the lock 'does NOT see unlocked sibling work - check:* gate scripts, dev servers, INSTALLS'. Mine had queued behind a 7-minute vitest hold for what turned out to be a 5.9s job; I killed my own waiter by PID (18464/18466 only) and ran it unlocked. The vitest run itself DID go through the lock (slot objectui-6906, waited 87s, held 65s)."
      ],
      "scope_addendum": "One file beyond the claim comment's declared surface: content/docs/guide/ci-cd-pipeline.md. It is the paired write this repo's convention requires (the #5754 changeset names it as such) and its 'Hook Self-Tests' section hand-listed two of the three matrices - the same enumeration defect, in the doc that describes the very step being changed. Leaving it would have shipped a doc that is false about this PR. Named with evidence in the PR body.",
      "tests": "See selftest_results and checks_run. All measurements at head 91eb09282 (the final commit): the vitest union and every check:* above were run on that tree, and no commit followed them. Exit codes were captured before any pipe (redirect-then-capture), and each gate is quoted by its own verdict line, never by a bare $?.",
      "mcp_calls": "0 - every GitHub read and write went through repo-scoped REST with curl (probe: GET /repos/objectstack-ai/objectui -> 200; POST issue comment -> 201; POST pull -> 201). No MCP GitHub tool was called.",
      "summary": "Ported objectstack's discovering collector into .github/workflows/hook-selftests.yml: one step that runs `find .claude/hooks -type f -name '*.selftest.sh' | sort` and executes each hit, replacing the hand-kept enumeration of run: steps. Both load-bearing properties are kept and were EXERCISED, not read: an empty discovery exits 1 (proven from two scratch dirs), and the loop tolerates-and-collects (proven with red stubs - a later matrix still ran, both failures were named, the step still exited 1; the naive bare loop was run as a counter-proof and never reached the later matrix). Coverage is unchanged and measured by set difference against the base sha: the same three self-tests, each also run individually to exit 0. .claude/** untouched. One premise nuance worth recording: the card quotes TWO hardcoded steps, but main already carries THREE - guard-tree-enum.selftest.sh had been added by hand, which is the card's own evidence rather than a changed premise.",
      "open_questions": [],
      "out_of_scope_findings": [],
      "blockers": [],
      "claim_comment_url": "https://github.com/objectstack-ai/objectui/issues/6906#issuecomment-5478498226"
    }

    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    ci/cddomain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatpm:dispatchedpriority:p2

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions