Skip to content

[finding] check:published-readme-exports' namespace-import branch can never fire — namespaceSymbol is hardcoded null at the only call site #10367

Description

@os-zhuang

Observation found while widening the call-site half for #9870 (PR #10365). Not fixed there — different defect, and it has no live consequence today.

The branch

analyzeDocument binds a namespace import so the call-site half can check its members against the module's export set:

if (imp.namespaceLocal) {
  const ns = target.namespaceSymbol;
  if (ns) {
    bound.set(imp.namespaceLocal, { symbol: ns, /* … */ imported: '*' });
  }
}

resolveTarget in run() is the only production caller, and it returns:

return {
  declared: true,
  exports,
  namespaceSymbol: null,   // ← always
  hasMember: …,
};

So ns is always falsy and the branch never binds anything. hasMember already has the matching SymbolFlags.Module arm (checker.getExportsOfModule(s).some(…)), so the machinery on the answering side exists — only the symbol is never supplied.

Why it is a finding and not a bug report

The population is zero twice over, measured with the gate's own publishedDocs():

  • 5 namespace imports exist across the 60 published documents;
  • all 5 are relative specifiers (import * as objects from './src/objects'), in packages/cli/README.md, packages/mcp/README.md and packages/spec/prompts/create-new-project.md;
  • splitSpecifier returns null for a relative specifier, so those imports are skipped before the branch is reached regardless.

⇒ Wiring namespaceSymbol today would change no verdict. Nothing is currently mis-measured.

Why it is still worth recording

