Skip to content

Fix: declare @types/express in dev, and gate the published type closure in CI - #382

Open
AmaadMartin wants to merge 3 commits into
fix/core-types-express-runtime-depfrom
fix/publish-public-type-deps
Open

Fix: declare @types/express in dev, and gate the published type closure in CI#382
AmaadMartin wants to merge 3 commits into
fix/core-types-express-runtime-depfrom
fix/publish-public-type-deps

Conversation

@AmaadMartin

@AmaadMartin AmaadMartin commented Jul 31, 2026

Copy link
Copy Markdown
Owner

Please ensure you have read the contribution guide before creating a pull request.

Link to Issue or Description of Change

  1. Link to an existing issue (if applicable):
    Closes: #issue_number
    Related: #issue_number
  2. Or, if no issue exists, describe the change:

Stacked on #292 (fix/core-types-express-runtime-dep), which is itself stacked on #273. Review/merge those first; this PR's base is #292's branch, so the diff below is only the 4 files this change adds on top. See Collision check at the end for why it is stacked rather than branched from main.

Problem: a package's published .d.ts files can reference a module the package does not declare in its dependencies. Those declarations ship to consumers, but a devDependency is never installed for them, so the reference dangles. Two independent halves:

  1. @google/adk-devtools does not declare @types/express. AdkApiServer exposes the public member readonly app: express.Application (dev/src/server/adk_api_server.ts:78) and is exported from dev/src/index.ts, so dev/dist/types/server/adk_api_server.d.ts emits import express from 'express'. express@4 ships no bundled declarations, and @types/express sat in dev/package.json's devDependencies.

  2. Nothing in CI could catch this class of defect, which is why it survived. Three independent reasons, each of which the new gate has to defeat:

    • skipLibCheck: true. The root tsconfig.json sets it. With it on, an unresolvable module inside a dependency's .d.ts does not error — it degrades silently to any.
    • npm workspace hoisting. core/node_modules does not exist; every workspace devDependency is hoisted to the repo-root node_modules. Anything type-checked from a path inside the repo (npm run ts:check, npm run docs:check, the tests/integration/build_setup fixtures) walks up into that directory and resolves types a consumer never receives.
    • file: directory installs. The build_setup fixtures depend on "@google/adk": "file:../../../../core", which links the workspace directory instead of exercising the packed tarball's declared closure.

    It is also invisible to an import-graph linter such as eslint-plugin-import or knip: TypeScript resolves @types/* automatically from node_modules/@types, so there is no import ... from '@types/express' statement anywhere to flag. (Feat: fail the build on phantom dependencies in the published src trees (import/no-extraneous-dependencies) #323 and Feat: add a dependency-hygiene gate (npm run deps:check) to CI #250 add exactly those linters for the runtime import graph; they are complementary to this, not overlapping — see Collision check.)

Solution: two commits.

  • fix(dev) — move @types/express from devDependencies to dependencies in dev/package.json, keeping the existing ^4.17.21 range untouched. A package must type its own public API from its own declared closure, not from whatever a sibling workspace happens to hoist.
  • feat(ci) — add npm run check:published-types (scripts/check_published_types.mjs) and a new published-types job in .github/workflows/validation.yaml. The script packs every publishable workspace with npm pack, installs the tarballs into a throwaway project, type-checks it, and fails on TS2307 / TS7016 anywhere in the output. Every other diagnostic is ignored, so an unrelated upstream type error inside some dependency's .d.ts cannot redden the job.

Three properties of the script are load-bearing, and map one-to-one onto the three reasons above. They are documented in the file header because a future refactor will otherwise "simplify" them away:

Property Defeats
Scratch project created with fs.mkdtemp under os.tmpdir(), never inside the repo hoisting into the repo-root node_modules
Installs npm pack tarballs, not file: links file: directory installs
"skipLibCheck": false in the probe tsconfig silent degradation to any

Each is verified by a mutation below, including a deliberate check that flipping skipLibCheck to true makes the gate wrongly pass.

Smaller decisions worth stating so a reviewer does not have to ask:

  • A new job, not a step in the run-tests matrix. That matrix runs on three operating systems; this check needs one network install of the full closure and only needs proving once. A parallel job costs no extra wall clock (measured ~1m31s end to end locally). This was challenged in review as 16 lines of duplicated Checkout/Use Node.js/npm install/npm run build; I kept the separate job and the reasoning is under Review follow-ups below. Happy to fold it in if a maintainer disagrees — it is a four-line edit either way.
  • --legacy-peer-deps on the probe install is deliberate: core declares five @mikro-orm/* drivers as non-optional peerDependencies. Which database driver a consumer picks is not part of the dependencies closure this gate is about.
  • @types/node is treated as the probe's toolchain, not a package dependency. Published declarations do reference Node globals (Buffer in core/dist/types/skills/loader.d.ts, node:child_process in dev), but every Node TypeScript consumer already installs @types/node, and pinning its major from a library is a known source of consumer conflicts. The probe installs typescript and @types/node at the ranges read from the root package.json devDependencies, so the probe's toolchain cannot drift from the repo's. (Whether core should declare @types/node is Fix: declare @types/node so the published declarations can resolve Buffer #327's question, and is unaffected either way by this gate.)
  • @types/adm-zip, @types/lodash-es, @types/cors and root-level @types/js-yaml deliberately stay in devDependencies. None is reachable from a public signature: adm-zip is used only inside function bodies in core/src/skills/loader.ts and only by dev/build.js in dev, lodash's cloneDeep/isEmpty are only ever called and never named in a type position, yaml.load() returns unknown, and cors is used as middleware only. The gate is green with all four left where they are, which is the check confirming the audit rather than my asserting it.
  • @google/adk-devtools also references @google/genai and @opentelemetry/api from its public .d.ts without declaring them at all. Those resolve transitively through @google/adk, so the gate stays green. Fixing them means adding new direct dependencies (a version-range decision, and @opentelemetry/api is singleton-sensitive), which is a different operation from moving an existing entry — deliberately left out of this PR. Feat: add a dependency-hygiene gate (npm run deps:check) to CI #250 and Feat: fail the build on phantom dependencies in the published src trees (import/no-extraneous-dependencies) #323 both propose exactly those additions.

Honest limitation, stated up front. The gate reports the verdict a consumer sees, not per-package declaration hygiene. All packages are installed side by side, so hoisting lets one workspace's declared dependency satisfy a sibling's undeclared import. Concretely: once @google/adk declares @types/express (#292), reverting the dev/package.json half of this PR still passes — mutation 3 below measures exactly that. The dev change is therefore justified as declaration hygiene, not as something this gate enforces; the limitation is written into the script's header comment so nobody later mistakes a green run for per-package proof. I looked for a way to demonstrate the dev defect observably under a non-hoisting install strategy and could not produce one in a reproducible environment, so I am not claiming one.

Not a breaking change. Moving an entry from devDependencies to dependencies only ever adds packages to a consumer's tree. Two trade-offs:

  • A consumer already on @types/express@5 would now also see @types/express@4 nested under @google/adk-devtools. This introduces no new class of conflict: express@^4.21.2 is already a hard runtime dependency of @google/adk-devtools, so that consumer already has express 4 in their tree. The alternative — a peerDependency — would force every consumer to install it by hand for a transitive implementation detail.
  • Install size grows by declaration-only packages. That is the cost of publishing types that reference express.

Lockfile. The package-lock.json delta is 3 lines: the dev manifest move, plus one incidental correction npm install makes unconditionally — the hoisted adm-zip entry loses a stale "dev": true, which it earns from core listing adm-zip as a runtime dependency. I confirmed that line appears from a bare npm install on the untouched base branch before making any edit, so it is not avoidable when regenerating the lockfile. It is the same stale flag #183 fixes on its own. No versions change and nothing is added to or removed from the tree.

Testing Plan

Please describe the tests that you ran to verify your changes. This is required for all PRs that are not small documentation or typo fixes.

Unit Tests:
[ ] I have added or updated unit tests for my change.
[x] All unit tests pass locally.

No new vitest unit test, and that is a decision rather than an omission. The new code is a CI orchestration script under scripts/, which is outside vitest.config.ts's coverage include (core/src/**, dev/src/**, integrations/src/**), so it moves no coverage threshold. More importantly the repo does not set allowJs, so a *_test.ts importing scripts/check_published_types.mjs breaks npm run ts:check with an unresolvable-declaration error — and the only ways out of that are a suppression or an any, neither of which is acceptable. No file under core/src, dev/src or integrations/src is modified, so no existing test needed to change, and none was changed or deleted.

The script's correctness is established by the mutation matrix below, which is a stronger claim than a unit test of a regex: the gate is observed failing on a broken tree and passing on a fixed one.

Mutation proof — every property of the gate, run against a mutated tree. A check that has never been observed failing is not a check.

# Mutation Result
0 none (this branch as submitted) exit 0Published type closure resolves for: @google/adk, @google/adk-devtools, @google/adk-integrations.
1 Revert #292's core/package.json hunk (@types/express + openapi-types back to devDependencies), npm install, re-run exit 1, 6 × TS2307 for openapi-types
2 Revert both the core and the dev hunks, npm install, re-run exit 1, 9 diagnostics — the 6 above plus 3 × TS7016 for express
3 Restore core, revert only the dev hunk, npm install, re-run exit 0 — the masking limitation described above, reproduced
4 Both hunks reverted and the probe tsconfig flipped to "skipLibCheck": true exit 0 — the gate wrongly passes, pinning the one option the whole check hinges on
5 mv core/dist/types/index.d.ts aside, re-run on the fixed tree exit 1, Error: core/dist/types/index.d.ts not found; run \npm run build` first.` — a forgotten build cannot masquerade as a pass, and is distinguishable from a real failure

Full output of mutation 2, the worst case:

Published declarations reference modules the packages do not declare:
  node_modules/@a2a-js/sdk/dist/server/express/index.d.ts(1,71): error TS7016: Could not find a declaration file for module 'express'. '<scratch>/node_modules/express/index.js' implicitly has an 'any' type.
  node_modules/@google/adk-devtools/dist/types/server/adk_api_server.d.ts(8,21): error TS7016: Could not find a declaration file for module 'express'. '<scratch>/node_modules/express/index.js' implicitly has an 'any' type.
  node_modules/@google/adk/dist/types/a2a/agent_to_a2a.d.ts(8,21): error TS7016: Could not find a declaration file for module 'express'. '<scratch>/node_modules/express/index.js' implicitly has an 'any' type.
  node_modules/@google/adk/dist/types/auth/auth_schemes.d.ts(6,27): error TS2307: Cannot find module 'openapi-types' or its corresponding type declarations.
  node_modules/@google/adk/dist/types/tools/openapi_tool/openapi_spec_parser/openapi_spec_parser.d.ts(6,27): error TS2307: Cannot find module 'openapi-types' or its corresponding type declarations.
  node_modules/@google/adk/dist/types/tools/openapi_tool/openapi_spec_parser/operation_parser.d.ts(6,27): error TS2307: Cannot find module 'openapi-types' or its corresponding type declarations.
  node_modules/@google/adk/dist/types/tools/openapi_tool/openapi_spec_parser/tool_auth_handler.d.ts(6,27): error TS2307: Cannot find module 'openapi-types' or its corresponding type declarations.
  node_modules/@google/adk/dist/types/tools/openapi_tool/openapi_toolset.d.ts(6,27): error TS2307: Cannot find module 'openapi-types' or its corresponding type declarations.
  node_modules/@google/adk/dist/types/tools/openapi_tool/rest_api_tool.d.ts(7,27): error TS2307: Cannot find module 'openapi-types' or its corresponding type declarations.
Move each missing package into the "dependencies" of the workspace owning the dist/types file above.

The @google/adk-devtools line is the dev defect this PR fixes, caught by the gate only because core's declaration is missing at the same time — mutation 3 is the same line disappearing behind hoisting.

Existing suites re-run, both unchanged by this PR:

npx vitest run --project integration tests/integration/dependency_resolution/dependency_resolution_test.ts   # 4 passed
npm run docs:check                                                                                          # ok

npx vitest run --project integration -t "Build setup" fails in my sandbox and I could not use it as a signal. It fails in beforeAll with Hook timed out in 10000ms at execAsync('npm install'); I timed that same install directly at 68.8s against vitest's default 10s hook timeout, so it is the environment's slow registry, not this change — the same pre-existing flake #117, #247 and #260 target. Nothing in this diff touches those fixtures or the packages they install.

Manual End-to-End (E2E) Tests:
Please provide instructions on how to manually test your changes, including any necessary setup or configuration.

The real proof is a genuine consumer install with no mocks, exactly what a user does:

npm install && npm run build
(cd core && npm pack --pack-destination /tmp)

mkdir -p /tmp/adk-manual/src && cd /tmp/adk-manual
printf '{"name":"probe","private":true,"type":"module","version":"0.0.0"}' > package.json
cat > tsconfig.json <<'EOF'
{
  "compilerOptions": {
    "target": "ES2022", "module": "nodenext", "moduleResolution": "nodenext",
    "strict": true, "noEmit": true, "skipLibCheck": false, "types": ["node"]
  },
  "include": ["src/**/*.ts"]
}
EOF
printf "import * as adk from '@google/adk';\nexport type Probe = typeof adk;\n" > src/probe.ts

