Skip to content

Commit

Permalink
250 new decorator standard (#257)
Browse files Browse the repository at this point in the history
  • Loading branch information
mlhaufe authored Aug 27, 2023
1 parent fed97fd commit 6b5299b
Show file tree
Hide file tree
Showing 16 changed files with 67 additions and 85 deletions.
6 changes: 3 additions & 3 deletions .github/workflows/build-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ jobs:

strategy:
matrix:
node-version: [14.x]
node-version: [20.x]

steps:
- uses: actions/checkout@v2
Expand All @@ -31,7 +31,7 @@ jobs:
- name: Install dependencies
run: npm install
- name: Build
run: npm run build-nofix
run: npm run build
- name: Run unit tests
run: npm test
- name: Create package
Expand All @@ -51,7 +51,7 @@ jobs:

strategy:
matrix:
node-version: [14.x]
node-version: [20.x]

steps:
- name: Download artifact
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ jobs:

strategy:
matrix:
node-version: [14.x]
node-version: [20.x]

steps:
- uses: actions/checkout@v2
Expand All @@ -35,7 +35,7 @@ jobs:
- name: npm install, build, and test
run: |
npm install
npm run build-nofix
npm run build
npm test
env:
CI: true
1 change: 1 addition & 0 deletions .vscode/ltex.dictionary.en-US.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
VSCode
accessors
Liskov
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@

* Updated dependencies
* Updated header year to 2023
* Project targets ES2022 to utilize the [new language feature](https://devblogs.microsoft.com/typescript/announcing-typescript-5-2/#decorator-metadata)
* Project targets ES2022 to utilize the [new decorator feature](https://devblogs.microsoft.com/typescript/announcing-typescript-5-2/#decorator-metadata)
* Updated decorator implementation to track the standard
* Converted project to utilize ESM
* Simplified eslint configuration
* Removed `@override` decorator in favor of [native](https://devblogs.microsoft.com/typescript/announcing-typescript-4-3-beta/#override-and-the-noimplicitoverride-flag) `override` keyword and `@override` [JSDoc tag](https://www.typescriptlang.org/docs/handbook/jsdoc-supported-types.html#override)
Expand Down
22 changes: 15 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,21 +39,29 @@ using this. If you would like an exception to this license per section 7

## Library Installation

As a dependency run the command:
The latest version:

`npm install @final-hill/decorator-contracts`

You can also use a specific [version](https://www.npmjs.com/package/@final-hill/decorator-contracts):
A specific version:

`npm install @final-hill/decorator-contracts@0.24.1`
`npm install @final-hill/decorator-contracts@x.x.x`

For use in a webpage:
For use in a browser (no build step) via [unpkg.com](https://unpkg.com/):

`<script src="https://cdn.skypack.dev/@final-hill/decorator-contracts"></script>`
```html
<script type="importmap">
{
"imports": {
"@final-hill/decorator-contracts": "https://unpkg.com/@final-hill/decorator-contracts"
}
}
</script>
```

With a specific [version](https://www.npmjs.com/package/@final-hill/[email protected]):
Via [skypack.dev](https://www.skypack.dev/):

`<script src="https://cdn.skypack.dev/@final-hill/decorator-contracts@0.24.1"></script>`
`<script type="module" src="https://cdn.skypack.dev/@final-hill/decorator-contracts"></script>`

## Usage

Expand Down
2 changes: 2 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# TODO

Prevent delete

==========
Expand Down
29 changes: 16 additions & 13 deletions src/Contracted.mts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

import { ClassRegistration, CLASS_REGISTRY, ClassType, Feature, takeWhile, assertInvariants } from './lib/index.mjs';
import { assert, checkedMode, Contract, extend } from './index.mjs';
import { MSG_BAD_SUBCONTRACT, MSG_NO_PROPERTIES, MSG_SINGLE_CONTRACT } from './Messages.mjs';
import { Messages } from './Messages.mjs';

const isContracted = Symbol('isContracted'),
innerContract = Symbol('innerContract');
Expand Down Expand Up @@ -39,21 +39,24 @@ function hasProperties<U>(registration: ClassRegistration, obj: U): boolean {
function Contracted<
T extends Contract<any> = Contract<any>,
U extends ClassType<any> = ClassType<any>
>(contract: T = new Contract() as T): ClassDecorator {
return function (Base: U & { [innerContract]?: Contract<any> }) {
assert(!Object.getOwnPropertySymbols(Base).includes(isContracted), MSG_SINGLE_CONTRACT);
>(contract: T = new Contract() as T) {
return function (Clazz: U & { [innerContract]?: Contract<any> }, ctx: ClassDecoratorContext<U>) {
if (ctx.kind !== 'class')
throw new TypeError(Messages.MsgNotContracted);

assert(!Object.getOwnPropertySymbols(Clazz).includes(isContracted), Messages.MsgSingleContract);

if (contract[checkedMode] === false)
return Base;
return Clazz;

const baseContract = Base[innerContract];
const baseContract = Clazz[innerContract];
assert(
!baseContract ||
baseContract && contract[extend] instanceof baseContract.constructor,
MSG_BAD_SUBCONTRACT
Messages.MsgBadSubcontract
);

abstract class InnerContracted extends Base {
abstract class InnerContracted extends Clazz {
// prevents multiple @Contracted decorators from being applied
static readonly [isContracted] = true;

Expand All @@ -63,12 +66,12 @@ function Contracted<
const Class = this.constructor as ClassType<any>,
classRegistration = CLASS_REGISTRY.getOrCreate(Class);

assert(!hasProperties(classRegistration, this), MSG_NO_PROPERTIES);
assert(!hasProperties(classRegistration, this), Messages.MsgNoProperties);

if (!classRegistration.contractsChecked) {
// bottom-up to closest Contracted class bind contracts
const ancRegistrations = takeWhile(classRegistration.ancestry(), (cr => cr.Class !== Base));
[classRegistration, ...ancRegistrations, CLASS_REGISTRY.get(Base)!].forEach(registration => {
const ancRegistrations = takeWhile(classRegistration.ancestry(), (cr => cr.Class !== Clazz));
[classRegistration, ...ancRegistrations, CLASS_REGISTRY.get(Clazz)!].forEach(registration => {
registration.bindContract(InnerContracted[innerContract]);
});
}
Expand All @@ -88,10 +91,10 @@ function Contracted<
const classRegistration = CLASS_REGISTRY.getOrCreate(InnerContracted);
classRegistration.contractsChecked = false;

Object.freeze(Base);
Object.freeze(Clazz);

return InnerContracted;
} as ClassDecorator;
};
}

export { isContracted, innerContract };
Expand Down
19 changes: 10 additions & 9 deletions src/Messages.mts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@
* @see <https://spdx.org/licenses/AGPL-3.0-only.html>
*/

export const ASSERTION_FAILED = 'Assertion failure';
export const MSG_NO_STATIC = 'Only instance members can be decorated, not static members';
export const MSG_NO_PROPERTIES = 'Public properties are forbidden';
export const MSG_NOT_CONTRACTED = 'The current class or one of its ancestors must declare @Contracted(...)';
export const MSG_MISSING_FEATURE = 'The requested feature is not registered';
export const MSG_SINGLE_CONTRACT = 'Only a single @Contracted decorator is allowed per class';
export const MSG_SINGLE_RETRY = 'retry can only be called once';
export const MSG_INVALID_CONTEXT = 'A contracted feature can not be applied to objects of a different base class';
export const MSG_BAD_SUBCONTRACT = 'A sub contract must extend a base contract';
export enum Messages {
AssertionFailed = 'Assertion failure',
MsgNoProperties = 'Public properties are forbidden',
MsgNotContracted = 'The current class or one of its ancestors must declare @Contracted(...)',
MsgMissingFeature = 'The requested feature is not registered',
MsgSingleContract = 'Only a single @Contracted decorator is allowed per class',
MsgSingleRetry = 'retry can only be called once',
MsgInvalidContext = 'A contracted feature can not be applied to objects of a different base class',
MsgBadSubcontract = 'A sub contract must extend a base contract'
}
4 changes: 2 additions & 2 deletions src/assert.mts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
*/

import AssertionError from './AssertionError.mjs';
import { ASSERTION_FAILED } from './Messages.mjs';
import { Messages } from './Messages.mjs';
import { Constructor } from './index.mjs';

/**
Expand All @@ -31,7 +31,7 @@ import { Constructor } from './index.mjs';
* let name = "Tom"
* assert(name.trim().length > 0, 'Name is required', TypeError)
*/
export default function assert(condition: unknown, message = ASSERTION_FAILED, ErrorConstructor: Constructor<Error> = AssertionError): asserts condition {
export default function assert(condition: unknown, message: string = Messages.AssertionFailed, ErrorConstructor: Constructor<Error> = AssertionError): asserts condition {
if (Boolean(condition) == false)
throw new ErrorConstructor(message);
}
6 changes: 3 additions & 3 deletions src/lib/ClassRegistration.mts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* @see <https://spdx.org/licenses/AGPL-3.0-only.html>
*/

import { MSG_INVALID_CONTEXT, MSG_SINGLE_RETRY } from '../Messages.mjs';
import { Messages } from '../Messages.mjs';
import { assert, checkedMode, Contract, innerContract } from '../index.mjs';
import { assertInvariants, assertDemands, CLASS_REGISTRY, ClassType, Feature, unChecked } from './index.mjs';
import assertEnsures from './assertEnsures.mjs';
Expand All @@ -28,7 +28,7 @@ function checkedFeature(
const { Class } = registration,
className = Class.name;

assert(this instanceof Class, `${MSG_INVALID_CONTEXT}. Expected: this instanceof ${className}. this: ${this.constructor.name}`);
assert(this instanceof Class, `${Messages.MsgInvalidContext}. Expected: this instanceof ${className}. this: ${this.constructor.name}`);

const contract = Reflect.get(this, innerContract);
if (!contract[checkedMode])
Expand Down Expand Up @@ -66,7 +66,7 @@ function checkedFeature(
let hasRetried = false;
unChecked(contract, () => {
rescue.call(this, this, error, [], (...args: any[]) => {
assert(!hasRetried, MSG_SINGLE_RETRY);
assert(!hasRetried, Messages.MsgSingleRetry);
hasRetried = true;
contract[checkedMode] = true;
result = innerCheckedFeature.call(this, ...args);
Expand Down
6 changes: 3 additions & 3 deletions src/tests/Contract.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* @see <https://spdx.org/licenses/AGPL-3.0-only.html>
*/

import { MSG_BAD_SUBCONTRACT, MSG_SINGLE_CONTRACT } from '../Messages.mjs';
import { Messages } from '../Messages.mjs';
import { Contracted, checkedMode, Contract, extend } from '../index.mjs';

interface StackType<T> {
Expand Down Expand Up @@ -145,7 +145,7 @@ describe('Only a single contract can be assigned to a class', () => {
class Foo { }

return new Foo();
}).toThrow(MSG_SINGLE_CONTRACT);
}).toThrow(Messages.MsgSingleContract);
});
});

Expand Down Expand Up @@ -202,6 +202,6 @@ describe('A subclass can only be contracted by a subcontract of the base class c
const badContract = new Contract<Bar>({});
@Contracted(badContract)
class Bar extends Foo { }
}).toThrow(MSG_BAD_SUBCONTRACT);
}).toThrow(Messages.MsgBadSubcontract);
});
});
6 changes: 3 additions & 3 deletions src/tests/invariant.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
*/

import { AssertionError, checkedMode, Contract, Contracted, extend, invariant } from '../index.mjs';
import { MSG_INVALID_CONTEXT, MSG_NO_PROPERTIES } from '../Messages.mjs';
import { Messages } from '../Messages.mjs';

// https://github.com/final-hill/decorator-contracts/issues/30
describe('The subclasses of a contracted class must obey the invariants', () => {
Expand Down Expand Up @@ -448,7 +448,7 @@ describe('Public properties must be forbidden', () => {
}

return new Foo();
}).toThrow(MSG_NO_PROPERTIES);
}).toThrow(Messages.MsgNoProperties);
});
});

Expand Down Expand Up @@ -773,6 +773,6 @@ describe('Contracted features can only be applied to objects of the same instanc
test('different instance error', () => {
expect(() => {
foo.inc.apply(bar);
}).toThrow(MSG_INVALID_CONTEXT);
}).toThrow(Messages.MsgInvalidContext);
});
});
4 changes: 2 additions & 2 deletions src/tests/public-properties.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* @see <https://spdx.org/licenses/AGPL-3.0-only.html>
*/

import { MSG_NO_PROPERTIES } from '../Messages.mjs';
import { Messages } from '../Messages.mjs';
import { Contracted } from '../index.mjs';

// https://github.com/final-hill/decorator-contracts/issues/35
Expand Down Expand Up @@ -34,6 +34,6 @@ describe('Public properties must be forbidden', () => {
) { }
}

expect(() => new Point2D(12, 5)).toThrow(MSG_NO_PROPERTIES);
expect(() => new Point2D(12, 5)).toThrow(Messages.MsgNoProperties);
});
});
4 changes: 2 additions & 2 deletions src/tests/rescue.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* @see <https://spdx.org/licenses/AGPL-3.0-only.html>
*/