This is a branch that reads as coverage and cannot fire — in a gate whose own header raises "zero is a broken scan, not a clean repo" (#4690) to a hard error. The day a published README namespace-imports a workspace package (import * as spec from '@objectstack/spec'), the call-site half will silently not read it, and the green line will not say so. The NOT read: clause added by PR #10365 would count those call sites, so the blind spot would at least be visible — but it would be attributed to "no type this gate can reach", which is not the reason.

Two honest resolutions, both cheap; picking between them is a maintainer call:

  1. Wire it — supply the module symbol from surface (the exportsOf cache already holds it) and pin the shape in --self-test, so the branch is exercised by a fixture even while the tree's population is 0.
  2. Delete it — remove the branch and the imported: '*' path, and say in the header that namespace imports are out of scope, the way CHANGELOG.md and bash fences are.

⛔ What it must not stay is what it is now: present, unreachable, and reading like coverage.

Refs: #9870 / PR #10365 (where it was found) · #9911 (the other no-population shape in this file) · #4690 (empty-population failure).

Activity

  1. added theissue type on Aug 21, 2026
  2. os-zhuang commented on Aug 21, 2026

    @os-zhuang
    ContributorAuthor

    First-touch grading: promoted finding → pm:queue (type Task, domain:devx kept). The card's own bottom line ("what it must not stay is present, unreachable, and reading like coverage") plus the gate's own #4690 posture settles this below the maintainer bar — it is invariant restoration in a check script, not a product call.

    Direction: option 1 (wire it) — supply the module symbol from surface (the exportsOf cache holds it) and pin the namespace-import shape in --self-test with a fixture, so the branch is exercised even at population 0. Strictly more coverage at trivial cost, and consistent with the gate's zero-is-broken stance. If wiring turns out materially more expensive than the cache suggests, fall back to option 2 (delete + header scope note) and say so in the PR body — either honest resolution ACCEPTs; staying as-is does not.

    Size/model suggestion: S, mode:subagent, sonnet–opus.


    Generated by Claude Code

  3. claude commented on Aug 21, 2026

    @claude
    Contributor

    Claim: /pm-dispatch domain:devx execution seat (bug-first, batched round).
    Session: session_01DdCnBGcHeufjrq7drTD3wt
    Branch: claude/issue-10367-readme-exports-population
    Worktree: objectstack-issue-10367
    Domain: domain:devx
    File surface: scripts/check-published-readme-exports.mjs (+ its --self-test fixtures)

    Batched with #10417 — both are no-population defects in this one file, at adjacent stages of the same axis. Landing them separately would put two PRs on the same hot file for no benefit.

    ⛔ Governed surface is out of scope for this diff (docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md, content/docs/releases/**) — propose wording in the report instead. ⛔ #9465 epic fence also out of scope (.changeset/ tooling, cut-rc.yml, release.yml, root package.json); a .changeset/*.md entry for this change is expected and is NOT fenced.


    Generated by Claude Code

  4. added a commit that references this issue on Aug 21, 2026
    dfcd87c
  5. claude commented on Aug 21, 2026

    @claude
    Contributor
    {
      "issue": 10367,
      "also_closes": 10417,
      "status": "done",
      "branch": "claude/issue-10367-readme-exports-population",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/10596",
      "premise_still_valid": true,
      "summary": "Both cards' premises re-verified on today's tree before any code was written, and both held. #10367: typeSurface gains moduleSymbolOf(abs) reading the cache exportsOf already fills, and run()'s resolveTarget is lifted into an exported surfaceTarget(surface, abs) that supplies it — the branch was WIRED, per the PM ruling, not deleted. Lifting the supplier out is the load-bearing part: every existing test drives analyzeDocument through a FAKE resolver, so the suite pinned the CONSUMER of the module symbol and structurally could not see that the SUPPLIER returned null; exported, that end is assertable. The namespace finding also gets its own sentence, since the existing arm interpolated the imported name and for a namespace binding that is the literal '*'. #10417: a new post-pass bindingRefusal refuses the whole-run conjunction symbolChecks===0 && callChecks===0, placed below the unbuilt branch (measured: an unbuilt checkout prints '192 package(s) are not built', not the vacuity refusal) and above the findings report. It is a separate function rather than a fourth stage of populationRefusal because the condition is whole-run and cannot be known until analyzeDocument has read every document. The two cards constrain each other, which is the argument for landing them together: a namespace import binds a receiver the call-site half reads while ticking symbolChecks ZERO times, so symbolChecks===0 alone is a state a healthy tree can be in — that is why the condition is a conjunction, and it is asserted as such. Header doctrine updated from three refusals to four, with the namespace-scope decision recorded beside the CALL-EXPRESSION-RECEIVERS decision it superficially contradicts.",
      "measured_counts": {
        "real_tree_gate": "exit 0 — 60 published document(s) across 77 workspace package(s); 212 import statement(s), 49 workspace type entr(ies)",
        "symbolChecks": 313,
        "callChecks": 78,
        "receivers": 233,
        "derivedReceivers": 46,
        "unreadCalls": 120,
        "unreadReceivers": 72,
        "verdict_on_10417": "Both halves healthily non-zero with enormous headroom, so per the brief the whole-run refusal was safe to land today and the 'legitimate type-only tree' is hypothetical. The headroom is NOT the argument in the PR body — the card was right to reject that: the argument used is the file's own rule for what may be refused (refusable iff the zero occurs only when the run is vacuous, which is why derivedReceivers is merely printed), and the conjunction passes it by construction because it is not a proxy for 'did the halves read anything' but is that question. The residual false-red case (a tree documenting only side-effect/type-only imports) is named explicitly in the failure text and sent at the fences or at retiring the gate, never at the baseline.",
        "10367_population_reverified": "Zero twice over, as the card claimed. The pre-change gate (namespaceSymbol: null) and this one print BYTE-IDENTICAL output on the real built tree — diff clean — so the wiring changes no verdict and no counter today."
      },
      "tests": "Build: the 42 packages the gate reads plus their dependency closure, via scripts/pm/os-verify-lock.sh (VERDICT command-exit 0, held 318s, waited 102s). Final gate union on head commit dfcd87c2c6, also under the lock (VERDICT command-exit 0): check:published-readme-exports --self-test exit 0 ('All FOUR steps of the population axis are refusals'); check:published-readme-exports exit 0 ('60 published document(s) ... 313 documented symbol(s) checked ... 78 documented X.y(...) call(s) checked'); check:cross-package-test-inputs exit 0 ('All 60 self-test cases passed. OK: 12 package(s) read outside themselves, all declared'); check:nul-bytes exit 0 ('scanned 6187 text file(s); no raw ASCII control bytes'); eslint on the changed file exit 0. Gate family derived with `node scripts/pm/dispatch-gates.mjs` with NO path arguments (it derived the change set itself from the merge base: 1 path, committed 1 / working tree 0 / untracked 0) — it named exactly check:cross-package-test-inputs, check:published-readme-exports and the ci.yml node form of the first; all were run. Exit codes were captured before any pipe (redirect-then-capture), and every result above quotes the gate's OWN printed verdict line rather than a bare $?. REBUILD DISCIPLINE FOR THE ABLATIONS: no rebuild was needed between legs and this is stated rather than assumed — the gate reads .mjs source plus the already-built .d.ts files, neither of which any mutation touched, so the built tree was constant across every leg; the mutations were to the gate scripts and to one README, both read from disk on each run. MUTATIONS CONFIRMED ON DISK, NEVER BY AN EDITOR'S EXIT CODE: each leg grep -c'd both the injected text and the deleted text before and after, with byte counts. (1) #10367 end to end on the real tree — appended a workspace namespace-import fence to packages/spec/README.md (namespace import 0->1, invented member 0->1, 4053->4166 bytes): pre-change gate exit 0 GREEN with '78 call(s) checked' unchanged and NOT read 120->122; this PR exit 1 reporting 'documents `spec.defineNothingAtAll(...)` on a namespace import of @objectstack/spec, but that package's published types export no `defineNothingAtAll`'. Then the same fence with ONLY the valid member, to show the branch READS rather than merely reddens: pre-change 78 checked / 233 receivers / NOT read 121, this PR 79 checked / 234 receivers / NOT read 120 — bound, read, and out of the unread bucket. The real module symbol was separately confirmed to carry SymbolFlags.Module (flags 512) so the SymbolFlags.Module arm answers on a real .d.ts, not only on the fake. RESTORE LEG: git checkout plus `cmp` against a pre-ablation copy — byte-identical, 4053 bytes, git status clean. (2) #10417 end to end on the real tree — injected the exact regression the card names ('parseImportClause stops returning bindings') into BOTH gates (marker 0->1, +84 bytes each): pre-change gate exit 0 GREEN over '212 import statement(s), 49 workspace type entr(ies)' with '0 documented symbol(s) checked' and '0 ... call(s) checked' — the #10417 shape reproduced at full tree scale, where the card had only a 1-document fixture; this PR exit 1 with the fourth refusal. NOT read went UP (120->198) under the regression, confirming the card's point that the unread pair is unusable as the test. Restore verified: marker back to 0, 121815 bytes, git status clean. (3) Three targeted self-test ablations, each restored with `git diff --quiet` confirming clean: surfaceTarget's namespaceSymbol back to null -> --self-test RED, 1 failure, 'the PRODUCTION caller supplies a module symbol, never a null'; neuter the bind branch (const ns = null) -> RED, 4 failures, including 'bindingRefusal — a namespace-only tree ... is never refused' flipping, which is the conjunction argument firing live; bindingRefusal always returns null -> RED, 1 failure, 'a run in which every import RESOLVED and not one BOUND A NAME is not refused'. Every self-test zero is MEASURED by the same reachedTargets/analyzeDocument the run calls, never typed into an assertion, and each reject side is asserted POSITIVELY — the namespace fixture pins that a real export stays silent AND that an invented one is reported, with counters (receivers 1, checked 2, unread 0) so that 'bound nothing, read nothing' cannot pass as 'checked two, found one'.",
      "open_questions": [],
      "brief_corrections": [
        "CHANGESET: the brief said 'a .changeset/*.md entry is expected and is NOT fenced'. The tree's practice is the opposite for a scripts/**-only diff and I followed the tree, not the brief. Evidence: the last five scripts-only merges to main carried no changeset, and PR #10502 — a scripts/**-only gate PR merged the same day — carries the skip-changeset label with the body line 'No changeset: the diff is scripts/** only and publishes nothing'. This PR publishes nothing, so a changeset would force a version bump and release of a package that did not change. Applied the skip-changeset label instead (via the ADDITIVE POST /labels endpoint, not a wholesale PUT, so a bot's concurrent label write cannot be erased) and said so in the PR body. Flagging rather than working around it silently, as asked.",
        "SCOPE ADDITION worth a reviewer's eye, beyond what the brief described: the brief framed #10367 as supplying namespaceSymbol from surface. Doing only that would have left the defect untestable — the suite fakes resolveTarget, so it pins the CONSUMER of the symbol and would have stayed green for the gate's whole life while the SUPPLIER returned null. So run()'s inline resolveTarget was lifted into an exported surfaceTarget(surface, abs) and pinned. This is a small refactor of production code the brief did not ask for; it is the only way the wiring becomes falsifiable, and ablation (3) shows it failing when reverted.",
        "Everything else in the brief checked out exactly as written, including both measured populations and the predicted 'NOT read goes UP, not to zero' behaviour."
      ],
      "out_of_scope_findings": []
    }

    Generated by Claude Code

  6. added a commit that references this issue on Aug 23, 2026
    157c20c
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions