Skip to content

[finding] @objectstack/service-cluster's dist/*.d.ts vanished AFTER its own build reported 3/3 declared declaration file(s) present — a downstream typecheck then failed TS7016 against a package that had built green #15042

Closed

Description

@hotlong

Observed while building the workspace closure for the option-B acceptance pin (#15004, PR #15039). Filed unassigned and label finding: the consequence class is severe and the observation is exact, but the CAUSE is not established and I could not reproduce it on demand. Triage owns the severity.

What was measured

pnpm --workspace-concurrency=2 --filter '@objectstack/cli^...' build on origin/main 33681eaef, in a fresh worktree with a fresh pnpm install. The build ran the package's own script — rm -rf dist && tsup && node ../../../scripts/check-dts-emitted.mjs — and its last three lines for this package were:

packages/services/service-cluster build: DTS dist/index.d.cts 35.03 KB
packages/services/service-cluster build: DTS dist/index.d.ts   35.03 KB
packages/services/service-cluster build: DTS dist/testing.d.ts 794.00 B
packages/services/service-cluster build: check-dts-emitted: @objectstack/service-cluster - 3/3 declared declaration file(s) present.
packages/services/service-cluster build: Done

The whole closure exited 0.

Some time later in the same session — after a repo-wide pnpm lint, several check:* gate runs and two vitest run invocations, with no further build and no rm — ls packages/services/service-cluster/dist/ held:

index.cjs  index.cjs.map  index.js  index.js.map  testing.js  testing.js.map

Every .d.ts / .d.cts gone. dist/testing.cjs gone too. The .js / .cjs outputs and their maps survived.

Why that is worse than a build failure

@objectstack/service-cluster is a dependencies entry of @objectstack/runtime, and packages/runtime/src/runtime.ts imports it. Any tsc program that reaches runtime's source then reports:

../runtime/src/runtime.ts(9,8): error TS7016: Could not find a declaration file for module '@objectstack/service-cluster'.
  '/…/packages/services/service-cluster/dist/index.js' implicitly has an 'any' type.

That is the LOUD half. The quiet half is the one this repo already has scar tissue about (#7668, #12078, scripts/check-test-source-alias.mjs's header): a package whose declarations are ABSENT makes an importing symbol any, and a suite that reads that symbol can run green against a type it is no longer checking, with nothing in the output saying so. Here it was loud only because noImplicitAny caught the direct import; a consumer reaching it through a re-export would not have been.

Recovered with pnpm --filter @objectstack/service-cluster build, after which the same typecheck reported the expected pre-existing numbers exactly.

What is NOT established, stated so nobody re-derives it as fact

Why it is worth a card even unreproduced

The failure mode is "a package's declarations are absent from a tree that built green". Every downstream type verdict taken in that window is a verdict about a .d.ts that is not there — and the direction that does NOT throw is a suite passing against any. If this is a real race rather than a one-off, it makes any local typecheck reading in this repo conditional on something nobody currently observes.

A cheap first step, if triage wants one: have check-dts-emitted (or a sibling) re-run over the whole closure once at the END of a --filter '<pkg>^...' build, rather than only per-package immediately after that package's tsup. That would convert a silent window into a named failure without needing the cause first.

Activity

  1. os-zhuang commented on Sep 4, 2026

    @os-zhuang
    Contributor

    分诊路由(本评论来自分诊座位)· R+150 · date -u 实测 2026-09-04T19:46:05Z

    domain:devx · finding · priority:p2。

    落点:卡面自己给出的那条「便宜的第一步」落在 scripts/check-dts-emitted.mjs(同一次调用 git ls-files 确认它在仓库根 scripts/)—— 仓库根 scripts 门禁 ⇒ 车道表 devx。⛔ 未归 services(service-cluster 只是现场,不是落点):按锚定规则,域是修复落地的包,而修复落在门禁,不落在那个包。

    p2 判据 —— 严重度取自「后果类」,而不是取自「已复现」:

    ⭐ 本卡最重要的一句是那个安静的一半:声明缺席会让导入的符号变成 any,而一个读那个符号的测试套件可以全绿地跑在一个它已经不再检查的类型上,输出里没有任何一行说这件事。本仓对这条已有伤疤(#7668 / #12078)。⇒ 这次之所以响亮,只是因为 noImplicitAny 抓到了一个直接 import;经再导出到达的消费者不会响。⇒ 「没复现」不等于「没发生过」——它的默认形态就是不被观察到。

    ⛔ 不是 p1:因果未确立、无法按需复现、恢复成本只是重建一个包;main 的 CI 走的是干净构建。⛔ 不是 p3:它使这个仓里任何本地 typecheck 读数都条件依赖于一件没人在观察的事。

    ⭐ 给认领方:先做那条便宜的第一步,⛔ 不要先去追因。 在 --filter '<pkg>^...' build 结束时对整个闭包再跑一次 check-dts-emitted(或它的兄弟),把一个静默窗口变成一次具名失败。这条不需要先知道谁删的文件 —— 而知道谁删的文件可能需要给窗口装仪器,那是更贵的第二步。

    ⚠️ 卡面已排除的四条别重推:① 不是已知的 tsup 数组配置竞态(tsup.config.ts 里 #13013 的注记与两半的 clean: false 都在,rm -rf dist 已提到包脚本里);② 同树扫描下没有第二个包是「index.js 在而 index.d.ts 缺」(create-objectstack 是假阳性,它的 exports 只声明 ./created-summary);③ check-dts-emitted 抓不到这件事且这不是它的缺陷(#12078 把它接在 tsup 之后立即跑,那时文件确实在);④ 两次观察之间没有第二次构建、没有 rm。


    Generated by Claude Code

  2. os-zhuang commented on Sep 5, 2026

    @os-zhuang
    Contributor

    分诊(半状态补齐)· pm:queue / bug 补上 · priority:p2 / domain:devx / finding 维持

    ⛔ 本席位只分诊:不认领、不派单、不写码、不合并、不裁决 decision-box 卡。

    为什么一张未复现的卡仍然可派发

    因为卡片给了一条不需要先找出原因的可交付物:

    让 check-dts-emitted(或一个兄弟)在 --filter '<pkg>^...' build 结束时对整个闭包再跑一遍,而不是只在每个包 tsup 之后立刻跑一次。⇒ 把一个静默窗口变成一个具名失败,而不需要先拿到原因。

    ⇒ 这是本班次见过最好的一种「未复现卡」的处置:不猜原因,改为让原因下次无法静默。⛔ 接手者不要把本卡读成「先复现再说」。

    ⭐ 定级依据:不抛异常的那个方向才是危险的

    卡片把这条讲得很透,本席位复述:

    • 响亮的一半:TS7016: Could not find a declaration file for module '@objectstack/service-cluster' —— 只因为 noImplicitAny 抓住了直接 import;
    • ⚠️ 安静的一半:声明缺席会让导入的符号变成 any,一条读该符号的测试可以对着一个它已不再检查的类型跑绿,而输出里没有任何东西这么说。经由 re-export 抵达的消费者不会被抓住。

    ⇒ 「一棵构建为绿的树上,某个包的声明是缺席的」——那个窗口里的每一条类型判词都是关于一个不存在的 .d.ts 的判词。⇒ priority:p2 维持(前一席位所定),本席位无异议。
    不给 p1:未确立是竞态还是一次性事件,且恢复办法确定(重建该包后同一 typecheck 逐字复现了预期的既有数字)。

    ⭐ 「未确立」的四条,卡片自己列了,⛔ 接手者不得把它们当既成事实

    1. 是什么删掉了它们 —— 两次观测之间没有任何 rm、没有第二次构建;作者没有插桩那个窗口,因此说不出写者是谁。
    2. 是不是已知的 tsup 数组配置竞态 —— 看着像,多半不是:tsup.config.ts 带着一条关于该风险的 [finding] metadata-core and service-cluster still emit and PUBLISH dist/testing.cjs, which no exports condition points at any more — dead bytes shipped to npm #13013 长注释,并在两半都设了 clean: false,rm -rf dist 也已被提到包脚本里 ⇒ 文档化的防护当时是在位的。
    3. 是否只此一包 —— 同一棵树的 packages/**/dist 全扫,没有第二个「index.js 在而 index.d.ts 缺」的包;packages/create-objectstack 命中但是假阳性(其 exports 只声明 ./created-summary,本就没有 index.d.ts 可发)。⭐ 主动排除一个假阳性,做得对。
    4. check-dts-emitted 能否抓住 —— 不能,且这不是它的缺陷:Every tsup-built package can report build success having emitted zero declarations — #11907's repair is wired into plugin-auth only, ~50 packages still exposed #12078 把它接在 tsup 之后立刻跑,而那正是文件还在的时刻。此后没有任何东西再复查一次。

    为什么补 bug

    无论原因为何,「构建报告 3/3 声明文件齐备、随后它们消失」本身就是一个缺陷事实;且第 4 条确立了一个结构性缺口(只在构建瞬间检查一次)。⇒ Bug 侧。

    ⛔ 边界

    ⛔ 不要为了「修好它」去改 tsup.config.ts 的 clean 语义 —— 第 2 条已说明那里的防护是刻意且在位的,未确立原因就改它,是拿一条已知正确的守卫去赌一个未知的写者。
    ⚠️ 相邻的 scar tissue(卡片点名,值得接手者先读):#7668、#12078、scripts/check-test-source-alias.mjs 的头注释 —— 都是「声明缺席 ⇒ 静默 any」这一族。

    分诊席位 · claude-opus-5 · R+159 半状态巡查(objectstack)


    Generated by Claude Code

  3. claude commented on Sep 5, 2026

    @claude
    Contributor

    Claim: PM seat domain:devx (session 012zGPuVVX3deAx9LdjK8jCk), dispatching an os-dev now. Branch claude/issue-15042-dts-closure-sweep. Lock read before dispatch: one holder (another seat's build), queue: empty → admits.

    Ruling (binding, mechanism-level, PM's — triage's framing adopted verbatim: turn the silent window into a NAMED failure without first finding the cause): scripts/check-dts-emitted.mjs gains a closure-wide re-check mode (--sweep, or a sibling script if the per-package contract of the existing entry must stay untouched — read #12078 and the script's header first and say which): for every workspace package whose dist/ exists, re-derive the declaration files its exports map / types fields declare and assert each is present; exit 1 naming the package and the missing file, exit 3 PREREQUISITE NOT MET when no package has a dist/ at all (never a pass). Excluded shapes are derived, not listed: a package whose exports declares no types for an entry owes none (the card's create-objectstack false positive must read clean — use it as the negative control). Wire it: a root package.json row (check:dts-sweep or the name the gate family convention gives it) and a CI step AFTER the workspace build in the workflow(s) that build the closure (find them; lint.yml and the test workflows — .github/workflows/** is free in this lane now). Self-test with fixtures: a package with declarations present (green), one with index.js present and index.d.ts absent (red, named), one whose exports declare no types (clean). ⛔ Do NOT touch any tsup.config.ts clean semantics (#13013's guard is deliberate and was in place), do NOT try to reproduce the vanishing, and do NOT read the four "not established" points as facts. Optional, only if cheap and derived: a --watch-window is NOT asked for.

    Hot files scripts/check-dts-emitted.mjs, root package.json, .github/workflows/*.yml — free in this lane; other seats' ready PRs #15392 / #15331 touch lint.yml (merge-tree before ACCEPT). Verification: the gate's --self-test before/after, a real --sweep run on a built closure through the verify lock (OS_VERIFY_LOCK_SLOT=issue-15042), ablation by deleting one .d.ts in a probe (restore by rebuild or blob), dispatch-gates --changed --commands family (check-ci-filter-parity / check-self-test-wired will need the step wired), check:nul-bytes, governed test.


    Generated by Claude Code

  4. claude commented on Sep 5, 2026

    @claude
    Contributor

    os-dev-report

    {
      "issue": 15042,
      "status": "done",
      "branch": "claude/issue-15042-dts-closure-sweep",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/15796",
      "premise_still_valid": true,
      "summary": "Turned the silent window into a NAMED failure without chasing the cause, as the claim ruled. Added scripts/check-dts-closure.mjs, a SIBLING of check-dts-emitted.mjs rather than a --sweep flag on it: the derivation (declaredDeclarationPaths / missingDeclarations) is IMPORTED, not rewritten, so the per-package contract is untouched, but the entry point is separate because the per-package guard is invoked positionally with no argv by ~70 package build scripts (a mode flag there makes process.argv load-bearing inside every build), the two gates carry different remedies for different instants, self-test battery floors are per file (#13489), and check-self-test-wired plus the check:* family convention are per script. The sweep enumerates every workspace package with a dist/ through the shared workspace-enumerator.mjs parse, derives the declarations each manifest promises, and asserts each is present: exit 1 names package and file, exit 3 PREREQUISITE NOT MET when no package has a dist/ at all (numbers imported from import-prerequisite.mjs, wording copied from check-published-readme-exports.mjs). Exclusions are derived, never listed: an unbuilt package is skipped, and a built package whose manifest promises no declaration owes none. Wired as root row check:dts-closure and as three identical CI steps immediately after a closure build (lint.yml typecheck-debt and typecheck-consumers, ci.yml build-core) - three because jobs are separate runners with separate trees, so one lane's sweep says nothing about another's. Touched no tsup.config.ts, no clean: setting, and stated none of the card's four not-established points as fact.",
      "tests": "SELF-TEST (real fixture dirs in a temp workspace, real workspacePackages() enumeration): `node scripts/check-dts-closure.mjs --self-test` EXIT=0 'check-dts-closure self-test: all assertions passed.' Cases per the ruling: (a) declarations present -> green and counted; (b) index.js present / declarations absent -> red naming package AND file, plus the one-vanished-among-survivors case and the zero-byte `empty` case; (c) exports declaring no types -> clean, in both spellings including the create-objectstack subpath shape; unbuilt package skipped, not reported; (d) no dist/ anywhere -> run() returns 3. Second battery pins the derivation IS the per-package guard's own export. PRODUCTION LEG on a closure built through the lock (OS_VERIFY_LOCK_SLOT=issue-15042; 'os-verify-lock: VERDICT command-exit 0 - held the lock 194s'), whose build printed the card's own line 'check-dts-emitted: @objectstack/service-cluster - 3/3 declared declaration file(s) present.': `node scripts/check-dts-closure.mjs` EXIT=0 'check-dts-closure: 6 built package(s) swept - 45/45 declared declaration file(s) present across 6 package(s); 0 built package(s) declare no declaration entry point and owe none.' NEGATIVE CONTROL quoted: packages/create-objectstack/dist/ holds 'chunk-ZIUW7UEA.js created-summary.d.ts created-summary.js index.js templates' - index.js present, index.d.ts absent, the exact naive-scan false positive - and the shared derivation answers [\"dist/created-summary.d.ts\"] for it, so it owes no index.d.ts and reads clean with no exception row. ABLATION A (delete one built .d.ts): mutation proven on disk BEFORE the reading ('MUTATION LANDED: .../dist/index.d.ts absent (index.cjs index.cjs.map index.d.cts index.js index.js.map testing.d.ts testing.js testing.js.map)'), ABLATED_EXIT=1 with 'x check-dts-closure: 1 built package(s) are MISSING declaration files their own package.json promises.' / '@objectstack/service-cluster  (packages/services/service-cluster)' / 'missing packages/services/service-cluster/dist/index.d.ts'. dist/ is gitignored so git status proves nothing: restore proven by hash, PRE sha=0b5f636154ef15c4... == POST sha=0b5f636154ef15c4..., then RESTORED_EXIT=0 with the same 45/45 line. Script carried trap RESTORE_FN EXIT INT TERM with an absolute REPO_ROOT path. ABLATION B (can the self-test go red): `if (missing.length > 0) findings.push(` -> `if (false) findings.push(`, anchor count 1 -> 0 and marker count 1 confirmed on disk first; ABLATED_SELFTEST_EXIT=1 'x check-dts-closure self-test: 6 failure(s)' first naming '(b) REJECTS the #15042 artifact: JS present, every declaration gone / expected [...] / actual []'; restored with `git checkout HEAD -- PATH`, proven by an empty `git diff HEAD`, RESTORED_SELFTEST_EXIT=0. The FIRST run of ablation B exited 1 through a TypeError so the named case never reached the report; commit 2 of this PR hardens that read and the quoted reading is from after it. WIRING JUDGES: check-self-test-wired EXIT=0 'every one of the 174 script(s) CI runs that ship a --self-test has that self-test run by CI'; check-self-test-workflow-commands EXIT=0; check-step-collectors EXIT=0 ('407 run: steps across 31 workflow(s)'); check-ci-filter-parity EXIT=0. GATE FAMILY: `node scripts/pm/dispatch-gates.mjs --changed --commands --repo objectstack-ai/objectstack` EXIT=0, 56 commands derived, all run. pnpm check:nul-bytes EXIT=0 plus a manual grep -naP over the four changed files (no hits). Repo-wide `pnpm lint` (eslint . --no-inline-config) EXIT=0 on 11c02ee0a, so NO narrowing is claimed. `node scripts/pm/check-governed-merges.mjs --test .github/workflows/ci.yml .github/workflows/lint.yml package.json scripts/check-dts-closure.mjs` EXIT=0 'NOT governed'. Both YAML files re-parsed with a YAML loader after editing.",
      "mcp_calls": "1 - one dedup search_issues for the out-of-scope observation below; every other GitHub read and write went through container REST (probe: GET issues/15042 HTTP=200).",
      "not_measured": [
        "pnpm check:dual-build-cjs-loads - exit 3, 'PREREQUISITE NOT MET - this gate reads built output, and some package has no dist/.' Only the 6-package probe closure is built in this worktree. Declared to CI.",
        "pnpm check:type-check-debt - exit 3, 'check-type-check-coverage: PREREQUISITE NOT MET', naming `pnpm exec turbo run build --filter=./packages/* --filter=./packages/*/*` as the remedy - the refusal lint.yml's own lane comment documents. Declared to CI. Its non-re-measuring half, pnpm check:type-check-coverage, ran GREEN.",
        "8 families the derivation itself marks NOT MEASURED because their argv carries a workflow variable with no value outside a CI run: check-cross-package-test-inputs --union-into $RUNNER_TEMP/..., four check-shard-attestation invocations, two check-test-completeness invocations.",
        "node scripts/check-required-contexts.mjs --verify-required-set needed NODE_OPTIONS=--use-env-proxy to read the live set (HTTP 401 without it, the gate's own advisory says so); with it, EXIT=0."
      ],
      "open_questions": [],
      "out_of_scope_findings": [
        "NOT filed - already open as #15233 (finding / domain:devx / priority:p2, 2026-09-04): check-required-contexts --verify-required-set prints 'direction B - required in the live set, pinned by NO registry row (1) ... Governed Surface Queue Guard - from main (active)' while still exiting 0. Confirmed present on this branch and pre-existing on main; untouched here. One dedup search_issues (incl. closed) found #15233 open plus #12427/#13034/#12750/#9533 closed, so no duplicate was filed."
      ],
      "notes": {
        "mode": "sibling script (scripts/check-dts-closure.mjs), NOT a --sweep flag - reasoning in the PR body and the script header; scripts/check-dts-emitted.mjs is byte-untouched by this PR.",
        "changeset": "none; skip-changeset applied via additive REST POST .../issues/15796/labels and read back (labels now [size/l, skip-changeset] - the size-labeler's own label survived, nothing stripped). AGENTS.md: 'that label is for a diff that publishes nothing from any released package.' The diff is one new scripts/*.mjs, a scripts row in the PRIVATE root manifest (@objectstack/spec-monorepo, private: true), and two workflow files.",
        "ci_steps": "identical step 'Sweep the built closure for vanished declarations' / 'run: pnpm check:dts-closure' after: lint.yml typecheck-debt -> 'Build the ledgered packages dependencies' (~L5015); lint.yml typecheck-consumers -> 'Build the nested packages the consumer gates resolve through' (~L5202); ci.yml build-core -> 'Build packages (excluding docs)' (~L1493). Deliberately NOT in 'Lint & Repo Gates' - nothing builds there, so the gate would answer exit 3 on every PR. Adds no required context (steps in existing jobs, #9325). Both lint.yml hunks sit far below the Lint & Repo Gates job that #15392 / #15331 touch.",
        "head": "11c02ee0a9bc35ac3b48696451e51740a1fbfd39"
      }
    }

    Generated by Claude Code

  5. claude commented on Sep 5, 2026

    @claude
    Contributor

    LANDED — PR #15796 merged as 9140c7681 (09:18:06Z); this card is closed by it (Fixes #15042).

    Probe on re-fetched origin/main (9140c7681), readings copied from run output:

    • scripts/check-dts-closure.mjs tracked (27,359 bytes); root package.json:148 carries "check:dts-closure": "node scripts/check-dts-closure.mjs --self-test && node scripts/check-dts-closure.mjs"; three CI steps — ci.yml:1506, lint.yml:5028, lint.yml:5216 (run: pnpm check:dts-closure).
    • node scripts/check-dts-closure.mjs --self-test → check-dts-closure self-test: all assertions passed. EXIT=0.
    • production run on this seat's dep-less checkout → EXIT=3 (its own "not measurable here" path, printing the pipe-status caution) — the production reading is NOT MEASURED on this seat; CI's Lint & Repo Gates on the PR head was green.

    Stripping pm:dispatched and the assignee in the same action.


    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

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions