@systemfsoftware/stryker-plugins 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,18 +1,13 @@
1
1
  # @systemfsoftware/stryker-plugins
2
2
 
3
- [Stryker](https://stryker-mutator.io) mutation-testing plugins for [Effect](https://effect.website).
3
+ ![version](https://img.shields.io/npm/v/@systemfsoftware/stryker-plugins)
4
+ ![license](https://img.shields.io/npm/l/@systemfsoftware/stryker-plugins)
4
5
 
5
- ## `effect-schema-ignorer`
6
+ > Stop Effect `Schema` declarations from dragging your Stryker score below 100%.
6
7
 
7
- A Stryker **Ignore** plugin (`effect-schema-declarations`) that skips the _equivalent_ mutants on Effect `Schema` declarations mutations that change source without changing behaviour, so no test can ever kill them. It recognizes and ignores:
8
+ A brand description, a `_tag`, a `title` mutate any of them and the source changes but the behaviour does not, so no test can ever kill the mutant. Those unkillable mutants sit in your report forever, indistinguishable from real coverage gaps. This [Stryker](https://stryker-mutator.io) Ignore plugin removes them, so the score that remains is behaviour.
8
9
 
9
- - brand descriptions in `Symbol.for('…')`,
10
- - `Schema.TaggedClass` / `Schema.TaggedError` `_tag` identifiers,
11
- - the field schemas of those declarations.
12
-
13
- Schema declarations are **data, not behaviour** (Constitution Article III §4) — mutating them produces unkillable equivalent mutants that drag a mutation score below 100% for no real coverage gap. This plugin removes that noise so the score reflects logic.
14
-
15
- ## Usage
10
+ ## Install
16
11
 
17
12
  ```bash
18
13
  pnpm add -D @systemfsoftware/stryker-plugins
@@ -31,5 +26,35 @@ In `stryker.config.json`:
31
26
  }
32
27
  ```
33
28
 
29
+ Mutants it recognizes are reported as `Ignored`, each carrying the reason it was safe to skip.
30
+
34
31
  > [!NOTE]
35
32
  > `@stryker-mutator/api` is a peer dependency — your Stryker install provides it. `effect` is a direct dependency (the plugin decodes AST nodes with `Schema`).
33
+
34
+ ## What it ignores
35
+
36
+ | Declaration | Example |
37
+ | --------------------------------------------------------- | ------------------------------------------------------------------- |
38
+ | Brand descriptions | `Symbol.for('UserId')` |
39
+ | `TaggedClass` / `TaggedError` tags | `S.TaggedClass<A>()('Placed', {…})` |
40
+ | The field schemas of those declarations | the `{…}` above |
41
+ | `optionalWith` defaults | `S.optionalWith(S.Number, { default: () => 0 })` |
42
+ | Documentation annotations | `identifier`, `description`, `title`, `documentation`, `examples` |
43
+ | An `annotations({…})` object that is _only_ documentation | `S.annotations({ title: 'Amount' })` |
44
+ | A `TemplateLiteral` head a piped `pattern` already forces | `S.TemplateLiteral('usr', S.String).pipe(S.pattern(/^usr[a-z]*$/))` |
45
+
46
+ ## Where the line is
47
+
48
+ Every ignore is proven redundant, never merely assumed — anything a test could observe keeps its mutants.
49
+
50
+ `arbitrary`, `pretty`, `equivalence`, `message`, `jsonSchema` and `parseIssueTitle` are **not** documentation: each changes what the schema does, so a survivor there is a test gap to close. That is why the two `annotations` rules differ — a `title` is ignored wherever it appears, but the enclosing object is ignored only when every entry documents, since emptying an object holding an `arbitrary` would silently change what your property tests generate.
51
+
52
+ The `TemplateLiteral` head is ignored only where the piped pattern makes it unobservable: the head must be the first span with no regex metacharacters, and the pattern must be an unflagged regex literal opening `^` + that head, with no quantifier making its final character optional. A head with no pattern keeps its mutants, and so do `/^usr?…/`, `/^usr…/i`, `/^usr…/m`, and every span past the first.
53
+
54
+ ## Contributing
55
+
56
+ Development setup and workflow: [AGENTS.md](AGENTS.md).
57
+
58
+ ## License
59
+
60
+ [MIT](LICENSE)
@@ -0,0 +1,154 @@
1
+ import { PluginKind, declareValuePlugin } from "@stryker-mutator/api/plugin";
2
+ import { Schema } from "effect";
3
+ //#region src/effect-schema-ignorer/ast-node.schema.ts
4
+ const Identifier = Schema.Struct({
5
+ type: Schema.Literal("Identifier"),
6
+ name: Schema.String
7
+ });
8
+ const StringLiteral = Schema.Struct({
9
+ type: Schema.Literal("StringLiteral"),
10
+ value: Schema.String
11
+ });
12
+ const RegExpLiteral = Schema.Struct({
13
+ type: Schema.Literal("RegExpLiteral"),
14
+ pattern: Schema.String,
15
+ flags: Schema.String
16
+ });
17
+ const ObjectExpression = Schema.Struct({ type: Schema.Literal("ObjectExpression") });
18
+ const ArrowFunctionExpression = Schema.Struct({ type: Schema.Literal("ArrowFunctionExpression") });
19
+ const UnknownNode = Schema.Struct({ type: Schema.String });
20
+ const MemberExpression = Schema.suspend(() => Schema.Struct({
21
+ type: Schema.Literal("MemberExpression"),
22
+ object: Schema.suspend(() => AstNode),
23
+ property: Schema.suspend(() => AstNode)
24
+ }));
25
+ const CallExpression = Schema.suspend(() => Schema.Struct({
26
+ type: Schema.Literal("CallExpression"),
27
+ callee: Schema.suspend(() => AstNode),
28
+ arguments: Schema.Array(Schema.suspend(() => AstNode))
29
+ }));
30
+ const AstNode = Schema.suspend(() => Schema.Union(Identifier, StringLiteral, RegExpLiteral, ObjectExpression, ArrowFunctionExpression, MemberExpression, CallExpression, UnknownNode));
31
+ //#endregion
32
+ //#region src/effect-schema-ignorer/schema-declaration-ignore.ts
33
+ const SYMBOL_DESCRIPTION_IGNORED = "Symbol.for() brand description is identity-only data, not behaviour";
34
+ const TAGGED_TAG_IGNORED = "TaggedClass/TaggedError _tag is a declaration discriminant, not behaviour";
35
+ const TAGGED_FIELDS_IGNORED = "TaggedClass/TaggedError field schema is a declaration, not behaviour";
36
+ const OPTIONAL_DEFAULT_IGNORED = "optionalWith default value is config, not behaviour";
37
+ const ANNOTATION_OBJECT_IGNORED = "annotations object holding only documentation is a declaration, not behaviour";
38
+ const ANNOTATION_TEXT_IGNORED = "annotation documentation value is declaration data, not behaviour";
39
+ const TEMPLATE_HEAD_IGNORED = "TemplateLiteral head re-stated by an anchored pattern filter is a declaration of the encoded type, not behaviour";
40
+ /**
41
+ * The Effect `Schema` annotations that describe a schema without changing what
42
+ * it does. `arbitrary`, `pretty`, `equivalence`, `message`, `jsonSchema` and
43
+ * `parseIssueTitle` are absent by design: each one alters observable behaviour,
44
+ * so a surviving mutant of one is a test gap to close, never an equivalent
45
+ * mutant to ignore.
46
+ */
47
+ const DocumentationKey = Schema.Literal("identifier", "description", "title", "documentation", "examples");
48
+ const DocumentationProperty = Schema.Struct({
49
+ type: Schema.Literal("ObjectProperty"),
50
+ computed: Schema.Literal(false),
51
+ key: Schema.Union(Schema.Struct({
52
+ type: Schema.Literal("Identifier"),
53
+ name: DocumentationKey
54
+ }), Schema.Struct({
55
+ type: Schema.Literal("StringLiteral"),
56
+ value: DocumentationKey
57
+ })),
58
+ value: Schema.Unknown
59
+ });
60
+ /**
61
+ * An object literal whose every entry documents. One behaviour-bearing entry -
62
+ * an `arbitrary` beside a `title` - fails the schema, so the object keeps its
63
+ * mutants: emptying it would delete a generator, which a test can observe.
64
+ */
65
+ const DocumentationObject = Schema.Struct({
66
+ type: Schema.Literal("ObjectExpression"),
67
+ properties: Schema.NonEmptyArray(DocumentationProperty)
68
+ });
69
+ const TAGGED_FACTORIES = ["TaggedClass", "TaggedError"];
70
+ const isIdentifier = Schema.is(Identifier);
71
+ const isStringLiteral = Schema.is(StringLiteral);
72
+ const isObjectExpression = Schema.is(ObjectExpression);
73
+ const isArrowFunctionExpression = Schema.is(ArrowFunctionExpression);
74
+ const isMemberExpression = Schema.is(MemberExpression);
75
+ const isCallExpression = Schema.is(CallExpression);
76
+ const isDocumentationProperty = Schema.is(DocumentationProperty);
77
+ const isDocumentationObject = Schema.is(DocumentationObject);
78
+ const isRegExpLiteral = Schema.is(RegExpLiteral);
79
+ const isNamedMember = (node, object, property) => isMemberExpression(node) && isIdentifier(node.object) && node.object.name === object && isIdentifier(node.property) && node.property.name === property;
80
+ const isSymbolForCallee = (callee) => isNamedMember(callee, "Symbol", "for");
81
+ const isTaggedFactoryReference = (reference) => isMemberExpression(reference) && isIdentifier(reference.property) && TAGGED_FACTORIES.includes(reference.property.name);
82
+ const isTaggedFactoryCallee = (callee) => isCallExpression(callee) && isTaggedFactoryReference(callee.callee);
83
+ const isArgumentOf = (node, parent, index, calleeMatches) => isCallExpression(parent) && calleeMatches(parent.callee) && parent.arguments[index] === node;
84
+ const isOptionalWithCallee = (callee) => isNamedMember(callee, "S", "optionalWith");
85
+ const isAnnotationsCallee = (callee) => isMemberExpression(callee) && isIdentifier(callee.property) && callee.property.name === "annotations";
86
+ /**
87
+ * A head whose every character stands for itself inside a regex. `a.b` is
88
+ * excluded because `^a.b` would also admit `axb`, which the head refuses.
89
+ */
90
+ const REGEX_INERT_HEAD = /^[A-Za-z0-9_]+$/;
91
+ /** `?`, `*` and `{` make the character before them optional, so `^usr?` never forces the `r`. */
92
+ const OPTIONAL_QUANTIFIERS = [
93
+ "?",
94
+ "*",
95
+ "{"
96
+ ];
97
+ const isNamedMethodCallee = (callee, method) => isMemberExpression(callee) && isIdentifier(callee.property) && callee.property.name === method;
98
+ /**
99
+ * Whether an unflagged, `^`-anchored regex admits only strings opening with
100
+ * `head`. Flags are refused outright: `i` would admit another casing and `m`
101
+ * would let `^` match a line start, and the head rejects both — so it decides.
102
+ */
103
+ const forcesPrefix = (regex, head) => isRegExpLiteral(regex) && regex.flags === "" && regex.pattern.startsWith(`^${head}`) && !OPTIONAL_QUANTIFIERS.includes(regex.pattern.charAt(head.length + 1));
104
+ const patternForcesPrefix = (argument, head) => isCallExpression(argument) && isNamedMethodCallee(argument.callee, "pattern") && argument.arguments.some((regex) => forcesPrefix(regex, head));
105
+ /**
106
+ * The head of a `TemplateLiteral` piped straight into a `pattern` that already
107
+ * forces that same prefix. Effect rejects refinements as spans, so a prefixed
108
+ * wire format can only earn its `` `${head}${string}` `` type this way, and the
109
+ * prefix ends up stated twice by construction. Emptying the head then changes
110
+ * nothing observable: the filter still refuses every input the head would have
111
+ * refused, and Effect derives the arbitrary from the filter's regex rather than
112
+ * the head. Only the encoded *type* moves, which no test can see.
113
+ *
114
+ * Restricted to argument 0: a trailing span sits past the anchor, so an
115
+ * anchored prefix proves nothing about it and its mutants stay.
116
+ */
117
+ const templateHeadRule = {
118
+ matches: (node, parent, grandparent, ancestor) => isStringLiteral(node) && REGEX_INERT_HEAD.test(node.value) && isArgumentOf(node, parent, 0, (callee) => isNamedMethodCallee(callee, "TemplateLiteral")) && isMemberExpression(grandparent) && grandparent.object === parent && isIdentifier(grandparent.property) && grandparent.property.name === "pipe" && isCallExpression(ancestor) && ancestor.callee === grandparent && ancestor.arguments.some((argument) => patternForcesPrefix(argument, node.value)),
119
+ reason: TEMPLATE_HEAD_IGNORED
120
+ };
121
+ const argumentRule = (is, argumentIndex, calleeMatches, reason) => ({
122
+ matches: (node, parent) => is(node) && isArgumentOf(node, parent, argumentIndex, calleeMatches),
123
+ reason
124
+ });
125
+ /**
126
+ * A documentation-keyed entry of an `annotations` call. Unlike the object rule
127
+ * this does not care what sits beside it: `title` is documentation whether or
128
+ * not an `arbitrary` shares the object, because replacing the title cannot
129
+ * change what the schema does. Emptying the whole object could, which is why
130
+ * that rule is the stricter of the two.
131
+ */
132
+ const documentationValueRule = {
133
+ matches: (node, parent, grandparent, ancestor) => isDocumentationProperty(parent) && parent.value === node && isArgumentOf(grandparent, ancestor, 0, isAnnotationsCallee),
134
+ reason: ANNOTATION_TEXT_IGNORED
135
+ };
136
+ const RULES = [
137
+ argumentRule(isStringLiteral, 0, isSymbolForCallee, SYMBOL_DESCRIPTION_IGNORED),
138
+ argumentRule(isStringLiteral, 0, isTaggedFactoryCallee, TAGGED_TAG_IGNORED),
139
+ argumentRule(isObjectExpression, 1, isTaggedFactoryCallee, TAGGED_FIELDS_IGNORED),
140
+ argumentRule(isArrowFunctionExpression, 1, isOptionalWithCallee, OPTIONAL_DEFAULT_IGNORED),
141
+ argumentRule(isDocumentationObject, 0, isAnnotationsCallee, ANNOTATION_OBJECT_IGNORED),
142
+ documentationValueRule,
143
+ templateHeadRule
144
+ ];
145
+ const decideSchemaDeclarationIgnore = (node, parent, grandparent, ancestor) => RULES.find((rule) => rule.matches(node, parent, grandparent, ancestor))?.reason;
146
+ //#endregion
147
+ //#region src/effect-schema-ignorer/index.ts
148
+ const strykerPlugins = [declareValuePlugin(PluginKind.Ignore, "effect-schema-declarations", { shouldIgnore(path) {
149
+ const parent = path.parentPath;
150
+ const grandparent = parent?.parentPath;
151
+ return decideSchemaDeclarationIgnore(path.node, parent?.node, grandparent?.node, grandparent?.parentPath?.node);
152
+ } })];
153
+ //#endregion
154
+ export { strykerPlugins as t };
@@ -1,2 +1,2 @@
1
- import { t as strykerPlugins } from "./effect-schema-ignorer-D-acEaX8.mjs";
1
+ import { t as strykerPlugins } from "./effect-schema-ignorer-C8RpAXcS.mjs";
2
2
  export { strykerPlugins };
package/dist/index.mjs CHANGED
@@ -1,5 +1,53 @@
1
- import { t as strykerPlugins$1 } from "./effect-schema-ignorer-D-acEaX8.mjs";
1
+ import { t as strykerPlugins$2 } from "./effect-schema-ignorer-C8RpAXcS.mjs";
2
+ import { PluginKind, declareValuePlugin } from "@stryker-mutator/api/plugin";
3
+ import { Schema } from "effect";
4
+ //#region src/in-source-test-ignorer/ast-node.schema.ts
5
+ const Identifier = Schema.Struct({
6
+ type: Schema.Literal("Identifier"),
7
+ name: Schema.String
8
+ });
9
+ const AstLike = Schema.Struct({ type: Schema.String });
10
+ const MetaProperty = Schema.Struct({
11
+ type: Schema.Literal("MetaProperty"),
12
+ meta: Identifier,
13
+ property: Identifier
14
+ });
15
+ const ImportMetaMember = Schema.Struct({
16
+ type: Schema.Literal("MemberExpression"),
17
+ object: MetaProperty,
18
+ property: Identifier
19
+ });
20
+ const BinaryExpression = Schema.Struct({
21
+ type: Schema.Literal("BinaryExpression"),
22
+ left: AstLike,
23
+ right: AstLike
24
+ });
25
+ const IfStatement = Schema.Struct({
26
+ type: Schema.Literal("IfStatement"),
27
+ test: AstLike
28
+ });
29
+ //#endregion
30
+ //#region src/in-source-test-ignorer/in-source-test-ignore.ts
31
+ const IN_SOURCE_TEST_IGNORED = "inside an `if (import.meta.vitest)` block — test code, not production behaviour";
32
+ const isImportMetaMember = Schema.is(ImportMetaMember);
33
+ const isBinaryExpression = Schema.is(BinaryExpression);
34
+ const isIfStatement = Schema.is(IfStatement);
35
+ const isImportMetaVitest = (node) => isImportMetaMember(node) && node.object.meta.name === "import" && node.object.property.name === "meta" && node.property.name === "vitest";
36
+ const guardsOnImportMetaVitest = (test) => isImportMetaVitest(test) || isBinaryExpression(test) && (isImportMetaVitest(test.left) || isImportMetaVitest(test.right));
37
+ const isInSourceTestGuard = (node) => isIfStatement(node) && guardsOnImportMetaVitest(node.test);
38
+ const decideInSourceTestIgnore = (ancestors) => {
39
+ for (const ancestor of ancestors) if (isInSourceTestGuard(ancestor)) return IN_SOURCE_TEST_IGNORED;
40
+ };
41
+ //#endregion
42
+ //#region src/in-source-test-ignorer/index.ts
43
+ function* ancestorsOf(path) {
44
+ for (let current = path.parentPath; current; current = current.parentPath) yield current.node;
45
+ }
46
+ const strykerPlugins$1 = [declareValuePlugin(PluginKind.Ignore, "in-source-vitest-block", { shouldIgnore(path) {
47
+ return decideInSourceTestIgnore(ancestorsOf(path));
48
+ } })];
49
+ //#endregion
2
50
  //#region src/mod.ts
3
- const strykerPlugins = [...strykerPlugins$1];
51
+ const strykerPlugins = [...strykerPlugins$2, ...strykerPlugins$1];
4
52
  //#endregion
5
53
  export { strykerPlugins };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@systemfsoftware/stryker-plugins",
3
3
  "license": "MIT",
4
- "version": "0.6.0",
4
+ "version": "0.8.0",
5
5
  "author": "Ryan Lee <drdgvhbh@gmail.com>",
6
6
  "repository": {
7
7
  "type": "git",
@@ -53,8 +53,8 @@
53
53
  "vitest": "^4",
54
54
  "@systemfsoftware/arethetypeswrong-cli": "^1.0.7",
55
55
  "@systemfsoftware/effect-schema-law": "^0.5.0",
56
- "@systemfsoftware/effect-schema-vite": "^1.3.0",
57
56
  "@systemfsoftware/oxlint-config": "^0.1.0",
57
+ "@systemfsoftware/effect-schema-vite": "^1.4.0",
58
58
  "@systemfsoftware/stryker-js-core": "^1.1.6",
59
59
  "@systemfsoftware/tsconfig": "^1.2.6",
60
60
  "@systemfsoftware/vitest-config": "^0.1.0",
@@ -1,78 +0,0 @@
1
- import { PluginKind, declareValuePlugin } from "@stryker-mutator/api/plugin";
2
- import { Schema } from "effect";
3
- //#region src/effect-schema-ignorer/ast-node.schema.ts
4
- const Identifier = Schema.Struct({
5
- type: Schema.Literal("Identifier"),
6
- name: Schema.String
7
- });
8
- const StringLiteral = Schema.Struct({
9
- type: Schema.Literal("StringLiteral"),
10
- value: Schema.String
11
- });
12
- const ObjectExpression = Schema.Struct({ type: Schema.Literal("ObjectExpression") });
13
- const ArrowFunctionExpression = Schema.Struct({ type: Schema.Literal("ArrowFunctionExpression") });
14
- const UnknownNode = Schema.Struct({ type: Schema.String });
15
- const MemberExpression = Schema.suspend(() => Schema.Struct({
16
- type: Schema.Literal("MemberExpression"),
17
- object: Schema.suspend(() => AstNode),
18
- property: Schema.suspend(() => AstNode)
19
- }));
20
- const CallExpression = Schema.suspend(() => Schema.Struct({
21
- type: Schema.Literal("CallExpression"),
22
- callee: Schema.suspend(() => AstNode),
23
- arguments: Schema.Array(Schema.suspend(() => AstNode))
24
- }));
25
- const AstNode = Schema.suspend(() => Schema.Union(Identifier, StringLiteral, ObjectExpression, ArrowFunctionExpression, MemberExpression, CallExpression, UnknownNode));
26
- //#endregion
27
- //#region src/effect-schema-ignorer/schema-declaration-ignore.ts
28
- const SYMBOL_DESCRIPTION_IGNORED = "Symbol.for() brand description is identity-only data, not behaviour";
29
- const TAGGED_TAG_IGNORED = "TaggedClass/TaggedError _tag is a declaration discriminant, not behaviour";
30
- const TAGGED_FIELDS_IGNORED = "TaggedClass/TaggedError field schema is a declaration, not behaviour";
31
- const OPTIONAL_DEFAULT_IGNORED = "optionalWith default value is config, not behaviour";
32
- const TAGGED_FACTORIES = ["TaggedClass", "TaggedError"];
33
- const isIdentifier = Schema.is(Identifier);
34
- const isStringLiteral = Schema.is(StringLiteral);
35
- const isObjectExpression = Schema.is(ObjectExpression);
36
- const isArrowFunctionExpression = Schema.is(ArrowFunctionExpression);
37
- const isMemberExpression = Schema.is(MemberExpression);
38
- const isCallExpression = Schema.is(CallExpression);
39
- const isNamedMember = (node, object, property) => isMemberExpression(node) && isIdentifier(node.object) && node.object.name === object && isIdentifier(node.property) && node.property.name === property;
40
- const isSymbolForCallee = (callee) => isNamedMember(callee, "Symbol", "for");
41
- const isTaggedFactoryReference = (reference) => isMemberExpression(reference) && isIdentifier(reference.property) && TAGGED_FACTORIES.includes(reference.property.name);
42
- const isTaggedFactoryCallee = (callee) => isCallExpression(callee) && isTaggedFactoryReference(callee.callee);
43
- const isArgumentOf = (node, parent, index, calleeMatches) => isCallExpression(parent) && calleeMatches(parent.callee) && parent.arguments[index] === node;
44
- const isOptionalWithCallee = (callee) => isNamedMember(callee, "S", "optionalWith");
45
- const RULES = [
46
- {
47
- is: isStringLiteral,
48
- argumentIndex: 0,
49
- calleeMatches: isSymbolForCallee,
50
- reason: SYMBOL_DESCRIPTION_IGNORED
51
- },
52
- {
53
- is: isStringLiteral,
54
- argumentIndex: 0,
55
- calleeMatches: isTaggedFactoryCallee,
56
- reason: TAGGED_TAG_IGNORED
57
- },
58
- {
59
- is: isObjectExpression,
60
- argumentIndex: 1,
61
- calleeMatches: isTaggedFactoryCallee,
62
- reason: TAGGED_FIELDS_IGNORED
63
- },
64
- {
65
- is: isArrowFunctionExpression,
66
- argumentIndex: 1,
67
- calleeMatches: isOptionalWithCallee,
68
- reason: OPTIONAL_DEFAULT_IGNORED
69
- }
70
- ];
71
- const decideSchemaDeclarationIgnore = (node, parent) => RULES.find((rule) => rule.is(node) && isArgumentOf(node, parent, rule.argumentIndex, rule.calleeMatches))?.reason;
72
- //#endregion
73
- //#region src/effect-schema-ignorer/index.ts
74
- const strykerPlugins = [declareValuePlugin(PluginKind.Ignore, "effect-schema-declarations", { shouldIgnore(path) {
75
- return decideSchemaDeclarationIgnore(path.node, path.parentPath?.node);
76
- } })];
77
- //#endregion
78
- export { strykerPlugins as t };