npm install /tmp/google-adk-1.4.0.tgz typescript@5.9.2 @types/node@20 --legacy-peer-deps
npx tsc --noEmit          # observed: no output, exit 0

Then the user-visible half — proving the type is real rather than any, under skipLibCheck: true, the setting that hid the bug:

// src/bogus.ts
import {LlmAgent, toA2a} from '@google/adk';
const agent = new LlmAgent({name: 'p', model: 'gemini-2.0-flash'});
const app = await toA2a(agent, {allowUnauthenticated: true});
app.totallyBogusMethod();
$ npx tsc --noEmit --skipLibCheck
src/bogus.ts(4,5): error TS2339: Property 'totallyBogusMethod' does not exist on type 'Application'.

Before the fix this file compiled clean, because toA2a returned Promise<any> for every consumer. That diagnostic is the clearest single piece of evidence the defect was real and is gone.

CI status: absent, validated locally instead. GitHub Actions triggers on pull_request: branches: [main], and this PR's base is #292's branch, so no workflow will run on it — including, ironically, the published-types job it adds. The full gate was therefore run locally on the exact pushed commit:

npm ci                        # ok (lockfile is a fixed point)
npm run build                 # ok
npm run check:published-types # ok  <- the new job
npm run lint                  # ok
npm run format:check          # All matched files use Prettier code style!
npm run docs:check            # ok

