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

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions