@sasjs/lint 4.3.1 → 4.3.2
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/.github/CONTRIBUTING.md +45 -0
- package/.github/workflows/mutation-pr.yml +69 -0
- package/.github/workflows/mutation-weekly.yml +92 -0
- package/jest.config.js +49 -0
- package/jest.stryker.config.js +17 -0
- package/mutation-baseline.json +30 -0
- package/package.json +14 -2
- package/scripts/mutation-gate.mjs +390 -0
- package/stryker.conf.json +19 -0
- package/utils/isIgnored.js +18 -3
- package/utils/isIgnored.js.map +1 -1
package/.github/CONTRIBUTING.md
CHANGED
|
@@ -92,6 +92,51 @@ Two habits catch the failures that plain coverage does not:
|
|
|
92
92
|
- **Mutation-check your assertions.** Revert the behaviour one piece at a time and confirm a test fails. An assertion that passes on the broken code is documenting nothing. Watch for vacuous cases: for example, a "does not throw" test over a mis-split file passes while the mis-split still contains the string being searched for, so the fixture has to be shaped to fail.
|
|
93
93
|
- **A negative test must fail for the reason it names.** Assert on the specific message or error, never a bare `toThrow()`.
|
|
94
94
|
|
|
95
|
+
### Mutation testing
|
|
96
|
+
|
|
97
|
+
The habit above is automated, because the mutant you think to try is rarely the
|
|
98
|
+
one that matters. Stryker rewrites the source in small ways (an operator, a
|
|
99
|
+
string, a branch) and runs the tests; a mutant that survives is behaviour no
|
|
100
|
+
test asserts.
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
npm run mutation:changed # only the files this branch changed, vs the baseline
|
|
104
|
+
MUTATION_FILES=src/utils/parseMacroCalls.ts npm run mutation:changed # one file
|
|
105
|
+
npm run mutation # whatever stryker.conf.json selects
|
|
106
|
+
npm run mutation:watch # the whole tree; prints a refreshed baseline
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
The gate compares each file with `mutation-baseline.json` and fails when a file
|
|
110
|
+
carries MORE surviving mutants than the baseline records, so it asks "did this
|
|
111
|
+
branch add an untested behaviour?", not "is the repo perfect yet". A file with no
|
|
112
|
+
baseline must reach 80%. `mutation-pr.yml` runs the changed-file gate on every
|
|
113
|
+
pull request; `mutation-weekly.yml` runs the whole tree and fails if anything
|
|
114
|
+
regressed, which is how the baseline is refreshed (the run prints the new JSON to
|
|
115
|
+
paste in).
|
|
116
|
+
|
|
117
|
+
Two things to know before running it:
|
|
118
|
+
|
|
119
|
+
- the dev tree needs Node 22.18 or newer: `@stryker-mutator/core` pulls `@babel/*` 8,
|
|
120
|
+
whose `engines` start at 22.18. `package.json` records that in `devEngines` (dev installs
|
|
121
|
+
warn, published installs are unaffected - the tarball carries no devDependencies).
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
- `jest.stryker.config.js` differs from `jest.config.js` in switching coverage
|
|
125
|
+
off, and in nothing else: a skipped spec records the mutants it covers as
|
|
126
|
+
legitimate survivors, and the weekly refresh writes them into the baseline.
|
|
127
|
+
`isIgnored` therefore derives its path with `path.relative(root, file)` and
|
|
128
|
+
returns false for a path outside the root, and `isIgnored.spec.ts` pins
|
|
129
|
+
`getProjectRoot()` with a jest mock - the `ignore` package rejects an absolute
|
|
130
|
+
or `..`-prefixed path, which is what a sandbox run produced before.
|
|
131
|
+
- a run mutates the file(s) in a sandbox under `.stryker-tmp`, and `jest.config.js`
|
|
132
|
+
ignores that directory by a `<rootDir>`-anchored pattern - an unanchored one
|
|
133
|
+
makes Stryker's own dry run collect nothing.
|
|
134
|
+
|
|
135
|
+
A surviving mutant is either a missing test or an EQUIVALENT mutant (a change
|
|
136
|
+
that cannot alter behaviour, such as dropping a check the following regular
|
|
137
|
+
expression repeats). Add the spec that fails on it, or say in the PR body why it
|
|
138
|
+
is equivalent - do not lower the baseline.
|
|
139
|
+
|
|
95
140
|
## Regenerating the shipped macro list
|
|
96
141
|
|
|
97
142
|
The macros SAS ships are language data, owned by `@sasjs/sas-language`. Do not hand-write them here. `npm run generate:macros` reads its `macroStatements` and `macroFunctions` groups and writes `src/utils/sasMacros.generated.ts`, which is committed so the published package carries no runtime dependency. `sasMacros.spec.ts` reads the same data independently and fails when the committed list differs, which is the drift guard. Extending that vocabulary is a PR to `@sasjs/sas-language`.
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
name: Mutation (changed files)
|
|
2
|
+
|
|
3
|
+
# Mutation testing answers a question coverage cannot: does any test FAIL when
|
|
4
|
+
# the code is wrong? Stryker flips an operator or a string in the source and runs
|
|
5
|
+
# the tests; a mutant that survives is behaviour nothing asserts.
|
|
6
|
+
#
|
|
7
|
+
# This job mutates only the source files the PR changed, and compares them with
|
|
8
|
+
# mutation-baseline.json - so it asks "did this branch add an untested
|
|
9
|
+
# behaviour?", not "is the repo perfect yet". The baseline is refreshed by
|
|
10
|
+
# mutation-weekly.yml.
|
|
11
|
+
#
|
|
12
|
+
# Cost control: Stryker runs the tests that cover the mutated file
|
|
13
|
+
# (`enableFindRelatedTests`), and only the mutated file is instrumented. A
|
|
14
|
+
# two-file run takes minutes, not the hours a whole-suite run per mutant would.
|
|
15
|
+
|
|
16
|
+
on:
|
|
17
|
+
pull_request:
|
|
18
|
+
paths:
|
|
19
|
+
- 'src/**'
|
|
20
|
+
- 'jest.config.js'
|
|
21
|
+
- 'jest.stryker.config.js'
|
|
22
|
+
- 'stryker.conf.json'
|
|
23
|
+
- 'mutation-baseline.json'
|
|
24
|
+
- 'scripts/mutation-gate.mjs'
|
|
25
|
+
- 'package.json'
|
|
26
|
+
- 'package-lock.json'
|
|
27
|
+
|
|
28
|
+
# a second push supersedes the first run rather than queueing behind it
|
|
29
|
+
concurrency:
|
|
30
|
+
group: mutation-${{ github.event.pull_request.number }}
|
|
31
|
+
cancel-in-progress: true
|
|
32
|
+
|
|
33
|
+
jobs:
|
|
34
|
+
mutation:
|
|
35
|
+
runs-on: ubuntu-latest
|
|
36
|
+
# checkout needs read, and the job writes nothing else
|
|
37
|
+
permissions:
|
|
38
|
+
contents: read
|
|
39
|
+
# a changed-file run takes minutes; the default cap is six hours, which would
|
|
40
|
+
# hold a runner on a stuck mutation run
|
|
41
|
+
timeout-minutes: 20
|
|
42
|
+
|
|
43
|
+
steps:
|
|
44
|
+
- uses: actions/checkout@v4
|
|
45
|
+
with:
|
|
46
|
+
# the diff against the base commit needs the base commit
|
|
47
|
+
fetch-depth: 0
|
|
48
|
+
|
|
49
|
+
- name: Use Node.js
|
|
50
|
+
uses: actions/setup-node@v4
|
|
51
|
+
with:
|
|
52
|
+
node-version: 22
|
|
53
|
+
cache: npm
|
|
54
|
+
|
|
55
|
+
- name: Install Dependencies
|
|
56
|
+
run: npm ci
|
|
57
|
+
|
|
58
|
+
- name: Mutate the changed files
|
|
59
|
+
env:
|
|
60
|
+
MUTATION_BASE: ${{ github.event.pull_request.base.sha }}
|
|
61
|
+
run: node scripts/mutation-gate.mjs pr
|
|
62
|
+
|
|
63
|
+
- name: Keep the report
|
|
64
|
+
if: always()
|
|
65
|
+
uses: actions/upload-artifact@v4
|
|
66
|
+
with:
|
|
67
|
+
name: mutation-report
|
|
68
|
+
path: reports/mutation/mutation.json
|
|
69
|
+
if-no-files-found: ignore
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
name: Mutation Watch
|
|
2
|
+
|
|
3
|
+
# The per-PR mutation gate compares a branch's changed files with a baseline, so
|
|
4
|
+
# the baseline is the thing that has to stay honest: if it is never refreshed,
|
|
5
|
+
# files that improve keep their old count and the gate stops asking for more.
|
|
6
|
+
#
|
|
7
|
+
# This runs the whole source tree weekly, prints the new baseline, and fails when
|
|
8
|
+
# any file carries MORE surviving mutants than the recorded one - which means a
|
|
9
|
+
# PR was merged without the gate seeing it (a push to main, a config change, a
|
|
10
|
+
# file the gate skipped). Failure files an issue, as audit-watch.yml does, and
|
|
11
|
+
# only a genuine regression does so.
|
|
12
|
+
|
|
13
|
+
on:
|
|
14
|
+
schedule:
|
|
15
|
+
- cron: '0 7 * * 1' # Mondays, 07:00 UTC
|
|
16
|
+
workflow_dispatch:
|
|
17
|
+
|
|
18
|
+
jobs:
|
|
19
|
+
watch:
|
|
20
|
+
runs-on: ubuntu-latest
|
|
21
|
+
# the whole tree takes ~20-30 minutes; bounded so a hang cannot hold a runner
|
|
22
|
+
# for the six-hour default
|
|
23
|
+
timeout-minutes: 90
|
|
24
|
+
permissions:
|
|
25
|
+
contents: read
|
|
26
|
+
issues: write
|
|
27
|
+
|
|
28
|
+
steps:
|
|
29
|
+
- uses: actions/checkout@v4
|
|
30
|
+
|
|
31
|
+
- name: Use Node.js
|
|
32
|
+
uses: actions/setup-node@v4
|
|
33
|
+
with:
|
|
34
|
+
node-version: 22
|
|
35
|
+
cache: npm
|
|
36
|
+
|
|
37
|
+
- name: Install Dependencies
|
|
38
|
+
run: npm ci
|
|
39
|
+
|
|
40
|
+
- name: Mutate the whole source tree
|
|
41
|
+
id: watch
|
|
42
|
+
env:
|
|
43
|
+
MUTATION_WATCH_REPORT: ${{ runner.temp }}/mutation-watch-report.md
|
|
44
|
+
run: node scripts/mutation-gate.mjs weekly
|
|
45
|
+
|
|
46
|
+
- name: Keep the report
|
|
47
|
+
if: always()
|
|
48
|
+
uses: actions/upload-artifact@v4
|
|
49
|
+
with:
|
|
50
|
+
name: mutation-watch-report
|
|
51
|
+
path: |
|
|
52
|
+
reports/mutation/mutation.json
|
|
53
|
+
${{ runner.temp }}/mutation-watch-report.md
|
|
54
|
+
if-no-files-found: ignore
|
|
55
|
+
|
|
56
|
+
report:
|
|
57
|
+
needs: watch
|
|
58
|
+
# a failure files an issue; a cancelled run does so only when the schedule
|
|
59
|
+
# cancelled it (the 90-minute timeout), so cancelling a manual run by hand
|
|
60
|
+
# does not claim a regression that never happened
|
|
61
|
+
if: >-
|
|
62
|
+
always() &&
|
|
63
|
+
(needs.watch.result == 'failure' ||
|
|
64
|
+
(needs.watch.result == 'cancelled' && github.event_name == 'schedule'))
|
|
65
|
+
runs-on: ubuntu-latest
|
|
66
|
+
timeout-minutes: 5
|
|
67
|
+
permissions:
|
|
68
|
+
contents: read
|
|
69
|
+
issues: write
|
|
70
|
+
|
|
71
|
+
steps:
|
|
72
|
+
- name: File an issue
|
|
73
|
+
env:
|
|
74
|
+
GH_TOKEN: ${{ github.token }}
|
|
75
|
+
run: |
|
|
76
|
+
title='Mutation watch: a file carries more surviving mutants than the baseline'
|
|
77
|
+
existing=$(gh issue list --state open --search "\"$title\" in:title" \
|
|
78
|
+
--json number --jq '.[0].number // empty')
|
|
79
|
+
|
|
80
|
+
if [ -n "$existing" ]; then
|
|
81
|
+
echo "already tracked in #$existing"
|
|
82
|
+
exit 0
|
|
83
|
+
fi
|
|
84
|
+
|
|
85
|
+
gh issue create --title "$title" --body \
|
|
86
|
+
"The weekly mutation run found a file carrying more surviving mutants than mutation-baseline.json records, so behaviour was merged without a test that fails on it.
|
|
87
|
+
|
|
88
|
+
The run names the file and each surviving mutant (file:line:column, mutator, replacement), and prints the refreshed baseline:
|
|
89
|
+
|
|
90
|
+
$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID
|
|
91
|
+
|
|
92
|
+
Close this once a spec kills them and mutation-baseline.json is updated from that run."
|
package/jest.config.js
CHANGED
|
@@ -1,12 +1,61 @@
|
|
|
1
1
|
module.exports = {
|
|
2
2
|
preset: 'ts-jest',
|
|
3
3
|
testEnvironment: 'node',
|
|
4
|
+
// A Stryker run keeps a sandbox under .stryker-tmp with a copy of src/ in it.
|
|
5
|
+
// Without this, any jest run that overlaps a mutation run collects every spec
|
|
6
|
+
// twice (measured: 946 tests instead of 464) and reports failures from files
|
|
7
|
+
// that are being mutated. Anchored to <rootDir> on purpose: inside the sandbox
|
|
8
|
+
// the whole project IS under .stryker-tmp, and an unanchored pattern would make
|
|
9
|
+
// Stryker's own dry run collect nothing ("No tests were executed").
|
|
10
|
+
testPathIgnorePatterns: [
|
|
11
|
+
'/node_modules/',
|
|
12
|
+
'<rootDir>/.stryker-tmp/',
|
|
13
|
+
'<rootDir>/build/'
|
|
14
|
+
],
|
|
4
15
|
coverageThreshold: {
|
|
5
16
|
global: {
|
|
6
17
|
branches: 80,
|
|
7
18
|
functions: 80,
|
|
8
19
|
lines: 80,
|
|
9
20
|
statements: -10
|
|
21
|
+
},
|
|
22
|
+
// Per-path floors, so a change that hollows out one area cannot hide behind
|
|
23
|
+
// the aggregate. Every number is the coverage measured when this was written,
|
|
24
|
+
// rounded down, so the block is a ratchet: raise one when the area improves,
|
|
25
|
+
// never lower one to get a branch green. `statements` is a PERCENTAGE unless
|
|
26
|
+
// it is negative, where `-N` reads as "at most N uncovered statements" - so a
|
|
27
|
+
// plain `0` asserts nothing at all (0% coverage passes), which is why the two
|
|
28
|
+
// fully-covered areas ask for 100 and the three with known gaps carry a budget
|
|
29
|
+
// measured from the tree on the day this landed.
|
|
30
|
+
'./src/utils/': {
|
|
31
|
+
branches: 95,
|
|
32
|
+
functions: 100,
|
|
33
|
+
lines: 99,
|
|
34
|
+
statements: -4
|
|
35
|
+
},
|
|
36
|
+
'./src/rules/': {
|
|
37
|
+
branches: 95,
|
|
38
|
+
functions: 100,
|
|
39
|
+
lines: 100,
|
|
40
|
+
statements: 100
|
|
41
|
+
},
|
|
42
|
+
'./src/lint/': {
|
|
43
|
+
branches: 85,
|
|
44
|
+
functions: 100,
|
|
45
|
+
lines: 100,
|
|
46
|
+
statements: -2
|
|
47
|
+
},
|
|
48
|
+
'./src/format/': {
|
|
49
|
+
branches: 90,
|
|
50
|
+
functions: 100,
|
|
51
|
+
lines: 100,
|
|
52
|
+
statements: 100
|
|
53
|
+
},
|
|
54
|
+
'./src/types/': {
|
|
55
|
+
branches: 95,
|
|
56
|
+
functions: 100,
|
|
57
|
+
lines: 96,
|
|
58
|
+
statements: -4
|
|
10
59
|
}
|
|
11
60
|
},
|
|
12
61
|
collectCoverageFrom: ['src/**/{!(index|formatExample|lintExample),}.ts']
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Jest config for Stryker runs.
|
|
3
|
+
*
|
|
4
|
+
* The only difference from jest.config.js is that coverage is off: Stryker
|
|
5
|
+
* instruments the code itself, and the coverage thresholds would fail a
|
|
6
|
+
* single-mutant run. The suite itself runs in full, so the weekly baseline is
|
|
7
|
+
* built on every test - a skipped spec here would record mutants it covers as
|
|
8
|
+
* legitimate survivors and let them into the baseline permanently.
|
|
9
|
+
*/
|
|
10
|
+
const base = require('./jest.config.js')
|
|
11
|
+
|
|
12
|
+
module.exports = {
|
|
13
|
+
...base,
|
|
14
|
+
collectCoverage: false,
|
|
15
|
+
coverageThreshold: undefined,
|
|
16
|
+
collectCoverageFrom: undefined
|
|
17
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "Surviving-mutant counts per file, used by scripts/mutation-gate.mjs. Refresh from the output of npm run mutation:watch; raise a count only with a reason in the PR that raises it.",
|
|
3
|
+
"generatedAt": "2026-10-25T20:06:38Z",
|
|
4
|
+
"files": {
|
|
5
|
+
"src/rules/file/noUnusedMacros.ts": {
|
|
6
|
+
"score": 100.0,
|
|
7
|
+
"survived": 0,
|
|
8
|
+
"killed": 37,
|
|
9
|
+
"timeout": 0
|
|
10
|
+
},
|
|
11
|
+
"src/utils/getIndicesOf.ts": {
|
|
12
|
+
"score": 100.0,
|
|
13
|
+
"survived": 0,
|
|
14
|
+
"killed": 15,
|
|
15
|
+
"timeout": 6
|
|
16
|
+
},
|
|
17
|
+
"src/utils/isIgnored.ts": {
|
|
18
|
+
"score": 100.0,
|
|
19
|
+
"survived": 0,
|
|
20
|
+
"killed": 25,
|
|
21
|
+
"timeout": 0
|
|
22
|
+
},
|
|
23
|
+
"src/utils/parseMacroCalls.ts": {
|
|
24
|
+
"score": 92.98,
|
|
25
|
+
"survived": 25,
|
|
26
|
+
"killed": 293,
|
|
27
|
+
"timeout": 38
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
package/package.json
CHANGED
|
@@ -12,7 +12,10 @@
|
|
|
12
12
|
"publishInit": "cp -r ./build/* . && rm -rf ./build && rm -rf ./src && rm tsconfig.json",
|
|
13
13
|
"package:lib": "npm run build && cp ./package.json ./checkNodeVersion.js build && cp README.md build && cd build && npm version \"5.0.0\" && npm pack",
|
|
14
14
|
"lint:fix": "npx prettier --write \"{src,test}/**/*.{ts,tsx,js,jsx,html,css,sass,less,json,yml,md,graphql}\"",
|
|
15
|
-
"lint": "npx prettier --check \"{src,test}/**/*.{ts,tsx,js,jsx,html,css,sass,less,json,yml,md,graphql}\""
|
|
15
|
+
"lint": "npx prettier --check \"{src,test}/**/*.{ts,tsx,js,jsx,html,css,sass,less,json,yml,md,graphql}\"",
|
|
16
|
+
"mutation": "stryker run",
|
|
17
|
+
"mutation:changed": "node scripts/mutation-gate.mjs pr",
|
|
18
|
+
"mutation:watch": "node scripts/mutation-gate.mjs weekly"
|
|
16
19
|
},
|
|
17
20
|
"publishConfig": {
|
|
18
21
|
"access": "public"
|
|
@@ -62,6 +65,8 @@
|
|
|
62
65
|
"devDependencies": {
|
|
63
66
|
"@nogoo9/gitleaks": "8.30.1-post.2",
|
|
64
67
|
"@sasjs/sas-language": "0.3.0",
|
|
68
|
+
"@stryker-mutator/core": "10.1.1",
|
|
69
|
+
"@stryker-mutator/jest-runner": "10.1.1",
|
|
65
70
|
"@types/jest": "30.0.0",
|
|
66
71
|
"@types/node": "26.6.3",
|
|
67
72
|
"jest": "30.5.2",
|
|
@@ -73,5 +78,12 @@
|
|
|
73
78
|
"@sasjs/utils": "4.0.0",
|
|
74
79
|
"ignore": "7.0.11"
|
|
75
80
|
},
|
|
76
|
-
"version": "4.3.
|
|
81
|
+
"version": "4.3.2",
|
|
82
|
+
"devEngines": {
|
|
83
|
+
"runtime": {
|
|
84
|
+
"name": "node",
|
|
85
|
+
"version": ">=22.18",
|
|
86
|
+
"onFail": "warn"
|
|
87
|
+
}
|
|
88
|
+
}
|
|
77
89
|
}
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
// Runs mutation testing over the files a branch changed, and fails when a file
|
|
2
|
+
// carries more surviving mutants than the recorded baseline.
|
|
3
|
+
//
|
|
4
|
+
// node scripts/mutation-gate.mjs pr # changed files, compare to baseline
|
|
5
|
+
// node scripts/mutation-gate.mjs weekly # whole src, print the new baseline
|
|
6
|
+
//
|
|
7
|
+
// Set MUTATION_FILES to run the gate on named files instead of the diff - the
|
|
8
|
+
// way to check a file you are working on before committing it:
|
|
9
|
+
//
|
|
10
|
+
// MUTATION_FILES=src/utils/parseMacroCalls.ts node scripts/mutation-gate.mjs pr
|
|
11
|
+
//
|
|
12
|
+
// A surviving mutant is a change to the code that no test noticed. The baseline
|
|
13
|
+
// records how many each file already carries, so the gate asks "did this branch
|
|
14
|
+
// add an untested behaviour?", not "is the repo perfect yet" - a threshold on
|
|
15
|
+
// the absolute score would fail every PR until the backlog is cleared.
|
|
16
|
+
import { execFileSync } from 'node:child_process'
|
|
17
|
+
import fs from 'node:fs'
|
|
18
|
+
import path from 'node:path'
|
|
19
|
+
|
|
20
|
+
const BASELINE_FILE = 'mutation-baseline.json'
|
|
21
|
+
const REPORT_FILE = 'reports/mutation/mutation.json'
|
|
22
|
+
const NEW_FILE_FLOOR = 80
|
|
23
|
+
const BASELINE_COMMENT =
|
|
24
|
+
'Surviving-mutant counts per file, used by scripts/mutation-gate.mjs. Refresh from the output of npm run mutation:watch; raise a count only with a reason in the PR that raises it.'
|
|
25
|
+
|
|
26
|
+
const mode = process.argv[2] || 'pr'
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The baseline to police a branch against.
|
|
30
|
+
*
|
|
31
|
+
* With a ref, this is the copy on the BASE branch, read out of git rather than
|
|
32
|
+
* taken from the working tree: the file travels inside the PR, so a branch that
|
|
33
|
+
* raises the number it is measured against would otherwise pass, and the weekly
|
|
34
|
+
* watch - which compares against the merged value - would never fire either.
|
|
35
|
+
*/
|
|
36
|
+
const readBaseline = (ref) => {
|
|
37
|
+
if (ref) {
|
|
38
|
+
// A ref that cannot be resolved (a shallow clone, a typo) must fail loudly:
|
|
39
|
+
// swallowing it drops the run to the new-file floor and reports green, which
|
|
40
|
+
// is the same fail-open the changedFiles() handler refuses.
|
|
41
|
+
try {
|
|
42
|
+
execFileSync(
|
|
43
|
+
'git',
|
|
44
|
+
['rev-parse', '--verify', '--quiet', `${ref}^{commit}`],
|
|
45
|
+
{
|
|
46
|
+
stdio: ['ignore', 'ignore', 'ignore']
|
|
47
|
+
}
|
|
48
|
+
)
|
|
49
|
+
} catch (e) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
`cannot resolve the base ref '${ref}' - fetch it (fetch-depth: 0 in CI) or set MUTATION_FILES`
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Does the ref carry a baseline at all? Ask git rather than reading the
|
|
56
|
+
// error text of a failed `git show`, whose wording differs by version and is
|
|
57
|
+
// empty when stderr is swallowed.
|
|
58
|
+
try {
|
|
59
|
+
execFileSync('git', ['cat-file', '-e', `${ref}:${BASELINE_FILE}`], {
|
|
60
|
+
stdio: ['ignore', 'ignore', 'ignore']
|
|
61
|
+
})
|
|
62
|
+
} catch (e) {
|
|
63
|
+
// the ref resolves but carried no baseline: the PR that adds the file
|
|
64
|
+
return null
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const raw = execFileSync('git', ['show', `${ref}:${BASELINE_FILE}`], {
|
|
69
|
+
encoding: 'utf8',
|
|
70
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
71
|
+
})
|
|
72
|
+
return JSON.parse(raw)
|
|
73
|
+
} catch (e) {
|
|
74
|
+
const detail = (e.stderr && e.stderr.toString().trim()) || e.message
|
|
75
|
+
throw new Error(`cannot read ${BASELINE_FILE} at '${ref}': ${detail}`)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (!fs.existsSync(BASELINE_FILE)) return { files: {} }
|
|
80
|
+
return JSON.parse(fs.readFileSync(BASELINE_FILE, 'utf8'))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The ref a branch is policed against: MUTATION_BASE when set, otherwise the
|
|
85
|
+
* merge base with origin/main, so the raise-check also fires for the documented
|
|
86
|
+
* local run (`npm run mutation:changed` with no MUTATION_BASE) instead of
|
|
87
|
+
* comparing the working copy against itself.
|
|
88
|
+
*/
|
|
89
|
+
const resolveBaseRef = () => {
|
|
90
|
+
if (process.env.MUTATION_BASE) return process.env.MUTATION_BASE
|
|
91
|
+
try {
|
|
92
|
+
return execFileSync('git', ['merge-base', 'HEAD', 'origin/main'], {
|
|
93
|
+
encoding: 'utf8',
|
|
94
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
95
|
+
}).trim()
|
|
96
|
+
} catch (e) {
|
|
97
|
+
return null
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The source files this branch changed, relative to the base branch. */
|
|
102
|
+
const changedFiles = () => {
|
|
103
|
+
const base = resolveBaseRef()
|
|
104
|
+
if (!base) {
|
|
105
|
+
console.error(
|
|
106
|
+
'Could not resolve a base ref (no MUTATION_BASE, no origin/main to merge-base against).'
|
|
107
|
+
)
|
|
108
|
+
process.exit(1)
|
|
109
|
+
}
|
|
110
|
+
let diff = ''
|
|
111
|
+
try {
|
|
112
|
+
diff = execFileSync('git', ['diff', '--name-only', `${base}...HEAD`], {
|
|
113
|
+
encoding: 'utf8'
|
|
114
|
+
})
|
|
115
|
+
} catch (e) {
|
|
116
|
+
// A broken clone or a missing base sha would otherwise look like "no files
|
|
117
|
+
// changed" and turn the gate into a green no-op.
|
|
118
|
+
console.error(`Could not diff against ${base}: ${e.message}`)
|
|
119
|
+
console.error(
|
|
120
|
+
'Fetch the base branch (fetch-depth: 0 in CI) or set MUTATION_FILES.'
|
|
121
|
+
)
|
|
122
|
+
process.exit(1)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const changed = diff
|
|
126
|
+
.split('\n')
|
|
127
|
+
.map((line) => line.trim())
|
|
128
|
+
.filter(Boolean)
|
|
129
|
+
|
|
130
|
+
const mutable = changed
|
|
131
|
+
.filter((line) => line.startsWith('src/'))
|
|
132
|
+
.filter((line) => line.endsWith('.ts'))
|
|
133
|
+
.filter((line) => !line.endsWith('.spec.ts'))
|
|
134
|
+
.filter((line) => fs.existsSync(line))
|
|
135
|
+
|
|
136
|
+
return { changed, mutable }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const runStryker = (mutate) => {
|
|
140
|
+
fs.rmSync('reports/mutation', { recursive: true, force: true })
|
|
141
|
+
execFileSync(
|
|
142
|
+
'npx',
|
|
143
|
+
['stryker', 'run', '--mutate', mutate.join(','), '--reporters', 'json'],
|
|
144
|
+
{ stdio: 'inherit' }
|
|
145
|
+
)
|
|
146
|
+
return JSON.parse(fs.readFileSync(REPORT_FILE, 'utf8'))
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Per-file totals from a Stryker JSON report. */
|
|
150
|
+
const summarise = (report) => {
|
|
151
|
+
const files = {}
|
|
152
|
+
for (const [name, file] of Object.entries(report.files)) {
|
|
153
|
+
const mutants = file.mutants || []
|
|
154
|
+
const survived = mutants.filter(
|
|
155
|
+
(m) => m.status === 'Survived' || m.status === 'NoCoverage'
|
|
156
|
+
)
|
|
157
|
+
const killed = mutants.filter((m) => m.status === 'Killed').length
|
|
158
|
+
const timeout = mutants.filter((m) => m.status === 'Timeout').length
|
|
159
|
+
// Ignored (a `// Stryker disable` comment) and CompileError mutants cannot be
|
|
160
|
+
// killed by any test, so counting them in the denominator reports a score
|
|
161
|
+
// below Stryker's own and can fail a file with no real survivor. NoCoverage is
|
|
162
|
+
// NOT one of them: a mutant no test executes is the untested behaviour this
|
|
163
|
+
// gate exists to catch, so it counts against the score and as a survivor.
|
|
164
|
+
const unkillable = mutants.filter(
|
|
165
|
+
(m) => m.status === 'CompileError' || m.status === 'Ignored'
|
|
166
|
+
).length
|
|
167
|
+
const covered = mutants.length - unkillable
|
|
168
|
+
files[name] = {
|
|
169
|
+
// no covered mutants means no test even ran against this file: scoring that
|
|
170
|
+
// 100 would let a brand-new untested file pass the new-file floor
|
|
171
|
+
score:
|
|
172
|
+
covered === 0
|
|
173
|
+
? 0
|
|
174
|
+
: Math.round(((killed + timeout) / covered) * 10000) / 100,
|
|
175
|
+
survived: survived.length,
|
|
176
|
+
killed,
|
|
177
|
+
timeout,
|
|
178
|
+
survivors: survived.map((m) => ({
|
|
179
|
+
line: m.location.start.line,
|
|
180
|
+
column: m.location.start.column,
|
|
181
|
+
mutator: m.mutatorName,
|
|
182
|
+
replacement: m.replacement,
|
|
183
|
+
status: m.status
|
|
184
|
+
}))
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return files
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const describeSurvivor = (file, m) =>
|
|
191
|
+
` ${file}:${m.line}:${m.column} ${m.mutator} -> ${m.replacement}${
|
|
192
|
+
m.status === 'NoCoverage' ? ' (no test executes this line)' : ''
|
|
193
|
+
}`
|
|
194
|
+
|
|
195
|
+
if (mode === 'weekly') {
|
|
196
|
+
const files = summarise(runStryker(['src/**/*.ts', '!src/**/*.spec.ts']))
|
|
197
|
+
|
|
198
|
+
const baseline = readBaseline().files || {}
|
|
199
|
+
|
|
200
|
+
// A file that reached main with no baseline entry - a push that bypassed the PR
|
|
201
|
+
// gate, or a file added before this gate existed - is not reported by a
|
|
202
|
+
// survivors-only comparison. It counts as a regression while it is below the
|
|
203
|
+
// new-file floor.
|
|
204
|
+
const regressions = Object.entries(files).filter(([name, stats]) => {
|
|
205
|
+
const known = baseline[name]
|
|
206
|
+
if (known === undefined) return stats.score < NEW_FILE_FLOOR
|
|
207
|
+
return stats.survived > known.survived
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
console.log('\nPer-file mutation score:')
|
|
211
|
+
for (const [name, stats] of Object.entries(files)) {
|
|
212
|
+
console.log(
|
|
213
|
+
` ${name}: ${stats.score}% (${stats.killed} killed, ${stats.timeout} timeout, ${stats.survived} survived)`
|
|
214
|
+
)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Printed without the per-mutant detail, so a refresh is a diff of counts
|
|
218
|
+
// rather than of every survivor's line and column. The timestamp stays: it is
|
|
219
|
+
// the field a reader uses to judge how stale the counts are.
|
|
220
|
+
const nextBaseline = {
|
|
221
|
+
_comment: BASELINE_COMMENT,
|
|
222
|
+
generatedAt: new Date().toISOString(),
|
|
223
|
+
files: Object.fromEntries(
|
|
224
|
+
Object.entries(files)
|
|
225
|
+
.sort(([a], [b]) => (a < b ? -1 : 1))
|
|
226
|
+
.map(([name, stats]) => [
|
|
227
|
+
name,
|
|
228
|
+
{
|
|
229
|
+
score: stats.score,
|
|
230
|
+
survived: stats.survived,
|
|
231
|
+
killed: stats.killed,
|
|
232
|
+
timeout: stats.timeout
|
|
233
|
+
}
|
|
234
|
+
])
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
const printed = JSON.stringify(nextBaseline, null, 2)
|
|
238
|
+
console.log('\nUpdated baseline (commit this as mutation-baseline.json):')
|
|
239
|
+
console.log(printed)
|
|
240
|
+
|
|
241
|
+
// the weekly workflow keeps the refreshed baseline as a job artifact, so the
|
|
242
|
+
// new counts survive the log
|
|
243
|
+
if (process.env.MUTATION_WATCH_REPORT)
|
|
244
|
+
fs.writeFileSync(process.env.MUTATION_WATCH_REPORT, `${printed}\n`)
|
|
245
|
+
|
|
246
|
+
if (regressions.length) {
|
|
247
|
+
console.error('\nSurviving mutants grew since the baseline:')
|
|
248
|
+
for (const [name, stats] of regressions) {
|
|
249
|
+
const known = baseline[name]
|
|
250
|
+
console.error(
|
|
251
|
+
known === undefined
|
|
252
|
+
? ` ${name}: no baseline entry, scores ${stats.score}% (floor ${NEW_FILE_FLOOR}%)`
|
|
253
|
+
: ` ${name}: ${known.survived} -> ${stats.survived}`
|
|
254
|
+
)
|
|
255
|
+
stats.survivors.forEach((m) => console.error(describeSurvivor(name, m)))
|
|
256
|
+
}
|
|
257
|
+
process.exit(1)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
console.log('\nNo file carries more surviving mutants than the baseline.')
|
|
261
|
+
process.exit(0)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// A PR that changes the gate's own configuration must still exercise it, or a
|
|
265
|
+
// broken config ships green: no source file changed, so the changed-file run has
|
|
266
|
+
// nothing to mutate. The probe is a small file with a baseline entry.
|
|
267
|
+
const PROBE_FILES = ['src/utils/getIndicesOf.ts']
|
|
268
|
+
|
|
269
|
+
const WATCHED_CONFIG = [
|
|
270
|
+
'jest.config.js',
|
|
271
|
+
'jest.stryker.config.js',
|
|
272
|
+
'stryker.conf.json',
|
|
273
|
+
'mutation-baseline.json',
|
|
274
|
+
'scripts/mutation-gate.mjs',
|
|
275
|
+
'package.json',
|
|
276
|
+
'package-lock.json'
|
|
277
|
+
]
|
|
278
|
+
|
|
279
|
+
let mutate = []
|
|
280
|
+
let configOnly = false
|
|
281
|
+
|
|
282
|
+
if (process.env.MUTATION_FILES) {
|
|
283
|
+
mutate = process.env.MUTATION_FILES.split(',')
|
|
284
|
+
.map((f) => f.trim())
|
|
285
|
+
.filter(Boolean)
|
|
286
|
+
} else {
|
|
287
|
+
const { changed, mutable } = changedFiles()
|
|
288
|
+
mutate = mutable
|
|
289
|
+
configOnly =
|
|
290
|
+
!mutable.length && changed.some((f) => WATCHED_CONFIG.includes(f))
|
|
291
|
+
if (configOnly) {
|
|
292
|
+
console.log(
|
|
293
|
+
'Only the gate configuration changed; running it against the probe file.'
|
|
294
|
+
)
|
|
295
|
+
mutate = PROBE_FILES
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (!mutate.length) {
|
|
300
|
+
console.log('No changed source files to mutate.')
|
|
301
|
+
process.exit(0)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
console.log(`Mutating the files this branch changed:\n ${mutate.join('\n ')}`)
|
|
305
|
+
|
|
306
|
+
const files = summarise(runStryker(mutate))
|
|
307
|
+
const baseRef = resolveBaseRef()
|
|
308
|
+
let baseBaseline = null
|
|
309
|
+
if (!baseRef) {
|
|
310
|
+
console.error(
|
|
311
|
+
'Could not resolve a base ref (no MUTATION_BASE, no origin/main to merge-base against).'
|
|
312
|
+
)
|
|
313
|
+
process.exit(1)
|
|
314
|
+
}
|
|
315
|
+
try {
|
|
316
|
+
baseBaseline = readBaseline(baseRef)
|
|
317
|
+
} catch (e) {
|
|
318
|
+
console.error(e.message)
|
|
319
|
+
process.exit(1)
|
|
320
|
+
}
|
|
321
|
+
console.log(`Base ref: ${baseRef}`)
|
|
322
|
+
const headBaseline = readBaseline().files || {}
|
|
323
|
+
const baseline = baseBaseline ? baseBaseline.files || {} : {}
|
|
324
|
+
const failures = []
|
|
325
|
+
|
|
326
|
+
// The baseline travels inside the PR, so a branch can otherwise raise the number
|
|
327
|
+
// it is measured against - and the weekly watch compares against the merged
|
|
328
|
+
// value, so it would stay quiet too.
|
|
329
|
+
if (baseBaseline) {
|
|
330
|
+
Object.entries(headBaseline).forEach(([name, entry]) => {
|
|
331
|
+
const known = baseline[name]
|
|
332
|
+
if (!known) {
|
|
333
|
+
if (!configOnly) console.log(`${name}: added to the baseline in this PR`)
|
|
334
|
+
return
|
|
335
|
+
}
|
|
336
|
+
if (entry.survived > known.survived)
|
|
337
|
+
failures.push(
|
|
338
|
+
`${name}: the baseline was raised in this PR, ${known.survived} -> ${entry.survived} surviving mutants`
|
|
339
|
+
)
|
|
340
|
+
})
|
|
341
|
+
Object.keys(baseline).forEach((name) => {
|
|
342
|
+
if (headBaseline[name]) return
|
|
343
|
+
// A source file deleted or renamed in this PR takes its entry with it; the
|
|
344
|
+
// entry only has to go when the file is still there to be mutated.
|
|
345
|
+
if (!fs.existsSync(name)) {
|
|
346
|
+
console.log(`${name}: deleted, its baseline entry goes with it`)
|
|
347
|
+
return
|
|
348
|
+
}
|
|
349
|
+
failures.push(`${name}: removed from the baseline in this PR`)
|
|
350
|
+
})
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
for (const [name, stats] of Object.entries(files)) {
|
|
354
|
+
const known = baseline[name]
|
|
355
|
+
|
|
356
|
+
if (!known) {
|
|
357
|
+
if (stats.score < NEW_FILE_FLOOR) {
|
|
358
|
+
failures.push(
|
|
359
|
+
`${name} scores ${stats.score}% and has no baseline: a new file must reach ${NEW_FILE_FLOOR}%`
|
|
360
|
+
)
|
|
361
|
+
stats.survivors.forEach((m) => failures.push(describeSurvivor(name, m)))
|
|
362
|
+
} else {
|
|
363
|
+
console.log(`${name}: ${stats.score}% (new file, above the floor)`)
|
|
364
|
+
}
|
|
365
|
+
continue
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (stats.survived > known.survived) {
|
|
369
|
+
failures.push(
|
|
370
|
+
`${name}: ${known.survived} -> ${stats.survived} surviving mutants`
|
|
371
|
+
)
|
|
372
|
+
stats.survivors.forEach((m) => failures.push(describeSurvivor(name, m)))
|
|
373
|
+
} else {
|
|
374
|
+
console.log(
|
|
375
|
+
`${name}: ${stats.score}% (${stats.survived} survived, baseline ${known.survived})`
|
|
376
|
+
)
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (failures.length) {
|
|
381
|
+
console.error('\nMutation gate failed:\n' + failures.join('\n'))
|
|
382
|
+
console.error(
|
|
383
|
+
'\nA surviving mutant is a change no test noticed. Add a spec that fails on it,\n' +
|
|
384
|
+
'then run `npm run mutation:changed` again. If a mutant is genuinely\n' +
|
|
385
|
+
'equivalent (the change cannot alter behaviour), say so in the PR body.'
|
|
386
|
+
)
|
|
387
|
+
process.exit(1)
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
console.log('\nMutation gate passed.')
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://raw.githubusercontent.com/stryker-mutator/stryker-js/master/packages/core/schema/stryker-schema.json",
|
|
3
|
+
"packageManager": "npm",
|
|
4
|
+
"testRunner": "jest",
|
|
5
|
+
"jest": {
|
|
6
|
+
"projectType": "custom",
|
|
7
|
+
"configFile": "jest.stryker.config.js",
|
|
8
|
+
"enableFindRelatedTests": true
|
|
9
|
+
},
|
|
10
|
+
"mutate": ["src/**/*.ts", "!src/**/*.spec.ts"],
|
|
11
|
+
"reporters": ["clear-text", "json"],
|
|
12
|
+
"jsonReporter": { "fileName": "reports/mutation/mutation.json" },
|
|
13
|
+
"coverageAnalysis": "perTest",
|
|
14
|
+
"concurrency": 4,
|
|
15
|
+
"timeoutMS": 30000,
|
|
16
|
+
"thresholds": { "high": 90, "low": 80, "break": null },
|
|
17
|
+
"tempDirName": ".stryker-tmp",
|
|
18
|
+
"cleanTempDir": true
|
|
19
|
+
}
|
package/utils/isIgnored.js
CHANGED
|
@@ -27,9 +27,24 @@ const isIgnored = (fPath, configuration) => __awaiter(void 0, void 0, void 0, fu
|
|
|
27
27
|
const config = configuration || (yield (0, _1.getLintConfig)());
|
|
28
28
|
const projectRoot = yield (0, _1.getProjectRoot)();
|
|
29
29
|
const gitIgnoreFilePath = path_1.default.join(projectRoot, '.gitignore');
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
// path.relative rather than stripping `projectRoot + sep` from the string: a
|
|
31
|
+
// separator or a normalisation the caller did not apply leaves the strip with
|
|
32
|
+
// nothing to remove, and the `ignore` package then receives an absolute path
|
|
33
|
+
// and rejects it with a RangeError. path.relative is lexical - a symlinked root
|
|
34
|
+
// still yields a `..`-prefixed path, which the guard below catches.
|
|
35
|
+
const relativePath = path_1.default.relative(projectRoot, fPath);
|
|
36
|
+
if (relativePath === '')
|
|
37
|
+
return false;
|
|
38
|
+
// On Windows, `path.relative` returns an absolute path when the two paths sit
|
|
39
|
+
// on different drives, which the two guards below cannot see; passing that on
|
|
40
|
+
// reaches `ignore` and throws the RangeError this function exists to avoid.
|
|
41
|
+
if (path_1.default.isAbsolute(relativePath))
|
|
42
|
+
return false;
|
|
43
|
+
// Nothing in a project's .gitignore can match a path outside it, and the
|
|
44
|
+
// `ignore` package rejects a path that escapes the root ("path should be a
|
|
45
|
+
// path.relative()d string"), so a caller passing one gets `false` rather than
|
|
46
|
+
// a RangeError.
|
|
47
|
+
if (relativePath === '..' || relativePath.startsWith(`..${path_1.default.sep}`))
|
|
33
48
|
return false;
|
|
34
49
|
let gitIgnoreFileContent = '';
|
|
35
50
|
if (yield (0, file_1.fileExists)(gitIgnoreFilePath))
|
package/utils/isIgnored.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"isIgnored.js","sourceRoot":"","sources":["../../src/utils/isIgnored.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;AAAA,4CAAwD;AACxD,gDAAuB;AACvB,oDAA2B;AAC3B,wBAAiD;AAGjD;;;;;GAKG;AACI,MAAM,SAAS,GAAG,CACvB,KAAa,EACb,aAA0B,EACR,EAAE;IACpB,MAAM,MAAM,GAAG,aAAa,IAAI,CAAC,MAAM,IAAA,gBAAa,GAAE,CAAC,CAAA;IACvD,MAAM,WAAW,GAAG,MAAM,IAAA,iBAAc,GAAE,CAAA;IAC1C,MAAM,iBAAiB,GAAG,cAAI,CAAC,IAAI,CAAC,WAAW,EAAE,YAAY,CAAC,CAAA;
|
|
1
|
+
{"version":3,"file":"isIgnored.js","sourceRoot":"","sources":["../../src/utils/isIgnored.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;AAAA,4CAAwD;AACxD,gDAAuB;AACvB,oDAA2B;AAC3B,wBAAiD;AAGjD;;;;;GAKG;AACI,MAAM,SAAS,GAAG,CACvB,KAAa,EACb,aAA0B,EACR,EAAE;IACpB,MAAM,MAAM,GAAG,aAAa,IAAI,CAAC,MAAM,IAAA,gBAAa,GAAE,CAAC,CAAA;IACvD,MAAM,WAAW,GAAG,MAAM,IAAA,iBAAc,GAAE,CAAA;IAC1C,MAAM,iBAAiB,GAAG,cAAI,CAAC,IAAI,CAAC,WAAW,EAAE,YAAY,CAAC,CAAA;IAE9D,6EAA6E;IAC7E,8EAA8E;IAC9E,6EAA6E;IAC7E,gFAAgF;IAChF,oEAAoE;IACpE,MAAM,YAAY,GAAG,cAAI,CAAC,QAAQ,CAAC,WAAW,EAAE,KAAK,CAAC,CAAA;IAEtD,IAAI,YAAY,KAAK,EAAE;QAAE,OAAO,KAAK,CAAA;IAErC,8EAA8E;IAC9E,8EAA8E;IAC9E,4EAA4E;IAC5E,IAAI,cAAI,CAAC,UAAU,CAAC,YAAY,CAAC;QAAE,OAAO,KAAK,CAAA;IAE/C,yEAAyE;IACzE,2EAA2E;IAC3E,8EAA8E;IAC9E,gBAAgB;IAChB,IAAI,YAAY,KAAK,IAAI,IAAI,YAAY,CAAC,UAAU,CAAC,KAAK,cAAI,CAAC,GAAG,EAAE,CAAC;QACnE,OAAO,KAAK,CAAA;IAEd,IAAI,oBAAoB,GAAG,EAAE,CAAA;IAE7B,IAAI,MAAM,IAAA,iBAAU,EAAC,iBAAiB,CAAC;QACrC,oBAAoB,GAAG,MAAM,IAAA,eAAQ,EAAC,iBAAiB,CAAC,CAAA;IAE1D,OAAO,IAAA,gBAAM,GAAE;SACZ,GAAG,CAAC,oBAAoB,CAAC;SACzB,GAAG,CAAC,MAAM,CAAC,UAAU,CAAC;SACtB,OAAO,CAAC,YAAY,CAAC,CAAA;AAC1B,CAAC,CAAA,CAAA;AAtCY,QAAA,SAAS,aAsCrB"}
|