import { MSG_SINGLE_RETRY } from '../Messages.mjs';
import { Messages } from '../Messages.mjs';
import { checkedMode, Contract, Contracted, invariant } from '../index.mjs';

/**
Expand Down Expand Up @@ -305,7 +305,7 @@ describe('The `retry` argument of the `rescue` declaration can only be called on
}
}
const base = new Base();
expect(() => base.method(0)).toThrow(MSG_SINGLE_RETRY);
expect(() => base.method(0)).toThrow(Messages.MsgSingleRetry);
});
});

Expand Down
1 change: 0 additions & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@
"noFallthroughCasesInSwitch": true,
"baseUrl": "./src",
"allowSyntheticDefaultImports": true,
"experimentalDecorators": true,
"noImplicitOverride": true
}
}
35 changes: 1 addition & 34 deletions webpack.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -51,40 +51,7 @@ export default {
extensions: ['.mts', '.mjs', '.js', '.ts', '.json'],
exclude: ['node_modules', 'dist', 'coverage'],
fix: true,
overrideConfigFile: path.resolve(dirName, '.eslintrc.json'),

overrideConfig: {
env: {
browser: true,
node: true,
jest: true,
es2022: true
},
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/eslint-recommended'
],
ignorePatterns: [
'node_modules',
'dist',
'.cache',
'coverage'
],
parser: '@typescript-eslint/parser',
parserOptions: {
sourceType: 'module',
project: [
'./tsconfig.json'
]
},
plugins: [
'@typescript-eslint',
'header'
],
rules: {

}
}
overrideConfigFile: path.resolve(dirName, '.eslintrc.json')
})
]
};

0 comments on commit 6b5299b

Please sign in to comment.