Per repository guidance only the targeted test files above were run, not the whole suite.

Collision check. Ran gh pr list --state open --limit 300 (286 open PRs) and diffed every plausibly adjacent one: #273, #292, #327, #323, #360, #347, #250, #249, #244, #290, #277.

Review follow-ups. A complexity review raised six findings; five are applied in refactor(ci): trim the published-types check to what decides the verdict, and one I did not take.

Applied:

  • Dropped the "unrelated diagnostics, ignored" bucket. Those lines were collected, formatted and printed but never consumed and could not influence the exit code. They remain non-fatal — they are simply no longer echoed. classify() collapsed to unresolvedModuleDiagnostics().
  • Dropped the tarball-count guard. It could not fire: the directory is created fresh under the mkdtemp scratch root, npm pack writes exactly one .tgz per invocation, and execFileSync already throws on a non-zero exit.
  • Inlined the one-line indent() helper into its single remaining caller.
  • Corrected the npm() docstring. It claimed shell quoting as half the rationale ("cmd.exe eats ^"), which is wrong — execFileSync without shell: true never routes through cmd.exe. The real and only reason is that npm is npm.cmd on Windows and execFileSync cannot spawn it directly. The two-line ternary stays, because node scripts/check_published_types.mjs run directly (without npm run setting npm_execpath) is a real caller of the fallback.
  • Reverted an incidental requote of NODE_OPTIONS in validation.yaml. Nothing in this repo formats YAML — format:check is prettier "**/*.ts" and lint-staged covers only js,ts,json,md — so that hunk was editor churn, correctly flagged.

