Skip to content

The service-lookup any rule misses getService<any>(...) — 80 sites erase the slot contract, 3 of them inside the rule's own scope #4251

Description

@os-zhuang

#4127 / #4214 added a lint rule against annotating a service-lookup result as any, on the grounds that "the lookup already returns the slot's contract … every such annotation found so far was hiding a real gap, including a project-membership gate that silently stopped gating." The reasoning is right. The rule as written catches a narrower shape than the reasoning covers.

Two gaps, and the second is the load-bearing one

Selector. Both selectors in eslint.config.mjs key off an annotation on a variable or an as expression:

'VariableDeclarator[id.typeAnnotation.typeAnnotation.type="TSAnyKeyword"]:has(CallExpression[callee.property.name=/^(resolveService|getService|getRequestKernelService)$/]...)'
'TSAsExpression[typeAnnotation.type="TSAnyKeyword"]:has(CallExpression[...])'

Neither matches the type argument form, which erases the contract just as completely:

const engine = ctx.getService<any>('data');

Here engine has no annotation and there is no as, so nothing matches — yet IDataEngine is gone exactly as if it had been written const engine: any = ctx.getService('data'), the form the rule does catch. This is a near sibling of the KNOWN RESIDUAL the rule already documents (an annotated wrapper return type), except this one needs no type information to catch — it is a plain AST shape.

Scope. The rule's files is ['packages/runtime/**/*.{ts,mts,cts}'], so route modules and service plugins outside packages/runtime are unlinted regardless of shape.

Evidence

Repo-wide, excluding tests and node_modules:

$ grep -rn "getService<any>\|resolveService<any>\|getRequestKernelService<any>" --include=*.ts packages/ | grep -v node_modules | grep -v "\.test\." | wc -l
80

Concentrated in the composition roots:

14  packages/rest/src/rest-api-plugin.ts
13  packages/plugins/plugin-auth/src/auth-plugin.ts
 7  packages/plugins/plugin-approvals/src/approvals-plugin.ts
 6  packages/plugins/plugin-sharing/src/sharing-plugin.ts
 5  packages/services/service-storage/src/storage-service-plugin.ts
 5  packages/plugins/plugin-reports/src/reports-plugin.ts
 …

Three of the 80 sit inside the rule's own scope, which isolates the selector gap from the scope gap — the rule is active in this directory today and these pass:

packages/runtime/src/default-datasource-plugin.ts:163:      const engine = ctx.getService<any>('data');
packages/runtime/src/default-datasource-plugin.ts:229:      const metadata = ctx.getService<any>('metadata');
packages/runtime/src/driver-plugin.ts:60:            const metadata = ctx.getService<any>('metadata');

Neither data nor metadata is in UNCONTRACTED_SLOTS (protocol, mcp, kernel-resolver, scope-manager), so by the rule's own ledger these are contracted slots being erased.

Suggested shape

Add a third selector for the type-argument form, and widen files to the packages that hold composition roots and route modules. The UNCONTRACTED_SLOTS exemption mechanism carries over unchanged — the :not(:has(Literal[value=/…/])) clause reads the slot name the same way.

80 sites is not a one-sitting sweep, and #4214 already established the pattern for this: batches, with the ratchet visible. Worth noting that #4214's own batches found a real bug (the project-membership gate), so the expected yield here is not just cosmetic.

Found while fixing #4225packages/services/service-datasource/src/admin-routes.ts is one of the 80, and its ctx.getService<any>(service) was carried over unchanged by #4234 rather than silently expanding that PR's scope.

Metadata

Metadata

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions