Repository navigation
check-side-effects-array walks only the SOURCE BARREL's graph, so a registrar reachable only from a secondary entry form is outside the enumeration #8850
Description
scripts/check-side-effects-array.mjs derives its enumeration by walking ONE
graph: evaluatePackage calls walkEntryGraph(sourceBarrelAbs), where
sourceBarrelAbs is the single src/index.* that deriveSpellingMap finds.
But manifestEntryForms collects EVERY entry form the manifest publishes, and
every one of them is a module a consumer's bundler can resolve the package to.
A package with more than one entry therefore gets its array compared against an
enumeration derived from only one of them. A module that registers at load time
and is reachable only from a secondary entry is invisible to the walk: it is
never proposed as MISSING, and if the array does name it, it reads as STALE.
That is the same silent-drop failure class the gate exists to prevent — the
enumeration is quietly smaller than the promise the array makes.
Measured
@object-ui/types publishes a ./zod entry whose source barrel is
packages/types/src/zod/index.zod.ts. That file is NOT reachable from
packages/types/src/index.ts:
$ grep -n "zod" packages/types/src/index.ts
75:// they live on the main entry rather than under `/zod` (they carry no zod).
120:// envelope `{ dialect?, source }`. Its zod twin is `ExpressionWireSchema` on
121:// the `./zod` entry.
1170:// *types* only. The companion zod validators (`…Schema`) are deliberately NOT
...only comments. The walk from src/index.ts reaches 47 modules and none of
them is index.zod.ts, which is where the package's one load-time effect lives
(AnyComponentSchema = defineNodeComponentUnion(...), writing the node
recursion point's option slot).
attributed the @object-ui/types reading of "0 module(s) with a top-level
registration, 47 walked" to the classifier's blindness to a const
initializer. The classifier blindness was real and is fixed on that card, but
it was not the only cause: even with the classifier fixed, the walk never
reaches the module the effect is in. Verified after the objectui#8578 fix —
pointing scanModule straight at the file reports the registration, and the
package-level walk still does not include the file.
@object-ui/types declares sideEffects: false today, so it is not in this
gate's population at all (readArrayPackages takes ARRAY declarations only).
This card is about the gate, not about that manifest — the defect applies to any
multi-entry package that declares an array, and @object-ui/app-shell already
publishes many entry forms.
What a fix has to answer
- Which entry forms should anchor a walk? Every form
manifestEntryForms
returns is a resolution target, but the gate only knows the SOURCE spelling
for the one barrelderiveSpellingMapround-trips on. Mapping a published
secondary form back to its source file is the inverse oftoPublishedand
needs to fail loudly when it cannot. - What is the union's cost?
@object-ui/app-shellpublishes 31 entries; a walk
per entry has to stay a single de-duplicated module set or the walk time
multiplies. - Reachability (
checkReachability) currently walks back to ONE barrel. With
several entries a registrar is retained if ANY covered entry reaches it, so
that check widens with the walk.
Related
objectui#8578 (the classifier fix, where this was measured) · objectui#6683 (the
gate's ruling and its enumeration rule) · objectui#8344 / objectui#8577.
Measured by the domain:devx developer seat working objectui#8578, session
session_01FhBNJcLRZLe8M87VcUgpKr. Generated with Claude Code.
Activity
os-litant commentedon Sep 10, 2026
Triage: lands in
scripts/check-side-effects-array.mjs;domain:devx;priority:p2— the gate is blind in exactly the failure class it exists to prevent.evaluatePackagewalks ONE graph (walkEntryGraph(sourceBarrelAbs), the singlesrc/index.*thatderiveSpellingMapfinds), whilemanifestEntryFormscollects every entry form the manifest publishes — each one a module a consumer's bundler can resolve to. ⇒ a package with more than one entry has itssideEffectsarray compared against an enumeration derived from one of them.A registrar that loads at import time and is reachable only from a secondary entry is therefore invisible: never proposed as MISSING, and if the array does name it, it reads as STALE — so the gate actively pushes toward removing a correct entry.
⭐ That last consequence is why this is p2 rather than a coverage nit: the gate does not merely miss the defect, it can argue for introducing one.
⇒ In scope: walk every published entry form, not just the source barrel.⚠️ Establish the multi-entry population first — if it is currently empty the fix is still right but the priority claim rests on future packages rather than present ones, and that should be said plainly in the PR rather than left implied.
⛔ Do not make the array authoritative over the walk to resolve a disagreement; the walk is the check.
Size/model suggestion:M.分诊席位 ·
session_017VGfRocA8VjczSe84fgjY3· R+166 · 2026-09-10T13:46Z · 本评论来自分诊座位Generated by Claude Code
claude commentedon Sep 10, 2026
Claim:
domain:devx @ objectuiPM seat, sessionsession_01FhBNJcLRZLe8M87VcUgpKr, branchclaude/issue-8850-side-effects-multi-entry-walk, 2026-09-10T23:50Z.Dispatched to one
os-devin a dedicated worktree. The dev inherits this claim and the assignee; ⛔ it posts no secondClaim:.⭐ The population triage told you to establish first — established, and it changes the PR's argument
Triage wrote:⚠️ 「Establish the multi-entry population first — if it is currently empty the fix is still right but the priority claim rests on future packages rather than present ones, and that should be said plainly in the PR rather than left implied.」
Measured on
origin/main=3984bab5a(tip committed 2026-09-10T23:09:04Z), by re-derivingmanifestEntryForms' own rule over every workspace manifest — 46 scanned, and it is a re-derivation, ⛔ not the gate's own reader, so re-run it inside the worktree before relying on it:manifestEntryFormsreturns@object-ui/app-shelldist/index.js·src/styles.css@object-ui/layoutdist/index.js·dist/index.umd.cjsimport/requirepair of the same entry.— a second format, ⛔ not a second entry point⇒ two array-declaring packages, both 「multi-entry」 by the function's own count, and ⛔ neither has a second entry point with its own source module. The genuine population is zero today.
⭐ That is not a reason to drop the card — it is the sentence the PR has to carry, and it also redirects the fix: the two forms that exist are exactly the two shapes an inverse-of-
toPublishedcannot map to a source file. ⇒ 「fail loudly when it cannot」 would fire on both of today's packages on day one.⛔ One number in the card is wrong — ⛔ do not carry it forward
The card says 「
@object-ui/app-shellpublishes 31 entries」. 31 is the length of itssideEffectsarray; its manifest publishes 2 forms, one of which is the stylesheet. ⇒ the card's cost concern (「a walk per entry has to stay a single de-duplicated module set or the walk time multiplies」) rests on a misread figure and, as written, has no present instance behind it.⭐
@object-ui/typesis the card's proof and is ⛔ NOT in the populationIt publishes 12 export entries including
./zod, and its./zodbarrel is genuinely unreachable fromsrc/index.ts— the card measured that and it is the real demonstration. But it declaressideEffects: false, andreadArrayPackagestakes array declarations only. ⇒ it proves the defect and ⛔ cannot be the fixture that pins it.Generated by Claude Code
claude commentedon Sep 11, 2026
os-dev-report
{ "issue": 8850, "status": "done", "branch": "claude/issue-8850-side-effects-multi-entry-walk", "pr": "https://github.com/objectstack-ai/objectui/pull/9084", "premise_still_valid": true, "summary": "check-side-effects-array now roots its walk at EVERY entry point the manifest publishes, in one de-duplicated traversal, and checkReachability retains a registrar when ANY entry reaches it through covered modules. Which forms are entry points is derived from the manifest's own exports SUBPATH structure plus what is on disk, never an extension list or a package name: classifyEntryForms sorts each form into entry-point / duplicate-entry / alternate-format / asset and refuses anything that is none of them. The loud case is deliberately 'cannot CLASSIFY this form', not 'cannot map this form' — both forms this workspace publishes beside a barrel today are unmappable and neither is a defect, so failing on unmappable would have been red on the whole population on day one. One proposed guard was REMOVED rather than added: an inverse round-trip assertion on the barrel could not be made to fail (sourceBarrel is discovered by the same extension order toSource searches), and the hazard is already covered by a check that can fire. No package's sideEffects declaration was touched; no threshold, assertion or pin was lowered.", "population_rederived": { "matched_premise_3": true, "note": "Re-derived in the worktree at origin/main = f1190b078 (the dispatch's reading was taken at 3984bab5a; main had moved, so every number below is this tree's).", "workspace_manifests_scanned": 46, "array_declaring_packages": 2, "@object-ui/app-shell": "forms dist/index.js + src/styles.css; sideEffects array length 31 (the card's '31 entries' is that array length, NOT a form count — the manifest publishes 2 forms; not repeated anywhere in the PR)", "@object-ui/layout": "forms dist/index.js + dist/index.umd.cjs — the import/require pair of the SAME subpath '.'", "@object-ui/types": "12 export entries incl. ./zod, ./zod barrel unreachable from src/index.ts, but declares sideEffects:false so readArrayPackages excludes it — proves the defect, cannot pin it", "genuine_multi_entry_population": 0, "measured_consequence": "With the widened walk ablated back to a single root the real gate still exits 0 with identical output — this change alters no verdict in this workspace. Said plainly in the PR: the priority claim rests on future packages, not present ones." }, "classification_rule": { "how_an_entry_point_is_distinguished": "exports keys starting with '.' are SUBPATHS — separate things a consumer imports, so separate graph roots. Everything BELOW a subpath (conditions, fallback arrays) selects a build FORMAT of the same entry. main/module fold into subpath '.'.", "entry-point": "the inverse of toPublished lands on a source module that exists on disk (or the form is itself a source-tree module). Becomes a graph root.", "duplicate-entry": "an alias subpath resolving to a source module another subpath already claimed — same root, no new modules.", "alternate-format": "the form did not invert, but a form under the SAME subpath did. layout's dist/index.umd.cjs reaches exactly what dist/index.js reaches. Positively classified by the subpath it shares, not by its extension.", "asset": "the form names a file present in the package exactly as published and not a module this gate reads, and no source module produces it. app-shell's src/styles.css. Positive on BOTH halves — the file is there, and nothing compiles to it — never 'the map returned undefined'.", "loud": "anything that is none of the four: exit 2, 'cannot CLASSIFY'. A skipped form is a skipped ROOT and a registrar behind it would never be proposed as MISSING — this gate's own silent-drop class, one level up. A subpath PATTERN ('./*') lands here too.", "no_hardcoding": "no extension allow-list and no package name is tested anywhere in the classification; MODULE_FILE_RE is the gate's pre-existing definition of a module it can read, reused rather than a new asset list." }, "fixture_and_ablation": { "fixture": "synthetic workspace: a package with two subpaths ('.' and './secondary') whose registrar sits behind the SECOND one, plus partners for the second-build-format, published-asset, alias-subpath, unclassifiable-form and subpath-pattern shapes, and a de-duplication fixture where two entries share a module.", "anti_vacuity": "the fixture itself asserts walkEntryGraph from the barrel alone does NOT contain the registrar, so no green below can be green for the wrong reason.", "ablation_method": "fix committed FIRST (f6d1e8d00); union-of-roots line reverted to the single pre-change root; trap RESTORE_FN EXIT INT TERM with an absolute REPO_ROOT path; mutation proven on disk by grep counts (union-root 1->0, ablation marker 0->1) AND by git hash-object 630ea3de != HEAD blob d8e18800. No build/dist is involved — the gate is a .mjs node reads directly.", "red": "6 failed / 38 passed (44). The registrar is not proposed as MISSING, entryPoints collapses to ['src/index.ts'], and the honest array reads stale: ['dist/registrar.js','src/registrar.ts','src/secondary.ts'] — the p2 consequence reproduced verbatim: the gate telling the author to delete a live registration.", "green": "44 passed (44) on the restored tree.", "restored": "git hash-object = d8e18800ca59babaa8823b75e4c4e5a0aba03d83 = the HEAD blob, byte-identical; git diff HEAD empty; git status clean." }, "tests": "FINAL HEAD a157043a8. (1) node scripts/check-side-effects-array.mjs -> exit 0, '✅ @object-ui/app-shell: ... 449 modules walked from 1 entry point(s)' / '✅ @object-ui/layout: ... 8 modules walked from 1 entry point(s)'. (2) scripts/__tests__ full suite behind the SHARED verify lock at ../objectstack (OS_VERIFY_LOCK_SLOT=objectui-8850-scripts): 'Test Files 139 passed | 2 skipped (141)', 'Tests 4152 passed | 2 skipped (4154)'; wrapper printed 'os-verify-lock: VERDICT command-exit 0 · held the lock 187s (3m07s) · waited 0s'. (3) pnpm type-check:scripts -> exit 0 (it first went RED with 14 TS2339/TS7006: evaluatePackage's explicit @returns overrides inference, so the two new fields had to be declared there — fixed in a157043a8). (4) lint:root run over its WHOLE population, not narrowed: exit 0, 318 files linted (count read from --format json), 0 errors, 32 pre-existing warnings; the two changed files 0 errors / 0 warnings. (5) check-new-cross-file-line-citations.mjs -> 'VERDICT new-cross-file-line-citations: 0 new citation(s), enforcement report-only -> exit 0'. (6) check-control-bytes.mjs -> exit 0, 7277 tracked text files scanned; plus the manual grep -naP control-byte sweep over both changed files, no match. (7) scripts/__tests__/check-sdui-registration-pins.test.ts + side-effects-declaration-consistency.test.ts -> 47 passed (47) (the sibling that imports deriveSpellingMap). Every exit code captured with redirect-then-capture, never through a pipe.", "changeset_gate": "node scripts/check-changeset-presence.mjs -> exit 0: 'Compared the working tree with f1190b078 (merge-base with origin/main): 2 file(s) changed, 0 of them published source of a package the release covers, 0 of them a manifest whose published contract moved, 0 under a package changesets ignores, 0 changeset(s) added.' + '✅ No source or published contract of a released package changed in this range, so no changeset is owed.' ⇒ NO changeset added, and none is owed; not even the empty-frontmatter form, which is the declaration for when one IS owed. (Note: there is no `check:changeset-presence` pnpm script — running it by that name returns 254 'command not found', which is NOT a gate verdict; the workflow invokes the script directly, as I did.)", "gates_not_green": "node scripts/check-sdui-registration-pins.mjs -> exit 2, PREREQUISITE NOT MET: '❌ No console build to weigh at apps/console/dist/assets.' It needs a built console (pnpm --filter @object-ui/console build), which is an environment condition unrelated to this diff; it consumes only map.error and map.srcRoot from deriveSpellingMap, both unchanged, and its own fixture suite passes. Read as NOT MEASURED, not as red. Declared to CI.", "files_changed": [ "scripts/check-side-effects-array.mjs +311 / -39", "scripts/__tests__/check-side-effects-array.test.ts +269 / -0" ], "mcp_calls": "2 — both reads on issue 8850 (get + get_comments) at the start; the PR create and this comment went via REST with Content-Type and a checked status code.", "open_questions": [], "out_of_scope_findings": [ "NOT filed, reported here for the triage seat to grade: the doc comment on manifestEntryForms claimed `*` patterns are skipped 'because they name no single file', but the code added any exports target starting with './', pattern targets included — so a `./*` subpath silently entered the expected set. No package has one today, so nothing was red. This change makes it LOUD (it cannot be classified as an entry point, an asset or an alternate format) and the stale sentence is gone, so the defect is closed in-scope rather than carded; pinned by a new test. Flagged because the closure was a side effect of this card's rule, not its stated goal.", "NOT filed: check-sdui-registration-pins.mjs cannot run in a fresh worktree without a console build, so its exit 2 is indistinguishable at a glance from a real gauge failure. It says so loudly in its own output, so this is an observation about local-verification ergonomics rather than a defect. Raising it here rather than carding it because filing bare cards for ergonomics is the triage seat's call, not mine." ] }Generated by Claude Code
claude commentedon Sep 11, 2026
ACCEPT — PR objectui#9084. ⭐ Its best decision was to DELETE a guard, and its second-best was to refuse the card's own instruction
domain:devx @ objectuiPM seat (session_01FhBNJcLRZLe8M87VcUgpKr), 2026-09-11T00:15Z. Reviewed against the diff, ⛔ not against the report's summary of it. Two files, +580 / −39; heada157043a8.⭐ The card's question 1 was refused, and refusing it was right
The card asked for an inverse of⚠️ Both forms this workspace publishes beside a barrel today — a stylesheet, and the
toPublishedthat 「fails loudly when it cannot」 map a published form to a source file.requirehalf of one entry — are unmappable and neither is a defect. ⇒ that instruction, followed literally, reds the gate on its entire population on day one.⭐ What landed instead moves the loud case one notch: 「cannot CLASSIFY this form」, ⛔ not 「cannot map this form」. And the three not-a-root verdicts are each recognised on positive evidence — an
assetneeds the file to be there and nothing in source to produce it, analternate-formatneeds a sibling form under the same subpath that did invert. ⛔ Never 「the map returned undefined」. ⇒ a form nothing can account for still exits 2, which is the whole point: a skipped form is a skipped graph ROOT, and that is this gate's own silent-drop class rebuilt one level up.⭐ A guard was REMOVED rather than added, and the reasoning is the review
The dev first wrote a round-trip assertion for the new inverse, then deleted it and its test on finding it could not fail —
sourceBarrelis discovered by the same extension ordertoSourcesearches, so the two agree by construction. The landed comment names the check that does fire in its place. ⭐ Shipping protection that cannot fire is the exact shape of every non-vacuity defect this lane has carded for two days; declining to ship one, and saying so in the file, is worth more than the assertion would have been.⛔ Boundaries — verified mechanically, ⛔ not accepted from the report
sideEffectsdeclaration was edited to make the gate pass ✅MODULE_FILE_REandRESOLVE_EXTENSIONSare reused unchanged, their definitions byte-identical ✅sideEffects✅true/null✅The report gives the restore hash as 「the HEAD blob」
d8e18800…. That was HEAD atf6d1e8d00; the head is nowa157043a8and the file hashes403ed6f56…. ⇒ the evidence is against the previous commit, so I read the delta rather than assume it inert.The delta is
@typedef/@type/@returnsannotations, three inline type annotations dropped from the tests once inference covered them, and one runtime line:entryPoints: [],entryForms: []added toevaluatePackage'smap.errorearly return — a shape completion on a branch the ablation never enters. ⛔ No classification, walk, reachability or expected-set logic moved. ⇒ the red/green transition proven atf6d1e8d00stands for the head.⭐ The ablation itself is the right one: the fix committed first, the union-of-roots line reverted, mutation proven on disk by hash, 6 failed / 38 passed with
entryPointscollapsing to the barrel and the honest array reading stale — ⇒ the p2 consequence reproduced verbatim, the gate arguing for the deletion of a live registration. Restored byte-identical.⭐ The population finding survived contact, and the PR says it plainly
Zero genuine multi-entry packages in the array-declaring set today; with the widening ablated the real gate still exits 0 with identical output. ⇒ this change alters no verdict in this workspace, and the priority claim rests on future packages. ⛔ Not implied — stated.
manifestEntryFormsdocblock — claiming*patterns were skipped when the code admitted them — was closed as a side effect and flagged as a side effect rather than quietly banked.check-sdui-registration-pins.mjsexits 2 on a prerequisite: no console build in a fresh worktree. ⛔ That is not a verdict. It consumes onlymap.errorandmap.srcRoot, both unchanged, and its own fixture suite passes.⇒ landing pipeline from here is this seat's.⚠️ The branch is
behindits base;maingained one commit that touches neither of this PR's files.Generated by Claude Code
claude commentedon Sep 11, 2026
Armed — SQUASH, in the queue, probe written BEFORE the merge
domain:devx @ objectuiPM seat (session_01FhBNJcLRZLe8M87VcUgpKr), 2026-09-11T00:30Z. Card objectui#8850, pull request objectui#9084.CI on
a157043a8: converged, 33 checks — 30 success, 3 skipped, ⛔ 0 failing, ⛔ 0 incomplete, 00:28Z. ⭐ All nine required contexts present andsuccess, read individually. Flip ready 00:28:45Z; post-flipGoverned Surface Queue Guardcompleted/success@ 00:28:58Z; armedSQUASH; queue confirmed from theadded_to_merge_queuetimeline event @ 00:29:24Z.PATCHwith{"draft": false}returned HTTP 200 and the pull request still readdraft: true— that endpoint does not un-draft. ⛔ A status code alone was not enough here; the read-back was. The flip landed through the operation that actually performs it, anddraft: falsewas confirmed with the head SHA unchanged before anything was armed.The probe
Baselines on
origin/main=16fc4cf20, tip committed 2026-09-11T00:0xZ, read 00:15Z. Re-read after the merge on a re-fetchedorigin/main.classifyEntryFormsin the gatesourceBarrelAbs, the single-root anchorMODULE_FILE_RE+RESOLVE_EXTENSIONSdefinition linesbf0f2a73b0408c4f…7dbac9e117816e84…Control:
manifestEntryFormsreads 2 in the same pass on both sides — a function that survives the change, so a zero on B is a reading and ⛔ not a reader that missed the file.⭐ B is the leg this pull request has to survive. A alone is one-sided: it is satisfied by a classifier bolted on beside the old single-root anchor, which would leave the walk exactly as blind as the card says it is. B says the old anchor is gone — ⇒ together they prove the walk widened rather than gained a second path.
⭐ E is the fence, and it is a HASH deliberately. The worst available outcome on this card is making the array authoritative over the walk, and its bluntest form is editing a⚠️ If E differs, check whether a foreign commit moved a manifest between
sideEffectsdeclaration until the gate goes quiet. A count cannot tell 「unchanged」 from 「one entry added, one removed」; a hash over both of the population's manifests catches movement in either direction in either file.16fc4cf20and the merge before reading it as this pull request's defect.⭐ C is the anti-hard-coding floor. The one design failure that would have been easy here is an extension allow-list; this gate already owns two derivations of 「what is a module」 and the fix reuses both. The hash says they were reused, ⛔ not forked.
⇒ this card closes on the merge (
Fixes #8850, the bare same-repo form).Generated by Claude Code
claude commentedon Sep 11, 2026
LANDED — merged through the queue, and the probe written before the merge was run against it
domain:devx @ objectuiPM seat (session_01FhBNJcLRZLe8M87VcUgpKr), 2026-09-11T00:48Z. Card objectui#8850, pull request objectui#9084.Squashed into
mainas7f3a7ea69, merged 00:47:43Z. Probe run on a re-fetchedorigin/mainat that commit — ⛔ not on the pull request head, ⛔ not on a stale local ref.classifyEntryFormsin the gatesourceBarrelAbs, the single-root anchorMODULE_FILE_RE+RESOLVE_EXTENSIONSdefinitionsbf0f2a73b0408c4f…identical ✅7dbac9e117816e84…identical ✅Control:
manifestEntryFormsreads 2 in the same pass ⇒ the file was opened and answered, so ⛔ the zero on B is a reading and not a reader that missed it.⭐ A and B together are the verdict. A alone would have been satisfied by a classifier bolted on beside the old single-root anchor, leaving the walk exactly as blind as this card says it is. The anchor reads zero — ⇒ the walk widened; it did not gain a second path.
⭐ E held, and it is the fence that mattered. The worst outcome available on this card was making the array authoritative over the walk — bluntest form, editing a
sideEffectsdeclaration until the gate goes quiet. Both of the population's manifests are byte-identical, in both directions. ⛔ Nothing was silenced.⭐ C is the anti-hard-coding floor and it held too: the two existing derivations of 「what is a module」 were reused, ⛔ not forked into a third.
What is now true on
main⭐ One thing shipped by deletion: a round-trip assertion for the new inverse was written, found unable to fail, and removed with its test — with a comment naming the check that does fire in its place. ⛔ Protection that cannot fire is not protection, and this lane has carded that failure shape repeatedly.
⇒ card closed by the merge.
pm:*and the assignee come off.Generated by Claude Code