#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 #4225 — packages/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.
#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.mjskey off an annotation on a variable or anasexpression:Neither matches the type argument form, which erases the contract just as completely:
Here
enginehas no annotation and there is noas, so nothing matches — yetIDataEngineis gone exactly as if it had been writtenconst 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
filesis['packages/runtime/**/*.{ts,mts,cts}'], so route modules and service plugins outsidepackages/runtimeare unlinted regardless of shape.Evidence
Repo-wide, excluding tests and
node_modules:Concentrated in the composition roots:
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:
Neither
datanormetadatais inUNCONTRACTED_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
filesto the packages that hold composition roots and route modules. TheUNCONTRACTED_SLOTSexemption 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 #4225 —
packages/services/service-datasource/src/admin-routes.tsis one of the 80, and itsctx.getService<any>(service)was carried over unchanged by #4234 rather than silently expanding that PR's scope.