Net −15 lines in the script.

Not applied — folding the published-types job into the run-tests matrix as a step. The finding is right that it deletes 16 lines of YAML, but I think the trade goes the other way on three grounds:

  1. It costs more, not less. The finding's premise is that folding removes a duplicated install+build. It does — one install+build on one runner — but it adds two extra runs of the check itself, on windows-latest and macos-latest, each a full network install of the published closure (measured 1m31s locally, and the slowest part is the install). It also moves that time onto the critical path, where a parallel job adds ~0 wall clock.
  2. It is the established shape in this repo. license-check.yml and cross-language-integration.yml both stand up their own job re-declaring Checkout (and, for the latter, Use Node.js + npm install). Re-declaring setup per job is inherent to GitHub Actions, not duplication this change introduces; removing it properly needs a composite action, which is more machinery, not less.
  3. Failure isolation matters more than usual here. CI cannot run on this PR at all (stacked base, see below), so the job's first real execution is post-merge. A brand-new packaging gate whose first live run is unobserved should not be able to redden the job that runs the entire test suite on three operating systems.

The finding's stated upside — that the matrix would exercise the Windows branch of npm() — is real but thin: that branch exists for developers running the check locally on Windows, and buying its coverage costs a full closure install on two extra runners on every PR.

Checklist

