Browse Source
* feat: implement Rc * feat: use Rc in sdk service * docs: add an example to `take()` * fix: clarify function doc * Add custom eslint rule package with enforced `using` rule (#13009) * feat: add custom eslint rule * feat: check for `UsingRequired` instead of hardcoding `Rc` * chore: move package to libs * wip: add tests. Tests work when run from same folder but not from root * fix: add dependencies to renovate * fix: add empty ts file to avoid typechecking throwing errors * fix: tests not running from root * chore: remove unecessary config * fix: linting * docs: add readme * chore: add platform ownership * chore: clean up comment * Add support for flat config to "Improved sdk referencing" (#13054) * WIP flat config for eslint * Add rxjs * Configure vscode to use flat config * Fix some new linting errors * Remove directory overrides of .eslintrc * Remove explicit dependencies on typescript-eslint/ and @angular-eslint/ * Add missing rules * Add rxjs recommended rules * Add storybook and enabled rxjs-angular rule * Add buildNoRestrictedImports helper * Ignore platform import restrictions * Remove unused ignores * feat: migrate rules over to .mjs and flat config * feat: implement support for .mjs tests * chore: remove old package approach * chore: update package-lock * fix: add empty TS file to stop errors * chore: clean up comments --------- Co-authored-by: Hinton <hinton@users.noreply.github.com> * fix: update CODEOWNERS to match folder name * fix: renovate.json after merge * fix: package.json, pin versions, sort order * fix: update package-lock.json --------- Co-authored-by: Hinton <hinton@users.noreply.github.com>pull/13180/head
20 changed files with 3080 additions and 6031 deletions
@ -0,0 +1,93 @@
@@ -0,0 +1,93 @@
|
||||
// Temporary workaround for Symbol.dispose
|
||||
// remove when https://github.com/jestjs/jest/issues/14874 is resolved and *released*
|
||||
const disposeSymbol: unique symbol = Symbol("Symbol.dispose"); |
||||
const asyncDisposeSymbol: unique symbol = Symbol("Symbol.asyncDispose"); |
||||
(Symbol as any).asyncDispose ??= asyncDisposeSymbol as unknown as SymbolConstructor["asyncDispose"]; |
||||
(Symbol as any).dispose ??= disposeSymbol as unknown as SymbolConstructor["dispose"]; |
||||
|
||||
// Import needs to be after the workaround
|
||||
import { Rc } from "./rc"; |
||||
|
||||
export class FreeableTestValue { |
||||
isFreed = false; |
||||
|
||||
free() { |
||||
this.isFreed = true; |
||||
} |
||||
} |
||||
|
||||
describe("Rc", () => { |
||||
let value: FreeableTestValue; |
||||
let rc: Rc<FreeableTestValue>; |
||||
|
||||
beforeEach(() => { |
||||
value = new FreeableTestValue(); |
||||
rc = new Rc(value); |
||||
}); |
||||
|
||||
it("should increase refCount when taken", () => { |
||||
rc.take(); |
||||
|
||||
expect(rc["refCount"]).toBe(1); |
||||
}); |
||||
|
||||
it("should return value on take", () => { |
||||
// eslint-disable-next-line @bitwarden/platform/required-using
|
||||
const reference = rc.take(); |
||||
|
||||
expect(reference.value).toBe(value); |
||||
}); |
||||
|
||||
it("should decrease refCount when disposing reference", () => { |
||||
// eslint-disable-next-line @bitwarden/platform/required-using
|
||||
const reference = rc.take(); |
||||
|
||||
reference[Symbol.dispose](); |
||||
|
||||
expect(rc["refCount"]).toBe(0); |
||||
}); |
||||
|
||||
it("should automatically decrease refCount when reference goes out of scope", () => { |
||||
{ |
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
using reference = rc.take(); |
||||
} |
||||
|
||||
expect(rc["refCount"]).toBe(0); |
||||
}); |
||||
|
||||
it("should not free value when refCount reaches 0 if not marked for disposal", () => { |
||||
// eslint-disable-next-line @bitwarden/platform/required-using
|
||||
const reference = rc.take(); |
||||
|
||||
reference[Symbol.dispose](); |
||||
|
||||
expect(value.isFreed).toBe(false); |
||||
}); |
||||
|
||||
it("should free value when refCount reaches 0 and rc is marked for disposal", () => { |
||||
// eslint-disable-next-line @bitwarden/platform/required-using
|
||||
const reference = rc.take(); |
||||
rc.markForDisposal(); |
||||
|
||||
reference[Symbol.dispose](); |
||||
|
||||
expect(value.isFreed).toBe(true); |
||||
}); |
||||
|
||||
it("should free value when marked for disposal if refCount is 0", () => { |
||||
// eslint-disable-next-line @bitwarden/platform/required-using
|
||||
const reference = rc.take(); |
||||
reference[Symbol.dispose](); |
||||
|
||||
rc.markForDisposal(); |
||||
|
||||
expect(value.isFreed).toBe(true); |
||||
}); |
||||
|
||||
it("should throw error when trying to take a disposed reference", () => { |
||||
rc.markForDisposal(); |
||||
|
||||
expect(() => rc.take()).toThrow(); |
||||
}); |
||||
}); |
||||
@ -0,0 +1,76 @@
@@ -0,0 +1,76 @@
|
||||
import { UsingRequired } from "../using-required"; |
||||
|
||||
export type Freeable = { free: () => void }; |
||||
|
||||
/** |
||||
* Reference counted disposable value. |
||||
* This class is used to manage the lifetime of a value that needs to be |
||||
* freed of at a specific time but might still be in-use when that happens. |
||||
*/ |
||||
export class Rc<T extends Freeable> { |
||||
private markedForDisposal = false; |
||||
private refCount = 0; |
||||
private value: T; |
||||
|
||||
constructor(value: T) { |
||||
this.value = value; |
||||
} |
||||
|
||||
/** |
||||
* Use this function when you want to use the underlying object. |
||||
* This will guarantee that you have a reference to the object |
||||
* and that it won't be freed until your reference goes out of scope. |
||||
* |
||||
* This function must be used with the `using` keyword. |
||||
* |
||||
* @example |
||||
* ```typescript
|
||||
* function someFunction(rc: Rc<SomeValue>) { |
||||
* using reference = rc.take(); |
||||
* reference.value.doSomething(); |
||||
* // reference is automatically disposed here
|
||||
* } |
||||
* ``` |
||||
* |
||||
* @returns The value. |
||||
*/ |
||||
take(): Ref<T> { |
||||
if (this.markedForDisposal) { |
||||
throw new Error("Cannot take a reference to a value marked for disposal"); |
||||
} |
||||
|
||||
this.refCount++; |
||||
return new Ref(() => this.release(), this.value); |
||||
} |
||||
|
||||
/** |
||||
* Mark this Rc for disposal. When the refCount reaches 0, the value |
||||
* will be freed. |
||||
*/ |
||||
markForDisposal() { |
||||
this.markedForDisposal = true; |
||||
this.freeIfPossible(); |
||||
} |
||||
|
||||
private release() { |
||||
this.refCount--; |
||||
this.freeIfPossible(); |
||||
} |
||||
|
||||
private freeIfPossible() { |
||||
if (this.refCount === 0 && this.markedForDisposal) { |
||||
this.value.free(); |
||||
} |
||||
} |
||||
} |
||||
|
||||
export class Ref<T extends Freeable> implements UsingRequired { |
||||
constructor( |
||||
private readonly release: () => void, |
||||
readonly value: T, |
||||
) {} |
||||
|
||||
[Symbol.dispose]() { |
||||
this.release(); |
||||
} |
||||
} |
||||
@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
|
||||
export type Disposable = { [Symbol.dispose]: () => void }; |
||||
|
||||
/** |
||||
* Types implementing this type must be used together with the `using` keyword |
||||
* |
||||
* @example using ref = rc.take(); |
||||
*/ |
||||
// We want to use `interface` here because it creates a separate type.
|
||||
// Type aliasing would not expose `UsingRequired` to the linter.
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
export interface UsingRequired extends Disposable {} |
||||
@ -0,0 +1,2 @@
@@ -0,0 +1,2 @@
|
||||
// This file is used to avoid TS errors. This package only uses `tsconfig.json` for dynamically generated test files but
|
||||
// TS doesn't know that in the CI.
|
||||
@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
|
||||
const sharedConfig = require("../../libs/shared/jest.config.angular"); |
||||
|
||||
/** @type {import('jest').Config} */ |
||||
module.exports = { |
||||
...sharedConfig, |
||||
testMatch: ["**/+(*.)+(spec).+(mjs)"], |
||||
displayName: "libs/eslint tests", |
||||
preset: "jest-preset-angular", |
||||
setupFilesAfterEnv: ["<rootDir>/test.setup.mjs"], |
||||
}; |
||||
@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
|
||||
import requiredUsing from "./required-using.mjs"; |
||||
|
||||
export default { rules: { "required-using": requiredUsing } }; |
||||
@ -0,0 +1,83 @@
@@ -0,0 +1,83 @@
|
||||
import { ESLintUtils } from "@typescript-eslint/utils"; |
||||
|
||||
export const errorMessage = "'using' keyword is required but not used"; |
||||
|
||||
export default { |
||||
meta: { |
||||
type: "problem", |
||||
docs: { |
||||
description: "Ensure objects implementing UsingRequired are used with the using keyword", |
||||
category: "Best Practices", |
||||
recommended: false, |
||||
}, |
||||
schema: [], |
||||
}, |
||||
create(context) { |
||||
const parserServices = ESLintUtils.getParserServices(context); |
||||
const checker = parserServices.program.getTypeChecker(); |
||||
|
||||
// Function to check if a type implements the `UsingRequired` interface
|
||||
function implementsUsingRequired(type) { |
||||
const symbol = type.getSymbol(); |
||||
if (!symbol) { |
||||
return false; |
||||
} |
||||
|
||||
const declarations = symbol.getDeclarations() || []; |
||||
for (const declaration of declarations) { |
||||
const heritageClauses = declaration.heritageClauses || []; |
||||
for (const clause of heritageClauses) { |
||||
if ( |
||||
clause.types.some( |
||||
(typeExpression) => |
||||
checker.typeToString(checker.getTypeAtLocation(typeExpression.expression)) === |
||||
"UsingRequired", |
||||
) |
||||
) { |
||||
return true; |
||||
} |
||||
} |
||||
} |
||||
|
||||
return false; |
||||
} |
||||
|
||||
// Function to check if a function call returns a `UsingRequired`
|
||||
function returnsUsingRequired(node) { |
||||
if (node.type === "CallExpression") { |
||||
const tsNode = parserServices.esTreeNodeToTSNodeMap.get(node); |
||||
const returnType = checker.getTypeAtLocation(tsNode); |
||||
|
||||
return implementsUsingRequired(returnType); |
||||
} |
||||
|
||||
return false; |
||||
} |
||||
|
||||
return { |
||||
VariableDeclarator(node) { |
||||
// Skip if `using` is already present
|
||||
if (node.parent.type === "VariableDeclaration" && node.parent.kind === "using") { |
||||
return; |
||||
} |
||||
|
||||
// Check if the initializer returns a `UsingRequired`
|
||||
if (node.init && returnsUsingRequired(node.init)) { |
||||
context.report({ |
||||
node, |
||||
message: errorMessage, |
||||
}); |
||||
} |
||||
}, |
||||
AssignmentExpression(node) { |
||||
// Check if the right-hand side returns a `UsingRequired`
|
||||
if (returnsUsingRequired(node.right)) { |
||||
context.report({ |
||||
node, |
||||
message: errorMessage, |
||||
}); |
||||
} |
||||
}, |
||||
}; |
||||
}, |
||||
}; |
||||
@ -0,0 +1,98 @@
@@ -0,0 +1,98 @@
|
||||
import { RuleTester } from "@typescript-eslint/rule-tester"; |
||||
|
||||
import rule, { errorMessage } from "./required-using.mjs"; |
||||
|
||||
const ruleTester = new RuleTester({ |
||||
languageOptions: { |
||||
parserOptions: { |
||||
project: [__dirname + "/../tsconfig.spec.json"], |
||||
projectService: { |
||||
allowDefaultProject: ["*.ts*"], |
||||
}, |
||||
tsconfigRootDir: __dirname + "/..", |
||||
}, |
||||
}, |
||||
}); |
||||
|
||||
const setup = ` |
||||
interface UsingRequired {} |
||||
class Ref implements UsingRequired {} |
||||
|
||||
const rc = { |
||||
take(): Ref { |
||||
return new Ref(); |
||||
}, |
||||
}; |
||||
`;
|
||||
|
||||
ruleTester.run("required-using", rule.default, { |
||||
valid: [ |
||||
{ |
||||
name: "Direct declaration with `using`", |
||||
code: ` |
||||
${setup} |
||||
using client = rc.take(); |
||||
`,
|
||||
}, |
||||
{ |
||||
name: "Function reference with `using`", |
||||
code: ` |
||||
${setup} |
||||
const t = rc.take; |
||||
using client = t(); |
||||
`,
|
||||
}, |
||||
], |
||||
invalid: [ |
||||
{ |
||||
name: "Direct declaration without `using`", |
||||
code: ` |
||||
${setup} |
||||
const client = rc.take(); |
||||
`,
|
||||
errors: [ |
||||
{ |
||||
message: errorMessage, |
||||
}, |
||||
], |
||||
}, |
||||
{ |
||||
name: "Assignment without `using`", |
||||
code: ` |
||||
${setup} |
||||
let client; |
||||
client = rc.take(); |
||||
`,
|
||||
errors: [ |
||||
{ |
||||
message: errorMessage, |
||||
}, |
||||
], |
||||
}, |
||||
{ |
||||
name: "Function reference without `using`", |
||||
code: ` |
||||
${setup} |
||||
const t = rc.take; |
||||
const client = t(); |
||||
`,
|
||||
errors: [ |
||||
{ |
||||
message: errorMessage, |
||||
}, |
||||
], |
||||
}, |
||||
{ |
||||
name: "Destructuring without `using`", |
||||
code: ` |
||||
${setup} |
||||
const { value } = rc.take(); |
||||
`,
|
||||
errors: [ |
||||
{ |
||||
message: errorMessage, |
||||
}, |
||||
], |
||||
}, |
||||
], |
||||
}); |
||||
@ -0,0 +1,8 @@
@@ -0,0 +1,8 @@
|
||||
/* eslint-disable no-undef */ |
||||
|
||||
import { clearImmediate, setImmediate } from "node:timers"; |
||||
|
||||
Object.defineProperties(globalThis, { |
||||
clearImmediate: { value: clearImmediate }, |
||||
setImmediate: { value: setImmediate }, |
||||
}); |
||||
@ -0,0 +1,5 @@
@@ -0,0 +1,5 @@
|
||||
{ |
||||
"extends": "../shared/tsconfig", |
||||
"compilerOptions": {}, |
||||
"exclude": ["node_modules", "dist"] |
||||
} |
||||
@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
|
||||
{ |
||||
"extends": "./tsconfig.json" |
||||
} |
||||
Loading…
Reference in new issue