vouchington-tooling 0.13.1 → 0.13.3
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 +26 -0
- package/dist/ast-grep-examples/companion-parity.d.mts +19 -0
- package/dist/ast-grep-examples/companion-parity.mjs +156 -0
- package/dist/ast-grep-examples/index.d.mts +2 -1
- package/dist/ast-grep-examples/index.mjs +1 -0
- package/dist/gha-check-run/cli.d.mts +2 -0
- package/dist/gha-check-run/cli.mjs +81 -0
- package/dist/gha-check-run/github.d.mts +30 -0
- package/dist/gha-check-run/github.mjs +63 -0
- package/dist/gha-check-run/index.d.mts +2 -0
- package/dist/gha-check-run/index.mjs +2 -0
- package/dist/gha-post-review/cli.mjs +1 -1
- package/dist/gha-post-review/github.d.mts +6 -22
- package/dist/gha-post-review/github.mjs +8 -92
- package/dist/gha-post-review/post.d.mts +7 -1
- package/dist/gha-post-review/post.mjs +11 -3
- package/dist/gha-post-review/pull-io.d.mts +13 -0
- package/dist/gha-post-review/pull-io.mjs +88 -0
- package/dist/index.d.mts +4 -3
- package/dist/index.mjs +2 -2
- package/dist/markdown/ast.d.mts +8 -0
- package/dist/markdown/ast.mjs +29 -0
- package/dist/markdown/index.d.mts +6 -0
- package/dist/markdown/index.mjs +5 -0
- package/dist/markdown/loose-pipe-rows.d.mts +2 -0
- package/dist/markdown/loose-pipe-rows.mjs +31 -0
- package/dist/markdown/sections.d.mts +2 -0
- package/dist/markdown/sections.mjs +25 -0
- package/dist/markdown/tables.d.mts +3 -0
- package/dist/markdown/tables.mjs +43 -0
- package/dist/markdown/text.d.mts +2 -0
- package/dist/markdown/text.mjs +22 -0
- package/dist/markdown/types.d.mts +35 -0
- package/dist/markdown/types.mjs +1 -0
- package/dist/scc-complexity/baseline.d.mts +6 -0
- package/dist/scc-complexity/baseline.mjs +90 -0
- package/dist/scc-complexity/constants.d.mts +1 -0
- package/dist/scc-complexity/constants.mjs +1 -0
- package/dist/scc-complexity/index.d.mts +6 -14
- package/dist/scc-complexity/index.mjs +64 -39
- package/dist/scc-complexity/parser.d.mts +2 -0
- package/dist/scc-complexity/parser.mjs +21 -0
- package/dist/scc-complexity/paths.d.mts +4 -0
- package/dist/scc-complexity/paths.mjs +44 -0
- package/dist/scc-complexity/scope-config.d.mts +3 -0
- package/dist/scc-complexity/scope-config.mjs +35 -0
- package/dist/scc-complexity/types.d.mts +32 -0
- package/dist/scc-complexity/types.mjs +1 -0
- package/dist/scc-complexity/workflow-command.d.mts +2 -0
- package/dist/scc-complexity/workflow-command.mjs +6 -0
- package/package.json +15 -1
|
@@ -3,8 +3,15 @@ import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { promisify } from 'node:util';
|
|
6
|
+
import { baselineEntryByScopeAndFile, normalizeSccComplexityBaseline, validateSccComplexityBaseline, } from './baseline.mjs';
|
|
7
|
+
import { SCC_COMPLEXITY_LIMIT } from './constants.mjs';
|
|
8
|
+
import { canonicalRepoPath } from './paths.mjs';
|
|
9
|
+
import { parseSccComplexityValues } from './parser.mjs';
|
|
10
|
+
import { normalizeScopes, scopesFor } from './scope-config.mjs';
|
|
11
|
+
import { escapeWorkflowCommandMessage, escapeWorkflowCommandProperty } from './workflow-command.mjs';
|
|
6
12
|
const execFileAsync = promisify(execFile);
|
|
7
|
-
export
|
|
13
|
+
export { parseSccComplexityBaseline, SCC_COMPLEXITY_BASELINE_VERSION } from './baseline.mjs';
|
|
14
|
+
export { SCC_COMPLEXITY_LIMIT } from './constants.mjs';
|
|
8
15
|
const DEFAULT_INCLUDE_EXT = 'js,mts,jsx,ts,tsx';
|
|
9
16
|
const DEFAULT_EXCLUDE_DIR = '.git,fixtures,__tests__,test-helpers';
|
|
10
17
|
const DEFAULT_NOT_MATCH = String.raw `\.(test|spec)\.`;
|
|
@@ -26,65 +33,83 @@ export function buildSccArgs(options = {}) {
|
|
|
26
33
|
];
|
|
27
34
|
}
|
|
28
35
|
export async function checkSccComplexity(ctx, options = {}, runScc) {
|
|
29
|
-
if (!ctx.isInsideGitRepo)
|
|
30
|
-
return {
|
|
31
|
-
|
|
36
|
+
if (!ctx.isInsideGitRepo)
|
|
37
|
+
return {
|
|
38
|
+
errors: [
|
|
39
|
+
`::error::${escapeWorkflowCommandMessage(ctx.repoRoot)} is not inside a git repository`,
|
|
40
|
+
],
|
|
41
|
+
};
|
|
32
42
|
const dir = await mkdtemp(join(tmpdir(), options.tmpdirPrefix ?? DEFAULT_TMPDIR_PREFIX));
|
|
33
|
-
const outputPath = join(dir, 'scc.json');
|
|
34
|
-
const limit = options.limit ?? SCC_COMPLEXITY_LIMIT;
|
|
35
43
|
try {
|
|
36
|
-
const
|
|
37
|
-
const
|
|
38
|
-
const
|
|
39
|
-
|
|
44
|
+
const scopes = await normalizeScopes(scopesFor(options), ctx.repoRoot, options.limit);
|
|
45
|
+
const trackedFileSet = new Set([...ctx.trackedFileSet].map((file) => canonicalRepoPath(ctx.repoRoot, file)));
|
|
46
|
+
const baseline = options.baseline && normalizeSccComplexityBaseline(options.baseline, ctx.repoRoot);
|
|
47
|
+
const results = await runScopes(ctx, options, scopes, dir, trackedFileSet, runScc);
|
|
48
|
+
if (baseline)
|
|
49
|
+
validateSccComplexityBaseline(baseline, scopes, trackedFileSet, new Map(results.map(({ scope, values }) => [scope.name, values])));
|
|
50
|
+
return { errors: formatViolations(results, baseline) };
|
|
40
51
|
}
|
|
41
52
|
catch (error) {
|
|
42
53
|
const message = error instanceof Error ? error.message : String(error);
|
|
43
|
-
return { errors: [`::error::scc-complexity failed: ${message}`] };
|
|
54
|
+
return { errors: [`::error::scc-complexity failed: ${escapeWorkflowCommandMessage(message)}`] };
|
|
44
55
|
}
|
|
45
56
|
finally {
|
|
46
57
|
await rm(dir, { force: true, recursive: true });
|
|
47
58
|
}
|
|
48
59
|
}
|
|
49
60
|
export function parseSccComplexityViolations(json, trackedFileSet, limit = SCC_COMPLEXITY_LIMIT) {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
if (file.Complexity <= limit)
|
|
65
|
-
continue;
|
|
66
|
-
violations.push({ file: file.Location, complexity: file.Complexity });
|
|
67
|
-
}
|
|
61
|
+
return parseSccComplexityValues(json, trackedFileSet)
|
|
62
|
+
.filter((value) => value.complexity > limit)
|
|
63
|
+
.toSorted(compare);
|
|
64
|
+
}
|
|
65
|
+
async function runScopes(ctx, options, scopes, dir, trackedFileSet, runScc) {
|
|
66
|
+
const results = [];
|
|
67
|
+
for (const [index, scope] of scopes.entries()) {
|
|
68
|
+
const outputPath = join(dir, `scope-${index}.json`);
|
|
69
|
+
const resolve = runScc ?? ((path) => runSccJson(ctx.repoRoot, path, options, scope));
|
|
70
|
+
const values = parseSccComplexityValues(await resolve(outputPath, scope), trackedFileSet, ctx.repoRoot);
|
|
71
|
+
const outside = values.find((value) => !isInScopeResult(scope, value.file));
|
|
72
|
+
if (outside)
|
|
73
|
+
throw new Error(`scope ${scope.name} received result outside its paths: ${outside.file}`);
|
|
74
|
+
results.push({ scope, values });
|
|
68
75
|
}
|
|
69
|
-
return
|
|
76
|
+
return results;
|
|
77
|
+
}
|
|
78
|
+
function formatViolations(results, baseline) {
|
|
79
|
+
const entries = baselineEntryByScopeAndFile(baseline);
|
|
80
|
+
return results.flatMap(({ scope, values }) => values.flatMap((value) => {
|
|
81
|
+
const limit = scope.limit ?? SCC_COMPLEXITY_LIMIT;
|
|
82
|
+
if (value.complexity <= limit)
|
|
83
|
+
return [];
|
|
84
|
+
const entry = entries.get(scope.name)?.get(value.file);
|
|
85
|
+
if (entry && value.complexity <= entry.complexity)
|
|
86
|
+
return [];
|
|
87
|
+
const prefix = scope.name ? `[${scope.name}] ` : '';
|
|
88
|
+
const detail = entry
|
|
89
|
+
? `scc complexity ${value.complexity} exceeds baseline ceiling ${entry.complexity}`
|
|
90
|
+
: `scc complexity ${value.complexity} exceeds ${limit}; simplify or split this file`;
|
|
91
|
+
return [
|
|
92
|
+
`::error file=${escapeWorkflowCommandProperty(value.file)}::${escapeWorkflowCommandMessage(`${prefix}${value.file}: ${detail}`)}`,
|
|
93
|
+
];
|
|
94
|
+
}));
|
|
95
|
+
}
|
|
96
|
+
function isInScopeResult(scope, file) {
|
|
97
|
+
return (scope.includePaths.length === 0 ||
|
|
98
|
+
scope.includePaths.some((path) => file === path || file.startsWith(`${path}/`) || path === '.'));
|
|
70
99
|
}
|
|
71
|
-
async function runSccJson(repoRoot, outputPath, options) {
|
|
100
|
+
async function runSccJson(repoRoot, outputPath, options, scope) {
|
|
72
101
|
try {
|
|
73
|
-
await execFileAsync(options.command ?? 'scc', [...buildSccArgs(options), '--output', outputPath], {
|
|
74
|
-
cwd: repoRoot,
|
|
75
|
-
maxBuffer: 1024 * 1024,
|
|
76
|
-
});
|
|
102
|
+
await execFileAsync(options.command ?? 'scc', [...buildSccArgs({ ...options, ...scope }), ...scope.includePaths, '--output', outputPath], { cwd: repoRoot, maxBuffer: 1024 * 1024 });
|
|
77
103
|
}
|
|
78
104
|
catch (error) {
|
|
79
|
-
if (isNodeSystemError(error) && error.code === 'ENOENT')
|
|
105
|
+
if (isNodeSystemError(error) && error.code === 'ENOENT')
|
|
80
106
|
throw new Error('scc executable not found; install with mise install', { cause: error });
|
|
81
|
-
}
|
|
82
107
|
throw error;
|
|
83
108
|
}
|
|
84
109
|
return readFile(outputPath, 'utf8');
|
|
85
110
|
}
|
|
86
|
-
function
|
|
87
|
-
return
|
|
111
|
+
function compare(a, b) {
|
|
112
|
+
return b.complexity - a.complexity || a.file.localeCompare(b.file);
|
|
88
113
|
}
|
|
89
114
|
function isNodeSystemError(error) {
|
|
90
115
|
return error instanceof Error && 'code' in error;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { canonicalRepoPath } from './paths.mjs';
|
|
2
|
+
export function parseSccComplexityValues(json, trackedFileSet, repoRoot) {
|
|
3
|
+
const parsed = JSON.parse(json);
|
|
4
|
+
if (!Array.isArray(parsed))
|
|
5
|
+
throw new Error('scc JSON output must be an array');
|
|
6
|
+
const values = [];
|
|
7
|
+
for (const language of parsed) {
|
|
8
|
+
if (!Array.isArray(language.Files))
|
|
9
|
+
continue;
|
|
10
|
+
for (const file of language.Files) {
|
|
11
|
+
if (typeof file.Location !== 'string')
|
|
12
|
+
continue;
|
|
13
|
+
const location = repoRoot ? canonicalRepoPath(repoRoot, file.Location) : file.Location;
|
|
14
|
+
if (!trackedFileSet.has(location))
|
|
15
|
+
continue;
|
|
16
|
+
if (typeof file.Complexity === 'number')
|
|
17
|
+
values.push({ complexity: file.Complexity, file: location });
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return values.toSorted((a, b) => b.complexity - a.complexity || a.file.localeCompare(b.file));
|
|
21
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare function canonicalRepoPath(repoRoot: string, path: string): string;
|
|
2
|
+
export declare function isInScope(file: string, includePaths: readonly string[]): boolean;
|
|
3
|
+
export declare function canonicalScopePath(repoRoot: string, path: string): Promise<string>;
|
|
4
|
+
export declare function assertWorkflowCommandData(value: string, label: string): void;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { lstat, realpath } from 'node:fs/promises';
|
|
2
|
+
import { dirname, isAbsolute, relative, resolve, win32 } from 'node:path';
|
|
3
|
+
export function canonicalRepoPath(repoRoot, path) {
|
|
4
|
+
const relativePath = relative(repoRoot, resolve(repoRoot, path.replaceAll('\\', '/'))).replaceAll('\\', '/');
|
|
5
|
+
return relativePath || '.';
|
|
6
|
+
}
|
|
7
|
+
function isInsideRepo(path) {
|
|
8
|
+
return path !== '..' && !path.startsWith('../');
|
|
9
|
+
}
|
|
10
|
+
export function isInScope(file, includePaths) {
|
|
11
|
+
return includePaths.some((includePath) => includePath === '.' || file === includePath || file.startsWith(`${includePath}/`));
|
|
12
|
+
}
|
|
13
|
+
export async function canonicalScopePath(repoRoot, path) {
|
|
14
|
+
if (isAbsolute(path) || win32.parse(path).root)
|
|
15
|
+
throw new Error('path is outside the repository');
|
|
16
|
+
const physicalRoot = await realpath(repoRoot);
|
|
17
|
+
const candidatePath = resolve(repoRoot, path.replaceAll('\\', '/'));
|
|
18
|
+
if (!isInsideRepo(canonicalRepoPath(repoRoot, candidatePath)))
|
|
19
|
+
throw new Error('path is outside the repository');
|
|
20
|
+
const physicalPath = await physicalPathFor(candidatePath, repoRoot, physicalRoot);
|
|
21
|
+
const canonicalPath = canonicalRepoPath(physicalRoot, physicalPath);
|
|
22
|
+
if (!isInsideRepo(canonicalPath))
|
|
23
|
+
throw new Error('path is outside the repository');
|
|
24
|
+
return canonicalPath;
|
|
25
|
+
}
|
|
26
|
+
export function assertWorkflowCommandData(value, label) {
|
|
27
|
+
if (/[\r\n]/.test(value))
|
|
28
|
+
throw new Error(`${label} contains a workflow command control character`);
|
|
29
|
+
}
|
|
30
|
+
async function physicalPathFor(path, repoRoot, physicalRoot) {
|
|
31
|
+
let existingPath = path;
|
|
32
|
+
while (existingPath !== repoRoot) {
|
|
33
|
+
try {
|
|
34
|
+
const physicalPath = await realpath(existingPath);
|
|
35
|
+
return resolve(physicalPath, relative(existingPath, path));
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
if ((await lstat(existingPath).catch(() => undefined))?.isSymbolicLink())
|
|
39
|
+
throw error;
|
|
40
|
+
existingPath = dirname(existingPath);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return resolve(physicalRoot, relative(repoRoot, path));
|
|
44
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { SccComplexityOptions, SccComplexityScope } from './types.mts';
|
|
2
|
+
export declare function scopesFor(options: SccComplexityOptions): readonly SccComplexityScope[];
|
|
3
|
+
export declare function normalizeScopes(scopes: readonly SccComplexityScope[], repoRoot: string, topLevelLimit: number | undefined): Promise<readonly SccComplexityScope[]>;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { assertWorkflowCommandData, canonicalScopePath } from './paths.mjs';
|
|
2
|
+
export function scopesFor(options) {
|
|
3
|
+
if (!options.scopes) {
|
|
4
|
+
if (options.baseline)
|
|
5
|
+
throw new Error('baseline requires named scopes');
|
|
6
|
+
return [{ includePaths: [], name: '', ...options }];
|
|
7
|
+
}
|
|
8
|
+
if (options.scopes.length === 0)
|
|
9
|
+
throw new Error('scopes must not be empty');
|
|
10
|
+
const names = new Set();
|
|
11
|
+
for (const scope of options.scopes) {
|
|
12
|
+
if (!scope.name || names.has(scope.name))
|
|
13
|
+
throw new Error(`scope name ${scope.name || '(empty)'} is invalid`);
|
|
14
|
+
assertWorkflowCommandData(scope.name, 'scope name');
|
|
15
|
+
if (scope.includePaths.length === 0 || scope.includePaths.some((path) => !path))
|
|
16
|
+
throw new Error(`scope ${scope.name} must include at least one path`);
|
|
17
|
+
for (const path of scope.includePaths)
|
|
18
|
+
assertWorkflowCommandData(path, `scope ${scope.name} path`);
|
|
19
|
+
names.add(scope.name);
|
|
20
|
+
}
|
|
21
|
+
return options.scopes;
|
|
22
|
+
}
|
|
23
|
+
export async function normalizeScopes(scopes, repoRoot, topLevelLimit) {
|
|
24
|
+
return Promise.all(scopes.map(async (scope) => {
|
|
25
|
+
let includePaths;
|
|
26
|
+
try {
|
|
27
|
+
includePaths = await Promise.all(scope.includePaths.map((path) => canonicalScopePath(repoRoot, path)));
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
throw new Error(`scope ${scope.name} includes a path outside the repository`);
|
|
31
|
+
}
|
|
32
|
+
const limit = scope.limit ?? topLevelLimit;
|
|
33
|
+
return limit === undefined ? { ...scope, includePaths } : { ...scope, includePaths, limit };
|
|
34
|
+
}));
|
|
35
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
interface SccComplexitySettings {
|
|
2
|
+
limit?: number;
|
|
3
|
+
includeExt?: string;
|
|
4
|
+
excludeDir?: string;
|
|
5
|
+
notMatch?: string;
|
|
6
|
+
}
|
|
7
|
+
export interface SccComplexityScope extends SccComplexitySettings {
|
|
8
|
+
name: string;
|
|
9
|
+
includePaths: readonly string[];
|
|
10
|
+
}
|
|
11
|
+
export interface SccComplexityBaselineEntry {
|
|
12
|
+
scope: string;
|
|
13
|
+
file: string;
|
|
14
|
+
complexity: number;
|
|
15
|
+
}
|
|
16
|
+
export interface SccComplexityBaseline {
|
|
17
|
+
version: 1;
|
|
18
|
+
entries: readonly SccComplexityBaselineEntry[];
|
|
19
|
+
}
|
|
20
|
+
export interface SccComplexityOptions extends SccComplexitySettings {
|
|
21
|
+
tmpdirPrefix?: string;
|
|
22
|
+
command?: string;
|
|
23
|
+
scopes?: readonly SccComplexityScope[];
|
|
24
|
+
baseline?: SccComplexityBaseline;
|
|
25
|
+
}
|
|
26
|
+
export interface SccComplexityViolation {
|
|
27
|
+
file: string;
|
|
28
|
+
complexity: number;
|
|
29
|
+
}
|
|
30
|
+
export interface SccComplexityValue extends SccComplexityViolation {
|
|
31
|
+
}
|
|
32
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export function escapeWorkflowCommandMessage(value) {
|
|
2
|
+
return value.replaceAll('%', '%25').replaceAll('\r', '%0D').replaceAll('\n', '%0A');
|
|
3
|
+
}
|
|
4
|
+
export function escapeWorkflowCommandProperty(value) {
|
|
5
|
+
return escapeWorkflowCommandMessage(value).replaceAll(':', '%3A').replaceAll(',', '%2C');
|
|
6
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vouchington-tooling",
|
|
3
|
-
"version": "0.13.
|
|
3
|
+
"version": "0.13.3",
|
|
4
4
|
"description": "Vouchington CLI and extractable tooling libraries.",
|
|
5
5
|
"homepage": "https://github.com/vouchington/vouchington-tooling/tree/main/packages/vouchington-tooling#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -178,6 +178,11 @@
|
|
|
178
178
|
"import": "./dist/csv/index.mjs",
|
|
179
179
|
"default": "./dist/csv/index.mjs"
|
|
180
180
|
},
|
|
181
|
+
"./markdown": {
|
|
182
|
+
"types": "./dist/markdown/index.d.mts",
|
|
183
|
+
"import": "./dist/markdown/index.mjs",
|
|
184
|
+
"default": "./dist/markdown/index.mjs"
|
|
185
|
+
},
|
|
181
186
|
"./http-body": {
|
|
182
187
|
"types": "./dist/http-body/index.d.mts",
|
|
183
188
|
"import": "./dist/http-body/index.mjs",
|
|
@@ -203,6 +208,11 @@
|
|
|
203
208
|
"import": "./dist/gha-claude-post-review/index.mjs",
|
|
204
209
|
"default": "./dist/gha-claude-post-review/index.mjs"
|
|
205
210
|
},
|
|
211
|
+
"./gha-check-run": {
|
|
212
|
+
"types": "./dist/gha-check-run/index.d.mts",
|
|
213
|
+
"import": "./dist/gha-check-run/index.mjs",
|
|
214
|
+
"default": "./dist/gha-check-run/index.mjs"
|
|
215
|
+
},
|
|
206
216
|
"./http-link-pagination": {
|
|
207
217
|
"types": "./dist/http-link-pagination/index.d.mts",
|
|
208
218
|
"import": "./dist/http-link-pagination/index.mjs",
|
|
@@ -294,10 +304,14 @@
|
|
|
294
304
|
"typecheck": "tsc --noEmit --project tsconfig.json"
|
|
295
305
|
},
|
|
296
306
|
"dependencies": {
|
|
307
|
+
"@types/mdast": "4.0.4",
|
|
308
|
+
"@types/unist": "3.0.3",
|
|
297
309
|
"csv-parse": "7.0.2",
|
|
298
310
|
"csv-stringify": "6.8.3",
|
|
299
311
|
"dockerfile-ast": "0.7.1",
|
|
300
312
|
"picomatch": "4.0.7",
|
|
313
|
+
"remark": "15.0.1",
|
|
314
|
+
"remark-gfm": "4.0.1",
|
|
301
315
|
"smol-toml": "1.8.0",
|
|
302
316
|
"yaml": "2.9.0"
|
|
303
317
|
},
|