[x] I have read the CONTRIBUTING.md document.
[x] I have performed a self-review of my own code.
[x] I have commented my code, particularly in hard-to-understand areas.
[x] I have added tests that prove my fix is effective or that my feature works.
[x] New and existing unit tests pass locally with my changes.

Amaad Martin added 2 commits July 31, 2026 05:06
AdkApiServer exposes `readonly app: express.Application`, and the class is
exported from dev/src/index.ts, so dev/dist/types/server/adk_api_server.d.ts
emits `import express from 'express'`. express@4 ships no bundled types, so a
package whose public declarations reference them must declare @types/express
itself rather than rely on a sibling workspace's dependency being hoisted.

The lockfile delta is the manifest move plus one incidental correction npm
makes on any install: the hoisted adm-zip entry loses a stale "dev": true,
which it earns from core listing adm-zip as a runtime dependency.
Adds `npm run check:published-types`, run by a new `published-types` CI job.
It packs every publishable workspace, installs the tarballs into a throwaway
project under the OS temp directory, and type-checks it, failing on TS2307 /
TS7016 anywhere in the output.

Three properties are load-bearing, and each defeats a distinct reason the
existing checks cannot see a phantom type dependency: the probe lives outside
the repository (npm hoists every workspace dependency into the repo-root
node_modules, so anything checked from inside the tree resolves types a
consumer never receives); it installs packed tarballs rather than `file:`
links, so the declared dependencies closure is what gets tested; and
skipLibCheck is false, without which an unresolvable module in a dependency's
.d.ts degrades silently to `any` and the check passes on a broken tree.

A separate job rather than a step in the run-tests matrix: the check needs one
network install of the full closure and only needs proving once, and running
in parallel costs no extra wall clock.
Review follow-ups on scripts/check_published_types.mjs:

- Drop the "unrelated diagnostics, ignored" bucket. Those diagnostics were
  collected, formatted and printed but never consumed and could not influence
  the exit code; they stay non-fatal, they are simply no longer echoed.
- Drop the tarball-count guard. The directory is created fresh under the
  mkdtemp scratch root, npm pack writes exactly one .tgz per invocation and
  execFileSync already throws on a non-zero exit, so it could not fire.
- Inline the one-line indent() helper into its single remaining caller.
- Correct the npm() docstring: execFileSync without shell:true never routes
  through cmd.exe, so shell quoting was never the reason. The npm.cmd spawn
  restriction on Windows is, and that is now all it claims.

Also reverts an incidental requote of NODE_OPTIONS in validation.yaml; nothing
in this repo formats YAML, so that hunk was editor churn.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant