covgate 0.0.0-stage → 0.2.1
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/LICENSE +21 -0
- package/README.md +504 -2
- package/bin/covgate-postinstall.js +13 -0
- package/bin/covgate.js +5 -0
- package/docs/ai-workflow.md +197 -0
- package/docs/design.md +327 -0
- package/docs/setup.md +165 -0
- package/package.json +48 -4
- package/schema/covgate.schema.json +295 -0
- package/src/annotations.js +224 -0
- package/src/args.js +189 -0
- package/src/cli.js +486 -0
- package/src/config.js +442 -0
- package/src/coverage/index.js +85 -0
- package/src/coverage/istanbul.js +215 -0
- package/src/coverage/lcov.js +234 -0
- package/src/coverage/model.js +316 -0
- package/src/coverage/paths.js +40 -0
- package/src/errors.js +34 -0
- package/src/gate.js +203 -0
- package/src/glob.js +242 -0
- package/src/index.js +28 -0
- package/src/process.js +170 -0
- package/src/reason.js +77 -0
- package/src/report/build.js +135 -0
- package/src/report/index.js +29 -0
- package/src/report/markdown.js +343 -0
- package/src/report/text.js +267 -0
- package/src/reports.js +113 -0
- package/src/runner.js +95 -0
- package/src/scope.js +440 -0
- package/src/setup/agent.js +329 -0
- package/src/setup/config.js +129 -0
- package/src/setup/index.js +270 -0
- package/src/setup/project.js +391 -0
- package/src/setup/prompt.js +43 -0
- package/src/util.js +163 -0
- package/src/version.js +8 -0
- package/templates/AGENTS.md.snippet.md +10 -0
- package/templates/agent-stop-hook.sh +7 -0
- package/templates/covgate.config.json +98 -0
- package/templates/github-actions-coverage.yml +41 -0
- package/templates/gitignore +5 -0
- package/templates/pre-commit +10 -0
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
const START = "<!-- covgate:start -->";
|
|
6
|
+
const END = "<!-- covgate:end -->";
|
|
7
|
+
|
|
8
|
+
const AGENT_BLOCK = [
|
|
9
|
+
START,
|
|
10
|
+
"## CovGate 覆盖率门禁(完成定义)",
|
|
11
|
+
"",
|
|
12
|
+
"- 修改代码后必须运行 `npx --no-install covgate run --json`;退出码 0 才能结束任务。",
|
|
13
|
+
"- 客户端覆盖率必须 100%,服务端不低于 95%;不得降低 `covgate.config.json` 中的门槛。",
|
|
14
|
+
"- 禁止 `istanbul ignore`、`c8 ignore`、`v8 ignore`、`node:coverage ignore`。",
|
|
15
|
+
"- 确实不可达的代码使用 `covgate-ignore-next-line`、`covgate-ignore-start/end` 或 `covgate-ignore-file`,写具体原因、owner 和 expires。",
|
|
16
|
+
"- 禁止通过扩大 exclude、缩小 sources.include 或删除测试来让门禁通过;这类改动必须由人类评审。",
|
|
17
|
+
"- 行为变更必须新增或更新覆盖该行为的真实测试;不能把“门禁失败”当作完成理由,也不得用空报告、`--passWithNoTests`、ignore 或降低门槛绕过。",
|
|
18
|
+
"- 失败详情在 `.covgate/report.json` 的 scopes/issues/waivers 中;修复后重跑。",
|
|
19
|
+
"",
|
|
20
|
+
"也可运行 `npm run coverage:gate -- --json`。Agent Stop hook 可调用 `scripts/covgate-agent-gate.ps1`(Windows)或 `scripts/covgate-agent-gate.sh`。",
|
|
21
|
+
"<!-- 由 covgate setup 生成;保留标记以便幂等更新。 -->",
|
|
22
|
+
END
|
|
23
|
+
].join("\n");
|
|
24
|
+
|
|
25
|
+
const CURSOR_RULE = [
|
|
26
|
+
"---",
|
|
27
|
+
"description: CovGate coverage gate",
|
|
28
|
+
"alwaysApply: true",
|
|
29
|
+
"---",
|
|
30
|
+
"",
|
|
31
|
+
"# CovGate 覆盖率门禁",
|
|
32
|
+
"",
|
|
33
|
+
"任何代码修改后运行 `npx --no-install covgate run --json`,退出码 0 才能结束。",
|
|
34
|
+
"客户端必须 100%,服务端至少 95%。行为变更必须补真实测试;门禁失败不能作为完成理由。",
|
|
35
|
+
"禁止原生 ignore;豁免必须写原因、owner、expires。",
|
|
36
|
+
""
|
|
37
|
+
].join("\n");
|
|
38
|
+
|
|
39
|
+
const SH_SCRIPT = [
|
|
40
|
+
"#!/bin/sh",
|
|
41
|
+
"# Generated by covgate setup. Use as an agent Stop hook or pre-commit step.",
|
|
42
|
+
"set -eu",
|
|
43
|
+
'root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"',
|
|
44
|
+
'cd "$root"',
|
|
45
|
+
"exec npx --no-install covgate run --json"
|
|
46
|
+
].join("\n") + "\n";
|
|
47
|
+
|
|
48
|
+
const PS1_SCRIPT = [
|
|
49
|
+
"# Generated by covgate setup. Use as an agent Stop hook or pre-commit step.",
|
|
50
|
+
"$ErrorActionPreference = 'Stop'",
|
|
51
|
+
"$root = git rev-parse --show-toplevel 2>$null",
|
|
52
|
+
"if (-not $root) { $root = (Get-Location).Path }",
|
|
53
|
+
"Set-Location $root",
|
|
54
|
+
"npx --no-install covgate run --json",
|
|
55
|
+
"exit $LASTEXITCODE"
|
|
56
|
+
].join("\n") + "\n";
|
|
57
|
+
|
|
58
|
+
function hasManagedBlock(text) {
|
|
59
|
+
return text.includes(START) && text.includes(END);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @param {string} text
|
|
64
|
+
* @param {string} block
|
|
65
|
+
*/
|
|
66
|
+
function replaceManagedBlock(text, block) {
|
|
67
|
+
const start = text.indexOf(START);
|
|
68
|
+
const end = text.indexOf(END);
|
|
69
|
+
if (start === -1 || end === -1 || end < start) return text + "\n\n" + block + "\n";
|
|
70
|
+
return text.slice(0, start) + block + text.slice(end + END.length);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* @param {{
|
|
75
|
+
* root: string,
|
|
76
|
+
* file: string,
|
|
77
|
+
* block: string,
|
|
78
|
+
* force?: boolean,
|
|
79
|
+
* dryRun?: boolean,
|
|
80
|
+
* log: (message: string) => void,
|
|
81
|
+
* changes: string[]
|
|
82
|
+
* }} options
|
|
83
|
+
*/
|
|
84
|
+
async function ensureManagedFile({ root, file, block, force, dryRun, log, changes }) {
|
|
85
|
+
const absolute = path.join(root, file);
|
|
86
|
+
let current = null;
|
|
87
|
+
try {
|
|
88
|
+
current = await readFile(absolute, "utf8");
|
|
89
|
+
} catch (error) {
|
|
90
|
+
if (!error || error.code !== "ENOENT") throw error;
|
|
91
|
+
}
|
|
92
|
+
if (current !== null && hasManagedBlock(current) && !force) {
|
|
93
|
+
log(" = " + file + " 已有 CovGate 区块,跳过");
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const next =
|
|
97
|
+
current === null
|
|
98
|
+
? block + "\n"
|
|
99
|
+
: current.endsWith("\n")
|
|
100
|
+
? current + "\n" + block + "\n"
|
|
101
|
+
: current + "\n\n" + block + "\n";
|
|
102
|
+
const finalContent =
|
|
103
|
+
current !== null && force && hasManagedBlock(current)
|
|
104
|
+
? replaceManagedBlock(current, block)
|
|
105
|
+
: next;
|
|
106
|
+
if (!dryRun) {
|
|
107
|
+
await mkdir(path.dirname(absolute), { recursive: true });
|
|
108
|
+
await writeFile(absolute, finalContent, "utf8");
|
|
109
|
+
}
|
|
110
|
+
changes.push(file);
|
|
111
|
+
log(" + " + file);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* @param {{
|
|
116
|
+
* root: string,
|
|
117
|
+
* file: string,
|
|
118
|
+
* content: string,
|
|
119
|
+
* force?: boolean,
|
|
120
|
+
* dryRun?: boolean,
|
|
121
|
+
* executable?: boolean,
|
|
122
|
+
* log: (message: string) => void,
|
|
123
|
+
* changes: string[]
|
|
124
|
+
* }} options
|
|
125
|
+
*/
|
|
126
|
+
async function ensureNewFile({ root, file, content, force, dryRun, executable, log, changes }) {
|
|
127
|
+
const absolute = path.join(root, file);
|
|
128
|
+
if (existsSync(absolute) && !force) {
|
|
129
|
+
log(" = " + file + " 已存在,保留");
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (!dryRun) {
|
|
133
|
+
await mkdir(path.dirname(absolute), { recursive: true });
|
|
134
|
+
await writeFile(absolute, content, "utf8");
|
|
135
|
+
if (executable && process.platform !== "win32") {
|
|
136
|
+
await chmod(absolute, 0o755).catch(() => {});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
changes.push(file);
|
|
140
|
+
log(" + " + file);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* @param {string} root
|
|
145
|
+
* @param {boolean} dryRun
|
|
146
|
+
* @param {(message: string) => void} log
|
|
147
|
+
* @param {string[]} changes
|
|
148
|
+
*/
|
|
149
|
+
async function ensureGitignore(root, dryRun, log, changes) {
|
|
150
|
+
const file = path.join(root, ".gitignore");
|
|
151
|
+
let current = "";
|
|
152
|
+
try {
|
|
153
|
+
current = await readFile(file, "utf8");
|
|
154
|
+
} catch (error) {
|
|
155
|
+
if (!error || error.code !== "ENOENT") throw error;
|
|
156
|
+
}
|
|
157
|
+
const lines = current.split(/\n/);
|
|
158
|
+
const additions = ["/.covgate/", "/coverage/"].filter((entry) => !lines.includes(entry));
|
|
159
|
+
if (additions.length === 0) {
|
|
160
|
+
log(" = .gitignore 已包含覆盖率产物");
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
const next =
|
|
164
|
+
current +
|
|
165
|
+
(current && !current.endsWith("\n") ? "\n" : "") +
|
|
166
|
+
additions.join("\n") +
|
|
167
|
+
"\n";
|
|
168
|
+
if (!dryRun) await writeFile(file, next, "utf8");
|
|
169
|
+
changes.push(".gitignore");
|
|
170
|
+
log(" + .gitignore(" + additions.join(", ") + ")");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* @param {{
|
|
175
|
+
* root: string,
|
|
176
|
+
* packageManager: string,
|
|
177
|
+
* force?: boolean,
|
|
178
|
+
* dryRun?: boolean,
|
|
179
|
+
* log: (message:string) => void
|
|
180
|
+
* }} options
|
|
181
|
+
*/
|
|
182
|
+
export async function scaffoldAgent({ root, packageManager, force = false, dryRun = false, log }) {
|
|
183
|
+
const changes = [];
|
|
184
|
+
const exists = (file) => existsSync(path.join(root, file));
|
|
185
|
+
await ensureManagedFile({
|
|
186
|
+
root,
|
|
187
|
+
file: "AGENTS.md",
|
|
188
|
+
block: AGENT_BLOCK,
|
|
189
|
+
force,
|
|
190
|
+
dryRun,
|
|
191
|
+
log,
|
|
192
|
+
changes
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
if (exists("CLAUDE.md") || exists(".claude")) {
|
|
196
|
+
await ensureManagedFile({
|
|
197
|
+
root,
|
|
198
|
+
file: "CLAUDE.md",
|
|
199
|
+
block: AGENT_BLOCK,
|
|
200
|
+
force,
|
|
201
|
+
dryRun,
|
|
202
|
+
log,
|
|
203
|
+
changes
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
if (exists(".cursor")) {
|
|
207
|
+
await ensureNewFile({
|
|
208
|
+
root,
|
|
209
|
+
file: ".cursor/rules/covgate.mdc",
|
|
210
|
+
content: CURSOR_RULE,
|
|
211
|
+
force,
|
|
212
|
+
dryRun,
|
|
213
|
+
log,
|
|
214
|
+
changes
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
if (exists(".github/copilot-instructions.md")) {
|
|
218
|
+
await ensureManagedFile({
|
|
219
|
+
root,
|
|
220
|
+
file: ".github/copilot-instructions.md",
|
|
221
|
+
block: AGENT_BLOCK,
|
|
222
|
+
force,
|
|
223
|
+
dryRun,
|
|
224
|
+
log,
|
|
225
|
+
changes
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
await ensureNewFile({
|
|
230
|
+
root,
|
|
231
|
+
file: "scripts/covgate-agent-gate.sh",
|
|
232
|
+
content: SH_SCRIPT,
|
|
233
|
+
force,
|
|
234
|
+
dryRun,
|
|
235
|
+
executable: true,
|
|
236
|
+
log,
|
|
237
|
+
changes
|
|
238
|
+
});
|
|
239
|
+
await ensureNewFile({
|
|
240
|
+
root,
|
|
241
|
+
file: "scripts/covgate-agent-gate.ps1",
|
|
242
|
+
content: PS1_SCRIPT,
|
|
243
|
+
force,
|
|
244
|
+
dryRun,
|
|
245
|
+
log,
|
|
246
|
+
changes
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
if (exists(".github")) {
|
|
250
|
+
await ensureNewFile({
|
|
251
|
+
root,
|
|
252
|
+
file: ".github/workflows/covgate.yml",
|
|
253
|
+
content: buildWorkflow(packageManager),
|
|
254
|
+
force,
|
|
255
|
+
dryRun,
|
|
256
|
+
log,
|
|
257
|
+
changes
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
await ensureGitignore(root, dryRun, log, changes);
|
|
261
|
+
return changes;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* @param {string} packageManager
|
|
266
|
+
*/
|
|
267
|
+
export function buildWorkflow(packageManager) {
|
|
268
|
+
const setup = [" - uses: actions/checkout@v4"];
|
|
269
|
+
let install = " - run: npm ci";
|
|
270
|
+
if (packageManager === "pnpm") {
|
|
271
|
+
setup.push(
|
|
272
|
+
" - uses: pnpm/action-setup@v4",
|
|
273
|
+
" with:",
|
|
274
|
+
" version: 9",
|
|
275
|
+
" - uses: actions/setup-node@v4",
|
|
276
|
+
" with:",
|
|
277
|
+
" node-version: 22",
|
|
278
|
+
" cache: pnpm"
|
|
279
|
+
);
|
|
280
|
+
install = " - run: pnpm install --frozen-lockfile";
|
|
281
|
+
} else if (packageManager === "yarn") {
|
|
282
|
+
setup.push(
|
|
283
|
+
" - uses: actions/setup-node@v4",
|
|
284
|
+
" with:",
|
|
285
|
+
" node-version: 22",
|
|
286
|
+
" cache: yarn"
|
|
287
|
+
);
|
|
288
|
+
install = " - run: corepack enable\n - run: yarn install --immutable";
|
|
289
|
+
} else if (packageManager === "bun") {
|
|
290
|
+
setup.push(" - uses: oven-sh/setup-bun@v2");
|
|
291
|
+
install = " - run: bun install --frozen-lockfile";
|
|
292
|
+
} else {
|
|
293
|
+
setup.push(
|
|
294
|
+
" - uses: actions/setup-node@v4",
|
|
295
|
+
" with:",
|
|
296
|
+
" node-version: 22",
|
|
297
|
+
" cache: npm"
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
return [
|
|
301
|
+
"name: coverage-gate",
|
|
302
|
+
"",
|
|
303
|
+
"on:",
|
|
304
|
+
" pull_request:",
|
|
305
|
+
" push:",
|
|
306
|
+
" branches: [main]",
|
|
307
|
+
"",
|
|
308
|
+
"permissions:",
|
|
309
|
+
" contents: read",
|
|
310
|
+
"",
|
|
311
|
+
"jobs:",
|
|
312
|
+
" coverage:",
|
|
313
|
+
" runs-on: ubuntu-latest",
|
|
314
|
+
" timeout-minutes: 30",
|
|
315
|
+
" steps:",
|
|
316
|
+
...setup,
|
|
317
|
+
install,
|
|
318
|
+
" - name: Run CovGate",
|
|
319
|
+
" run: npx --no-install covgate run --json",
|
|
320
|
+
" - name: Upload audit report",
|
|
321
|
+
" if: always()",
|
|
322
|
+
" uses: actions/upload-artifact@v4",
|
|
323
|
+
" with:",
|
|
324
|
+
" name: covgate-report",
|
|
325
|
+
" path: .covgate/",
|
|
326
|
+
" if-no-files-found: ignore",
|
|
327
|
+
""
|
|
328
|
+
].join("\n");
|
|
329
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { expandFiles, matchGlob } from "../glob.js";
|
|
2
|
+
import { relativeDisplay } from "../util.js";
|
|
3
|
+
|
|
4
|
+
const TEST_EXCLUDE_CANDIDATES = [
|
|
5
|
+
{
|
|
6
|
+
pattern: "**/*.test.*",
|
|
7
|
+
reason: "测试文件,不是产品运行时代码"
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
pattern: "**/*.spec.*",
|
|
11
|
+
reason: "测试文件,不是产品运行时代码"
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
pattern: "**/__tests__/**",
|
|
15
|
+
reason: "测试目录,不是产品运行时代码"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
pattern: "**/*.d.ts",
|
|
19
|
+
reason: "类型声明文件,没有运行时代码"
|
|
20
|
+
}
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Only add exclusions that currently match something. CovGate intentionally
|
|
25
|
+
* fails stale exclusions, so a starter config must not contain guesses that
|
|
26
|
+
* match zero files.
|
|
27
|
+
*
|
|
28
|
+
* @param {string} root
|
|
29
|
+
* @param {string} sourceGlob
|
|
30
|
+
*/
|
|
31
|
+
async function pickExcludes(root, sourceGlob) {
|
|
32
|
+
let files = [];
|
|
33
|
+
try {
|
|
34
|
+
files = await expandFiles([sourceGlob], root);
|
|
35
|
+
} catch {
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
const relative = files.map((file) => relativeDisplay(file, root));
|
|
39
|
+
return TEST_EXCLUDE_CANDIDATES.filter((entry) =>
|
|
40
|
+
relative.some((file) => matchGlob(file, entry.pattern))
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {{
|
|
46
|
+
* root: string,
|
|
47
|
+
* scopes?: Array<{ name: string, profile: "client" | "server", label?: string, sourceGlob: string }>,
|
|
48
|
+
* profile?: "client" | "server",
|
|
49
|
+
* sourceGlob?: string,
|
|
50
|
+
* command: string,
|
|
51
|
+
* coveragePath: string
|
|
52
|
+
* }} options
|
|
53
|
+
*/
|
|
54
|
+
export async function buildStarterConfig({
|
|
55
|
+
root,
|
|
56
|
+
scopes,
|
|
57
|
+
profile,
|
|
58
|
+
sourceGlob,
|
|
59
|
+
command,
|
|
60
|
+
coveragePath
|
|
61
|
+
}) {
|
|
62
|
+
const specs =
|
|
63
|
+
scopes && scopes.length > 0
|
|
64
|
+
? scopes
|
|
65
|
+
: [
|
|
66
|
+
{
|
|
67
|
+
name: profile,
|
|
68
|
+
profile,
|
|
69
|
+
label: profile === "client" ? "客户端" : "服务端",
|
|
70
|
+
sourceGlob
|
|
71
|
+
}
|
|
72
|
+
];
|
|
73
|
+
const scopeConfigs = {};
|
|
74
|
+
for (const spec of specs) {
|
|
75
|
+
if (!spec.profile || !spec.sourceGlob || !spec.name) continue;
|
|
76
|
+
const label =
|
|
77
|
+
spec.label || (spec.profile === "client" ? "客户端" : "服务端");
|
|
78
|
+
const exclude = await pickExcludes(root, spec.sourceGlob);
|
|
79
|
+
scopeConfigs[spec.name] = {
|
|
80
|
+
profile: spec.profile,
|
|
81
|
+
label,
|
|
82
|
+
commands: [
|
|
83
|
+
{
|
|
84
|
+
name: label + "测试与覆盖率",
|
|
85
|
+
run: command,
|
|
86
|
+
timeoutSeconds: 900
|
|
87
|
+
}
|
|
88
|
+
],
|
|
89
|
+
coverage: {
|
|
90
|
+
format: "auto",
|
|
91
|
+
paths: [coveragePath]
|
|
92
|
+
},
|
|
93
|
+
sources: {
|
|
94
|
+
include: [spec.sourceGlob]
|
|
95
|
+
},
|
|
96
|
+
exclude
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
$schema: "./node_modules/covgate/schema/covgate.schema.json",
|
|
101
|
+
version: 1,
|
|
102
|
+
scopes: scopeConfigs,
|
|
103
|
+
waivers: {
|
|
104
|
+
minReasonLength: 8,
|
|
105
|
+
requireOwner: false,
|
|
106
|
+
stale: "fail",
|
|
107
|
+
expiry: "fail"
|
|
108
|
+
},
|
|
109
|
+
reports: {
|
|
110
|
+
dir: ".covgate",
|
|
111
|
+
formats: ["json", "markdown"]
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* @param {any} config
|
|
118
|
+
*/
|
|
119
|
+
export function configSummary(config) {
|
|
120
|
+
const scopes = Object.entries(config.scopes || {}).map(([name, scope]) => ({
|
|
121
|
+
name,
|
|
122
|
+
profile: scope.profile,
|
|
123
|
+
command: scope.commands?.[0]?.run,
|
|
124
|
+
source: scope.sources?.include?.[0],
|
|
125
|
+
coverage: scope.coverage?.paths?.[0],
|
|
126
|
+
exclusions: scope.exclude?.length || 0
|
|
127
|
+
}));
|
|
128
|
+
return scopes;
|
|
129
|
+
}
|