@brighterly/lib-fe-codestyle 1.0.0 → 1.0.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/README.md +234 -178
- package/dist/cli.d.ts +8 -0
- package/dist/cli.js +181 -0
- package/dist/config/define-config.d.ts +11 -0
- package/dist/config/define-config.js +33 -0
- package/dist/config/load-config.d.ts +15 -0
- package/dist/config/load-config.js +52 -0
- package/dist/config/schema.d.ts +58 -0
- package/dist/config/schema.js +139 -0
- package/dist/{invariants → engine}/ast.d.ts +8 -0
- package/dist/{invariants → engine}/ast.js +51 -0
- package/dist/engine/docs.d.ts +14 -0
- package/dist/engine/docs.js +72 -0
- package/dist/engine/meta.d.ts +32 -0
- package/dist/engine/meta.js +47 -0
- package/dist/engine/migrate.d.ts +22 -0
- package/dist/engine/migrate.js +175 -0
- package/dist/engine/overrides.d.ts +36 -0
- package/dist/engine/overrides.js +92 -0
- package/dist/engine/registry.d.ts +35 -0
- package/dist/engine/registry.js +100 -0
- package/dist/engine/report.d.ts +23 -0
- package/dist/engine/report.js +87 -0
- package/dist/engine/run.d.ts +57 -0
- package/dist/engine/run.js +353 -0
- package/dist/engine/self-test.d.ts +33 -0
- package/dist/engine/self-test.js +141 -0
- package/dist/engine/types.d.ts +232 -0
- package/dist/{invariants → engine}/types.js +2 -1
- package/dist/{invariants → engine}/util.d.ts +31 -39
- package/dist/{invariants → engine}/util.js +59 -86
- package/dist/index.d.ts +16 -0
- package/dist/index.js +14 -0
- package/dist/rules/convention.d.ts +20 -0
- package/dist/{invariants/rules → rules}/convention.js +103 -16
- package/dist/rules/index.d.ts +28 -0
- package/dist/{invariants/rules → rules}/index.js +40 -32
- package/dist/{invariants/rules → rules}/regression.d.ts +1 -1
- package/dist/{invariants/rules → rules}/regression.js +26 -6
- package/dist/{invariants/rules → rules}/shared.d.ts +1 -1
- package/dist/validate/index.d.ts +1 -1
- package/dist/validate/index.js +1 -1
- package/package.json +8 -13
- package/dist/invariants/index.d.ts +0 -7
- package/dist/invariants/index.js +0 -5
- package/dist/invariants/rules/convention.d.ts +0 -18
- package/dist/invariants/rules/fixtures.d.ts +0 -17
- package/dist/invariants/rules/fixtures.js +0 -89
- package/dist/invariants/rules/index.d.ts +0 -55
- package/dist/invariants/rules/linter.d.ts +0 -10
- package/dist/invariants/rules/linter.js +0 -9
- package/dist/invariants/run.d.ts +0 -53
- package/dist/invariants/run.js +0 -746
- package/dist/invariants/self-test.d.ts +0 -46
- package/dist/invariants/self-test.js +0 -84
- package/dist/invariants/types.d.ts +0 -222
- /package/dist/{invariants/rules → rules}/shared.js +0 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Meta rules. They police the escape hatch rather than the app, so they are not in the rule
|
|
3
|
+
* inventory — but they are live names (a suppression or an override may name them), they
|
|
4
|
+
* report like any rule, and their rationale lives here, next to the scans.
|
|
5
|
+
* IG0 — a `codestyle-ignore` with no reason
|
|
6
|
+
* IG1 — a reference to a rule name that no longer exists
|
|
7
|
+
* IG2 — a `codestyle-ignore` naming a rule declared `noIgnore`
|
|
8
|
+
*/
|
|
9
|
+
import type { Registry } from './registry.js';
|
|
10
|
+
import type { Finding } from './types.js';
|
|
11
|
+
import { parseIgnores } from './util.js';
|
|
12
|
+
export type MetaRuleId = 'IG0' | 'IG1' | 'IG2';
|
|
13
|
+
export declare const metaRuleInfo: Record<MetaRuleId, {
|
|
14
|
+
title: string;
|
|
15
|
+
why: string;
|
|
16
|
+
fix: string;
|
|
17
|
+
}>;
|
|
18
|
+
export type ParsedIgnores = ReturnType<typeof parseIgnores>;
|
|
19
|
+
export interface MetaFinding extends Finding {
|
|
20
|
+
rule: MetaRuleId;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* IG0/IG1/IG2 over the given files. `ignoresFor` is the caller's per-file parse cache (it
|
|
24
|
+
* already knows which files are exempt from parsing — the rule sources whose fixture strings
|
|
25
|
+
* carry ignore comments on purpose).
|
|
26
|
+
*/
|
|
27
|
+
export declare const scanMeta: (input: {
|
|
28
|
+
files: string[];
|
|
29
|
+
ignoresFor: (file: string) => ParsedIgnores;
|
|
30
|
+
registry: Registry;
|
|
31
|
+
configFile: string;
|
|
32
|
+
}) => MetaFinding[];
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Meta rules. They police the escape hatch rather than the app, so they are not in the rule
|
|
3
|
+
* inventory — but they are live names (a suppression or an override may name them), they
|
|
4
|
+
* report like any rule, and their rationale lives here, next to the scans.
|
|
5
|
+
* IG0 — a `codestyle-ignore` with no reason
|
|
6
|
+
* IG1 — a reference to a rule name that no longer exists
|
|
7
|
+
* IG2 — a `codestyle-ignore` naming a rule declared `noIgnore`
|
|
8
|
+
*/
|
|
9
|
+
import { findDanglingRuleRefs, findForbiddenSuppressions } from './util.js';
|
|
10
|
+
export const metaRuleInfo = {
|
|
11
|
+
IG0: {
|
|
12
|
+
title: 'unexplained suppression',
|
|
13
|
+
why: 'an escape hatch nobody audits is a backdoor — the run prints PASS while the rule is not enforced, and nobody can later tell a reviewed exemption from someone making the red go away',
|
|
14
|
+
fix: 'put the reason after the name: // codestyle-ignore: no-bare-storage — the safe wrapper is the one place bare storage is allowed. Or delete the comment and fix the code.',
|
|
15
|
+
},
|
|
16
|
+
IG1: {
|
|
17
|
+
title: 'reference to a rule that does not exist',
|
|
18
|
+
why: 'the entry is inert but reads like active protection — a deleted or renamed rule silently voids every suppression and override naming it',
|
|
19
|
+
fix: 'drop the dead entry, or point it at a live rule name — `codestyle list` prints them',
|
|
20
|
+
},
|
|
21
|
+
IG2: {
|
|
22
|
+
title: 'suppression of a rule that cannot be suppressed',
|
|
23
|
+
why: 'a rule marked noIgnore has an AI author in scope; a plausible reason costs an AI nothing, so the escape hatch would become the default path — the comment suppresses nothing and the attempt is reported so a reviewer sees the intent',
|
|
24
|
+
fix: 'fix the code. If the rule is wrong, change it in the rulebook together with its proof — exceptions are made in the rulebook by a human, not in the file under check',
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* IG0/IG1/IG2 over the given files. `ignoresFor` is the caller's per-file parse cache (it
|
|
29
|
+
* already knows which files are exempt from parsing — the rule sources whose fixture strings
|
|
30
|
+
* carry ignore comments on purpose).
|
|
31
|
+
*/
|
|
32
|
+
export const scanMeta = (input) => {
|
|
33
|
+
const { files, ignoresFor, registry, configFile } = input;
|
|
34
|
+
const out = [];
|
|
35
|
+
const comments = files.flatMap(file => ignoresFor(file).comments.map(c => ({ file, ...c })));
|
|
36
|
+
for (const file of files) {
|
|
37
|
+
for (const u of ignoresFor(file).unreasoned) {
|
|
38
|
+
out.push({ rule: 'IG0', file, line: u.line, message: `codestyle-ignore: ${u.ids.join(', ')} has no reason — an unexplained exemption silently deletes the rule` });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const noIgnore = new Set(registry.all.filter(r => 'noIgnore' in r.rule && r.rule.noIgnore).map(r => r.rule.name));
|
|
42
|
+
for (const v of findForbiddenSuppressions(comments, noIgnore))
|
|
43
|
+
out.push({ rule: 'IG2', ...v });
|
|
44
|
+
for (const v of findDanglingRuleRefs({ live: registry.live, renamed: registry.renamed, ignores: comments, configFile }))
|
|
45
|
+
out.push({ rule: 'IG1', ...v });
|
|
46
|
+
return out;
|
|
47
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `codestyle migrate` — the mechanical half of moving a repo from 0.5.x to 1.0:
|
|
3
|
+
* 1. rewrite `invariant-ignore:` → `codestyle-ignore:` and every renamed package id (`core/R1` →
|
|
4
|
+
* `no-bare-storage`) inside suppression comments and in `invariants/config.mts`;
|
|
5
|
+
* 2. print (or write) a `codestyle.config.ts` skeleton from the old config.
|
|
6
|
+
* Moving `invariants/` to `codestyle/` stays manual — the recipe in the README.
|
|
7
|
+
*
|
|
8
|
+
* The old config cannot be imported (the 0.5.x subpath it imports no longer exists), so it is read
|
|
9
|
+
* as text: each section's object literal is evaluated on its own, once, at migration time — it is
|
|
10
|
+
* the developer's own config. A section that will not evaluate is carried as a TODO, never dropped.
|
|
11
|
+
*/
|
|
12
|
+
export interface MigrateResult {
|
|
13
|
+
/** Repo-relative files whose content changed (or would change on a dry run). */
|
|
14
|
+
rewritten: string[];
|
|
15
|
+
/** A complete `codestyle.config.ts`, or `''` when there is no old config. */
|
|
16
|
+
configSkeleton: string;
|
|
17
|
+
/** One line per thing the migration dropped or could not do. */
|
|
18
|
+
notes: string[];
|
|
19
|
+
}
|
|
20
|
+
export declare const migrate: (root: string, io: {
|
|
21
|
+
write: boolean;
|
|
22
|
+
}) => MigrateResult;
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `codestyle migrate` — the mechanical half of moving a repo from 0.5.x to 1.0:
|
|
3
|
+
* 1. rewrite `invariant-ignore:` → `codestyle-ignore:` and every renamed package id (`core/R1` →
|
|
4
|
+
* `no-bare-storage`) inside suppression comments and in `invariants/config.mts`;
|
|
5
|
+
* 2. print (or write) a `codestyle.config.ts` skeleton from the old config.
|
|
6
|
+
* Moving `invariants/` to `codestyle/` stays manual — the recipe in the README.
|
|
7
|
+
*
|
|
8
|
+
* The old config cannot be imported (the 0.5.x subpath it imports no longer exists), so it is read
|
|
9
|
+
* as text: each section's object literal is evaluated on its own, once, at migration time — it is
|
|
10
|
+
* the developer's own config. A section that will not evaluate is carried as a TODO, never dropped.
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import { resolve } from 'node:path';
|
|
14
|
+
import { ConfigError } from '../config/define-config.js';
|
|
15
|
+
import { DEFAULT_IGNORES } from '../config/schema.js';
|
|
16
|
+
import { renamedRules } from '../rules/index.js';
|
|
17
|
+
import { sliceObjectLiteral, walk } from './util.js';
|
|
18
|
+
const OLD_CONFIG = 'invariants/config.mts';
|
|
19
|
+
const OLD_CLI = 'invariants/cli.mts';
|
|
20
|
+
const NEW_CONFIG = 'codestyle.config.ts';
|
|
21
|
+
const REWRITE = /\.(?:ts|mts|js|mjs|vue|scss|md)$/;
|
|
22
|
+
const escape = (s) => s.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
|
|
23
|
+
/** Whole tokens only: `core/R1` must not touch `core/R10`. */
|
|
24
|
+
const RENAMED = Object.entries(renamedRules).map(([from, to]) => [new RegExp(`(?<![\\w/-])${escape(from)}(?![\\w-])`, 'g'), to]);
|
|
25
|
+
const renameIds = (text) => RENAMED.reduce((t, [re, to]) => t.replace(re, to), text);
|
|
26
|
+
const renamedName = (id) => (Object.hasOwn(renamedRules, id) ? renamedRules[id] : id);
|
|
27
|
+
/**
|
|
28
|
+
* Every `invariant-ignore:` marker becomes `codestyle-ignore:` — every one on a line, in comments
|
|
29
|
+
* and in strings alike (a fixture string holding an ignore comment is a proof that wants the 1.0
|
|
30
|
+
* spelling too). Renamed ids are rewritten from a line's first marker onwards, so every marker's
|
|
31
|
+
* ids are covered; in the old config, anywhere. Idempotent: a second pass changes nothing.
|
|
32
|
+
*/
|
|
33
|
+
const rewrite = (file, source) => {
|
|
34
|
+
if (file === OLD_CONFIG)
|
|
35
|
+
return renameIds(source.replace(/invariant-ignore:/g, 'codestyle-ignore:'));
|
|
36
|
+
return source.split('\n').map(line => {
|
|
37
|
+
const at = line.search(/(?:invariant|codestyle)-ignore:/);
|
|
38
|
+
if (at === -1)
|
|
39
|
+
return line;
|
|
40
|
+
return line.slice(0, at) + renameIds(line.slice(at).replace(/invariant-ignore:/g, 'codestyle-ignore:'));
|
|
41
|
+
}).join('\n');
|
|
42
|
+
};
|
|
43
|
+
/** `export const <key>…= { … }` → its evaluated value, its raw text when evaluation fails, or null. */
|
|
44
|
+
const section = (source, key) => {
|
|
45
|
+
const m = new RegExp(`export\\s+const\\s+${key}\\b[^=]*=`).exec(source);
|
|
46
|
+
if (!m)
|
|
47
|
+
return null;
|
|
48
|
+
const literal = sliceObjectLiteral(source, m.index + m[0].length);
|
|
49
|
+
try {
|
|
50
|
+
return { ok: true, value: new Function(`return (${literal})`)() };
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return { ok: false, raw: literal };
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
/** The `[ ... ]` literal starting at the first `[` after `fromIndex`, balanced on brackets. */
|
|
57
|
+
const sliceArrayLiteral = (source, fromIndex) => {
|
|
58
|
+
const start = source.indexOf('[', fromIndex);
|
|
59
|
+
if (start === -1)
|
|
60
|
+
return '';
|
|
61
|
+
let depth = 0;
|
|
62
|
+
for (let i = start; i < source.length; i++) {
|
|
63
|
+
if (source[i] === '[')
|
|
64
|
+
depth++;
|
|
65
|
+
else if (source[i] === ']' && --depth === 0)
|
|
66
|
+
return source.slice(start, i + 1);
|
|
67
|
+
}
|
|
68
|
+
return source.slice(start);
|
|
69
|
+
};
|
|
70
|
+
/** A string array assigned to one of `keys` in the 0.5.x composition root (`scanRoots: [...]`). */
|
|
71
|
+
const arrayIn = (source, keys) => {
|
|
72
|
+
const m = new RegExp(`\\b(?:${keys.join('|')})\\s*[:=]\\s*(?=\\[)`).exec(source);
|
|
73
|
+
if (!m)
|
|
74
|
+
return null;
|
|
75
|
+
const literal = sliceArrayLiteral(source, m.index + m[0].length);
|
|
76
|
+
try {
|
|
77
|
+
const value = new Function(`return (${literal})`)();
|
|
78
|
+
return Array.isArray(value) && value.every(v => typeof v === 'string') ? { ok: true, value } : { ok: false, raw: literal };
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return { ok: false, raw: literal };
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
/** The old tree moves to `codestyle/`; its paths follow. */
|
|
85
|
+
const moved = (p) => p.replace(/^invariants(?=\/|$)/, 'codestyle');
|
|
86
|
+
const q = (s) => JSON.stringify(s);
|
|
87
|
+
const todo = (key, raw) => [` // TODO: could not evaluate ${key} — port by hand:`, ...raw.split('\n').map(l => ` // ${l}`)];
|
|
88
|
+
const skeletonOf = (source, cli, notes) => {
|
|
89
|
+
const lines = [
|
|
90
|
+
"import { defineConfig } from '@brighterly/lib-fe-codestyle'",
|
|
91
|
+
"// custom rules: import repo from './codestyle/index.ts' and spread it: ...repo",
|
|
92
|
+
'',
|
|
93
|
+
'export default defineConfig({',
|
|
94
|
+
];
|
|
95
|
+
// The scan roots and rule sources lived in the composition root, not the config. Dropping them
|
|
96
|
+
// would shrink the tree to the defaults and silence any rule scoped outside it.
|
|
97
|
+
if (cli !== null) {
|
|
98
|
+
for (const [key, names] of [['files', ['scanRoots', 'SCAN_ROOTS']], ['ruleSources', ['ruleSources', 'RULE_SOURCES']]]) {
|
|
99
|
+
const found = arrayIn(cli, [...names]);
|
|
100
|
+
if (found?.ok)
|
|
101
|
+
lines.push(` ${key}: [${found.value.map(p => q(moved(p))).join(', ')}],`);
|
|
102
|
+
else if (found)
|
|
103
|
+
lines.push(...todo(key, found.raw));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const settings = section(source, 'settings');
|
|
107
|
+
if (settings?.ok) {
|
|
108
|
+
const entries = Object.entries(settings.value);
|
|
109
|
+
if (entries.some(([k]) => k === 'selfTestFile'))
|
|
110
|
+
notes.push('migrate: dropped settings.selfTestFile — fixtures live on the rule objects; files under ruleSources are exempt from ignore parsing');
|
|
111
|
+
const kept = entries.filter(([k]) => k !== 'selfTestFile');
|
|
112
|
+
if (kept.length) {
|
|
113
|
+
lines.push(' settings: {', ...kept.map(([k, v]) => (typeof v === 'string'
|
|
114
|
+
? ` ${q(k)}: ${q(v)},`
|
|
115
|
+
: ` // TODO: could not read settings[${q(k)}] — port by hand: ${JSON.stringify(v)}`)), ' },');
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
else if (settings)
|
|
119
|
+
lines.push(...todo('settings', settings.raw));
|
|
120
|
+
const levels = section(source, 'levels');
|
|
121
|
+
if (levels?.ok) {
|
|
122
|
+
const entries = Object.entries(levels.value);
|
|
123
|
+
const isLevel = (v) => typeof v === 'object' && v !== null && typeof v.level === 'string';
|
|
124
|
+
if (entries.length) {
|
|
125
|
+
lines.push(' rules: {', ...entries.map(([id, o]) => (isLevel(o)
|
|
126
|
+
? ` ${q(renamedName(id))}: ${q(o.level)},${typeof o.reason === 'string' && o.reason ? ` // ${o.reason.replace(/\n/g, ' ')}` : ''}`
|
|
127
|
+
: ` // TODO: could not read levels[${q(id)}] — port by hand: ${JSON.stringify(o)}`)), ' },');
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
else if (levels)
|
|
131
|
+
lines.push(...todo('levels', levels.raw));
|
|
132
|
+
const allowlists = section(source, 'allowlists');
|
|
133
|
+
if (allowlists?.ok) {
|
|
134
|
+
const entries = Object.entries(allowlists.value)
|
|
135
|
+
.flatMap(([id, paths]) => Object.entries(paths).map(([path, reason]) => ` { files: [${q(path)}], rules: { ${q(renamedName(id))}: "off" }, reason: ${q(reason)} },`));
|
|
136
|
+
if (entries.length)
|
|
137
|
+
lines.push(' overrides: [', ...entries, ' ],');
|
|
138
|
+
}
|
|
139
|
+
else if (allowlists)
|
|
140
|
+
lines.push(...todo('allowlists', allowlists.raw));
|
|
141
|
+
if (/export\s+const\s+unmaintained\b/.test(source))
|
|
142
|
+
notes.push('migrate: dropped unmaintained — 1.0 has no exclusion list; prune a tree with `ignores`, or exempt it with an override and its reason');
|
|
143
|
+
if (/export\s+const\s+retired\b/.test(source))
|
|
144
|
+
notes.push('migrate: dropped retired — a dead name is reported by IG1; renamed package ids say where they went');
|
|
145
|
+
lines.push('})', '');
|
|
146
|
+
return lines.join('\n');
|
|
147
|
+
};
|
|
148
|
+
export const migrate = (root, io) => {
|
|
149
|
+
// Never overwrite a config, and refuse before any file is touched.
|
|
150
|
+
if (io.write && existsSync(resolve(root, NEW_CONFIG)))
|
|
151
|
+
throw new ConfigError(`${NEW_CONFIG} already exists — delete it or run without --write`);
|
|
152
|
+
const notes = [];
|
|
153
|
+
const oldPath = resolve(root, OLD_CONFIG);
|
|
154
|
+
const oldSource = existsSync(oldPath) ? readFileSync(oldPath, 'utf8') : null;
|
|
155
|
+
const cliPath = resolve(root, OLD_CLI);
|
|
156
|
+
const cliSource = existsSync(cliPath) ? readFileSync(cliPath, 'utf8') : null;
|
|
157
|
+
const configSkeleton = oldSource === null ? '' : skeletonOf(oldSource, cliSource, notes);
|
|
158
|
+
if (oldSource === null)
|
|
159
|
+
notes.push(`migrate: no ${OLD_CONFIG} — no config skeleton; write ${NEW_CONFIG} by hand`);
|
|
160
|
+
const ignored = new Set(DEFAULT_IGNORES);
|
|
161
|
+
const files = walk(root, '', rel => ignored.has(rel.slice(rel.lastIndexOf('/') + 1))).filter(f => REWRITE.test(f));
|
|
162
|
+
const rewritten = [];
|
|
163
|
+
for (const file of files) {
|
|
164
|
+
const source = readFileSync(resolve(root, file), 'utf8');
|
|
165
|
+
const next = rewrite(file, source);
|
|
166
|
+
if (next === source)
|
|
167
|
+
continue;
|
|
168
|
+
rewritten.push(file);
|
|
169
|
+
if (io.write)
|
|
170
|
+
writeFileSync(resolve(root, file), next);
|
|
171
|
+
}
|
|
172
|
+
if (io.write && configSkeleton)
|
|
173
|
+
writeFileSync(resolve(root, NEW_CONFIG), configSkeleton);
|
|
174
|
+
return { rewritten: rewritten.sort(), configSkeleton, notes };
|
|
175
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-place overrides: "never the rule is off — always this place, this reason".
|
|
3
|
+
*
|
|
4
|
+
* Compiled once per run from the config against the registry. The runner asks `resolve` for
|
|
5
|
+
* each finding's effective level; a rule receives its own entries through
|
|
6
|
+
* `RuleContext.overrides` (`forRule`). Everything wrong with an entry is a `problem` reported
|
|
7
|
+
* against the config file as IG1, never a silent no-op:
|
|
8
|
+
* a rules key naming no live rule (or an external one, or a renamed id),
|
|
9
|
+
* a glob that matches no file of the scanned tree,
|
|
10
|
+
* an `until` in the past (the entry then stops applying),
|
|
11
|
+
* a missing `until` on a rule that requires one (the entry does not apply to that rule).
|
|
12
|
+
*
|
|
13
|
+
* Pure: the scanned files, the config source and today's date all arrive through `io`.
|
|
14
|
+
*/
|
|
15
|
+
import type { ResolvedConfig } from '../config/schema.js';
|
|
16
|
+
import type { Registry } from './registry.js';
|
|
17
|
+
import type { EffectiveOverride, Finding, Level, ReportedFinding, RuleName } from './types.js';
|
|
18
|
+
export interface CompiledOverrides {
|
|
19
|
+
/** The non-expired entries naming `name`, window-adjusted, in config order — what `RuleContext.overrides` receives. */
|
|
20
|
+
forRule: (name: RuleName) => EffectiveOverride[];
|
|
21
|
+
/** `min(level, lowest matching entry)` on `finding.subject ?? finding.file`; never raises. */
|
|
22
|
+
resolve: (finding: Finding, name: RuleName, level: Level) => Level;
|
|
23
|
+
/** Reported against the config file, rule `IG1`, level `error`. */
|
|
24
|
+
problems: ReportedFinding[];
|
|
25
|
+
}
|
|
26
|
+
export interface OverridesIo {
|
|
27
|
+
/** Repo-relative path the problems are reported against. */
|
|
28
|
+
configFile: string;
|
|
29
|
+
/** Its text, so a problem can point at the entry's line. */
|
|
30
|
+
configSource?: string;
|
|
31
|
+
/** The FULL scanned tree, repo-relative — never a --staged/--stdin subset. */
|
|
32
|
+
files: string[];
|
|
33
|
+
/** Today as `YYYY-MM-DD`. */
|
|
34
|
+
today: string;
|
|
35
|
+
}
|
|
36
|
+
export declare const compileOverrides: (config: ResolvedConfig, registry: Registry, io: OverridesIo) => CompiledOverrides;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-place overrides: "never the rule is off — always this place, this reason".
|
|
3
|
+
*
|
|
4
|
+
* Compiled once per run from the config against the registry. The runner asks `resolve` for
|
|
5
|
+
* each finding's effective level; a rule receives its own entries through
|
|
6
|
+
* `RuleContext.overrides` (`forRule`). Everything wrong with an entry is a `problem` reported
|
|
7
|
+
* against the config file as IG1, never a silent no-op:
|
|
8
|
+
* a rules key naming no live rule (or an external one, or a renamed id),
|
|
9
|
+
* a glob that matches no file of the scanned tree,
|
|
10
|
+
* an `until` in the past (the entry then stops applying),
|
|
11
|
+
* a missing `until` on a rule that requires one (the entry does not apply to that rule).
|
|
12
|
+
*
|
|
13
|
+
* Pure: the scanned files, the config source and today's date all arrive through `io`.
|
|
14
|
+
*/
|
|
15
|
+
import { exemptionExpiry, globToRegExp } from './util.js';
|
|
16
|
+
const RANK = { off: 0, warn: 1, error: 2 };
|
|
17
|
+
const lower = (a, b) => (RANK[a] <= RANK[b] ? a : b);
|
|
18
|
+
const higher = (a, b) => (RANK[a] >= RANK[b] ? a : b);
|
|
19
|
+
/** `iso` minus `days` calendar days, as `YYYY-MM-DD`. */
|
|
20
|
+
const minusDays = (iso, days) => {
|
|
21
|
+
const [y, m, d] = iso.split('-').map(Number);
|
|
22
|
+
return new Date(Date.UTC(y, m - 1, d - days)).toISOString().slice(0, 10);
|
|
23
|
+
};
|
|
24
|
+
export const compileOverrides = (config, registry, io) => {
|
|
25
|
+
const problems = [];
|
|
26
|
+
const lines = io.configSource?.split('\n') ?? [];
|
|
27
|
+
/** First line containing the entry's reason, else the glob, else 1. */
|
|
28
|
+
const lineOf = (...needles) => {
|
|
29
|
+
for (const needle of needles) {
|
|
30
|
+
const i = needle === '' ? -1 : lines.findIndex(l => l.includes(needle));
|
|
31
|
+
if (i !== -1)
|
|
32
|
+
return i + 1;
|
|
33
|
+
}
|
|
34
|
+
return 1;
|
|
35
|
+
};
|
|
36
|
+
const report = (entryIndex, message, glob = '') => {
|
|
37
|
+
const line = lineOf(config.overrides[entryIndex]?.reason ?? '', glob);
|
|
38
|
+
problems.push({ file: io.configFile, line, message, rule: 'IG1', level: 'error' });
|
|
39
|
+
};
|
|
40
|
+
const compiled = [];
|
|
41
|
+
config.overrides.forEach((o, i) => {
|
|
42
|
+
const patterns = o.files.map(g => globToRegExp(g));
|
|
43
|
+
o.files.forEach((glob, j) => {
|
|
44
|
+
if (!io.files.some(f => patterns[j].test(f)))
|
|
45
|
+
report(i, `overrides[${i}].files[${j}] "${glob}" matches no file — the exemption is inert; drop it or fix the glob`, glob);
|
|
46
|
+
});
|
|
47
|
+
const until = exemptionExpiry(o.reason);
|
|
48
|
+
if (until !== null && io.today > until) {
|
|
49
|
+
report(i, `overrides[${i}] expired on ${until} — remove the entry or renew it`);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const inWindow = until !== null && io.today >= minusDays(until, 7);
|
|
53
|
+
for (const [name, entryLevel] of Object.entries(o.rules)) {
|
|
54
|
+
const label = `overrides[${i}].rules["${name}"]`;
|
|
55
|
+
if (!registry.live.has(name)) {
|
|
56
|
+
const to = Object.hasOwn(registry.renamed, name) ? registry.renamed[name] : undefined;
|
|
57
|
+
report(i, `${label}: unknown rule${to ? ` — it was renamed to ${to}` : ''}`);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const reg = registry.byName.get(name);
|
|
61
|
+
if (reg && (reg.owner === 'eslint' || reg.owner === 'test')) {
|
|
62
|
+
report(i, `${label}: ${name} is owned by ${reg.owner === 'eslint' ? 'eslint' : 'a test'} — an override cannot change it`);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (until === null && reg && 'exemptionsRequireUntil' in reg.rule && reg.rule.exemptionsRequireUntil) {
|
|
66
|
+
report(i, `overrides[${i}] names ${name}, which requires an "until YYYY-MM-DD" in its reason`);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
// An entry at or above the rule's effective level changes nothing — an inert exemption.
|
|
70
|
+
if (reg && RANK[entryLevel] >= RANK[reg.level]) {
|
|
71
|
+
report(i, `overrides[${i}] never lowers ${name} (${entryLevel} ≥ ${reg.level}) — drop it`);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const effective = inWindow ? higher(entryLevel, 'warn') : entryLevel;
|
|
75
|
+
compiled.push({ name, entry: { files: [...o.files], level: effective, reason: o.reason, until }, patterns });
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
return {
|
|
79
|
+
forRule: name => compiled.filter(c => c.name === name).map(c => ({ ...c.entry, files: [...c.entry.files] })),
|
|
80
|
+
resolve: (finding, name, level) => {
|
|
81
|
+
const target = finding.subject ?? finding.file;
|
|
82
|
+
let out = level;
|
|
83
|
+
for (const c of compiled) {
|
|
84
|
+
if (c.name !== name || !c.patterns.some(p => p.test(target)))
|
|
85
|
+
continue;
|
|
86
|
+
out = lower(out, c.entry.level);
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
},
|
|
90
|
+
problems,
|
|
91
|
+
};
|
|
92
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rule registry — the one place that knows which rules exist, who owns each, and at what
|
|
3
|
+
* effective level. Overrides, the runner and the CLI all read it; nothing else decides whether
|
|
4
|
+
* a name is live.
|
|
5
|
+
*
|
|
6
|
+
* effective level = `config.rules[name]` when present, else the rule's own `level`
|
|
7
|
+
* external rules = live names with no check here: level `off`, never selected
|
|
8
|
+
* meta rules = IG0/IG1/IG2: live (a suppression may name them), never configurable
|
|
9
|
+
*/
|
|
10
|
+
import type { ResolvedConfig } from '../config/schema.js';
|
|
11
|
+
import type { AnyRule, Level, Rule, RuleName } from './types.js';
|
|
12
|
+
export interface RegisteredRule {
|
|
13
|
+
rule: AnyRule;
|
|
14
|
+
/** Effective level: the `rules` entry when present, else the rule's own `level`. */
|
|
15
|
+
level: Level;
|
|
16
|
+
owner: 'package' | 'custom' | 'eslint' | 'test';
|
|
17
|
+
tags: string[];
|
|
18
|
+
/** The config's `rules` entry for this rule, when there is one: the base level AND a ceiling over a finding's own level. */
|
|
19
|
+
configured?: Level;
|
|
20
|
+
}
|
|
21
|
+
export interface Registry {
|
|
22
|
+
/** Package first (in its given order), then custom in declaration order. */
|
|
23
|
+
all: RegisteredRule[];
|
|
24
|
+
byName: Map<RuleName, RegisteredRule>;
|
|
25
|
+
/** Checkable (non-external) rules whose effective level is not `off`, in `all` order. */
|
|
26
|
+
select: (opts: {
|
|
27
|
+
tags?: string[];
|
|
28
|
+
only?: RuleName[];
|
|
29
|
+
}) => RegisteredRule[];
|
|
30
|
+
/** Every addressable name, external and IG0/IG1/IG2 included. */
|
|
31
|
+
live: Set<RuleName>;
|
|
32
|
+
/** Old id → new name, for IG1 messages. */
|
|
33
|
+
renamed: Record<string, RuleName>;
|
|
34
|
+
}
|
|
35
|
+
export declare const buildRegistry: (config: ResolvedConfig, pkg?: Rule[]) => Registry;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rule registry — the one place that knows which rules exist, who owns each, and at what
|
|
3
|
+
* effective level. Overrides, the runner and the CLI all read it; nothing else decides whether
|
|
4
|
+
* a name is live.
|
|
5
|
+
*
|
|
6
|
+
* effective level = `config.rules[name]` when present, else the rule's own `level`
|
|
7
|
+
* external rules = live names with no check here: level `off`, never selected
|
|
8
|
+
* meta rules = IG0/IG1/IG2: live (a suppression may name them), never configurable
|
|
9
|
+
*/
|
|
10
|
+
import { ConfigError } from '../config/define-config.js';
|
|
11
|
+
import { packageRules, renamedRules } from '../rules/index.js';
|
|
12
|
+
import { isExternal } from './types.js';
|
|
13
|
+
const META = ['IG0', 'IG1', 'IG2'];
|
|
14
|
+
const META_NAMES = new Set(META);
|
|
15
|
+
/** Own keys only: `constructor` or `toString` must never read as a renamed rule. */
|
|
16
|
+
const renamedTo = (name) => (Object.hasOwn(renamedRules, name) ? renamedRules[name] : undefined);
|
|
17
|
+
const ownerWording = (owner) => (owner === 'eslint' ? 'eslint' : 'a test');
|
|
18
|
+
const unknownName = (label, name) => {
|
|
19
|
+
const to = renamedTo(name);
|
|
20
|
+
return new ConfigError(`${label}: unknown rule${to ? ` — it was renamed to ${to}` : ''}`);
|
|
21
|
+
};
|
|
22
|
+
export const buildRegistry = (config, pkg = packageRules) => {
|
|
23
|
+
const levels = config.rules;
|
|
24
|
+
/** Own keys only: a rule named `toString` must not read `Object.prototype.toString` as its level. */
|
|
25
|
+
const configuredFor = (name) => (Object.hasOwn(levels, name) ? levels[name] : undefined);
|
|
26
|
+
const levelFor = (name, fallback) => configuredFor(name) ?? fallback;
|
|
27
|
+
const byName = new Map();
|
|
28
|
+
const all = [];
|
|
29
|
+
const add = (entry) => {
|
|
30
|
+
all.push(entry);
|
|
31
|
+
byName.set(entry.rule.name, entry);
|
|
32
|
+
};
|
|
33
|
+
for (const rule of pkg) {
|
|
34
|
+
if (byName.has(rule.name))
|
|
35
|
+
throw new ConfigError(`${rule.name} is declared twice in the package rules`);
|
|
36
|
+
add({ rule, level: levelFor(rule.name, rule.level), owner: 'package', tags: [...(rule.tags ?? [])], configured: configuredFor(rule.name) });
|
|
37
|
+
}
|
|
38
|
+
for (const rule of config.custom) {
|
|
39
|
+
const name = rule.name;
|
|
40
|
+
const taken = byName.get(name);
|
|
41
|
+
if (taken) {
|
|
42
|
+
throw new ConfigError(taken.owner === 'package'
|
|
43
|
+
? `${name} is both a package rule and a custom rule — rename the custom one`
|
|
44
|
+
: `${name} is declared twice in custom — rule names must be unique`);
|
|
45
|
+
}
|
|
46
|
+
const to = renamedTo(name);
|
|
47
|
+
if (to)
|
|
48
|
+
throw new ConfigError(`custom rule "${name}" uses a retired package id (renamed to ${to}) — pick another name`);
|
|
49
|
+
if (META_NAMES.has(name))
|
|
50
|
+
throw new ConfigError(`custom rule "${name}" uses an engine rule name — pick another name`);
|
|
51
|
+
if (isExternal(rule))
|
|
52
|
+
add({ rule, level: 'off', owner: rule.owner, tags: [...(rule.tags ?? [])] });
|
|
53
|
+
else
|
|
54
|
+
add({ rule, level: levelFor(name, rule.level), owner: 'custom', tags: [...(rule.tags ?? [])], configured: configuredFor(name) });
|
|
55
|
+
}
|
|
56
|
+
for (const name of Object.keys(levels)) {
|
|
57
|
+
const label = `rules["${name}"]`;
|
|
58
|
+
if (META_NAMES.has(name))
|
|
59
|
+
throw new ConfigError(`${label}: ${name} is an engine rule and cannot be configured`);
|
|
60
|
+
const entry = byName.get(name);
|
|
61
|
+
if (!entry)
|
|
62
|
+
throw unknownName(label, name);
|
|
63
|
+
if (entry.owner === 'eslint')
|
|
64
|
+
throw new ConfigError(`${label}: ${name} is owned by ${ownerWording(entry.owner)} — set its level in the ESLint config`);
|
|
65
|
+
if (entry.owner === 'test')
|
|
66
|
+
throw new ConfigError(`${label}: ${name} is owned by ${ownerWording(entry.owner)} — it has no level here; the test passes or fails`);
|
|
67
|
+
}
|
|
68
|
+
const checkable = (r) => r.owner === 'package' || r.owner === 'custom';
|
|
69
|
+
const select = ({ tags, only }) => {
|
|
70
|
+
let picked = all.filter(r => checkable(r) && r.level !== 'off');
|
|
71
|
+
// A tag no rule carries selects nothing and would print PASS over an empty gate — a typo, not a filter.
|
|
72
|
+
for (const t of tags ?? []) {
|
|
73
|
+
if (!all.some(r => r.tags.includes(t)))
|
|
74
|
+
throw new ConfigError(`no rule carries tag "${t}" — codestyle list prints the tags`);
|
|
75
|
+
}
|
|
76
|
+
// An empty filter is no filter: the CLI passes [] or undefined for "everything".
|
|
77
|
+
if (only?.length) {
|
|
78
|
+
for (const name of only) {
|
|
79
|
+
const entry = byName.get(name);
|
|
80
|
+
if (!entry)
|
|
81
|
+
throw unknownName(`--only "${name}"`, name);
|
|
82
|
+
if (!checkable(entry))
|
|
83
|
+
throw new ConfigError(`--only "${name}": owned by ${ownerWording(entry.owner)}, so there is nothing to run here`);
|
|
84
|
+
if (entry.level === 'off')
|
|
85
|
+
throw new ConfigError(`${name} is off in the config — raise its level or drop --only`);
|
|
86
|
+
}
|
|
87
|
+
picked = picked.filter(r => only.includes(r.rule.name));
|
|
88
|
+
}
|
|
89
|
+
if (tags?.length)
|
|
90
|
+
picked = picked.filter(r => r.tags.some(t => tags.includes(t)));
|
|
91
|
+
return picked;
|
|
92
|
+
};
|
|
93
|
+
return {
|
|
94
|
+
all,
|
|
95
|
+
byName,
|
|
96
|
+
select,
|
|
97
|
+
live: new Set([...byName.keys(), ...META]),
|
|
98
|
+
renamed: renamedRules,
|
|
99
|
+
};
|
|
100
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The human reporter: findings grouped by file, each rule's why/fix once, then the PASS/FAIL
|
|
3
|
+
* line. Layout is deliberate — where, which rule, what happened, what to do — so a long run
|
|
4
|
+
* stays scannable. The JSON shape lives with `check()`; this file only prints for people.
|
|
5
|
+
*/
|
|
6
|
+
import type { MetaRuleId } from './meta.js';
|
|
7
|
+
import type { Registry } from './registry.js';
|
|
8
|
+
import type { ReportedFinding } from './types.js';
|
|
9
|
+
export interface ReportOptions {
|
|
10
|
+
/** ANSI colour; the caller turns it off under `--no-color`, NO_COLOR or a non-TTY stdout. */
|
|
11
|
+
color: boolean;
|
|
12
|
+
/** How many files were checked, for the summary line. */
|
|
13
|
+
scanned: number;
|
|
14
|
+
/** The repo's own rules failed their proofs: the run fails even with no error finding. */
|
|
15
|
+
selfTestFailed?: boolean;
|
|
16
|
+
}
|
|
17
|
+
type MetaInfo = Record<MetaRuleId, {
|
|
18
|
+
title: string;
|
|
19
|
+
why: string;
|
|
20
|
+
fix: string;
|
|
21
|
+
}>;
|
|
22
|
+
export declare const printReport: (findings: ReportedFinding[], registry: Registry, meta: MetaInfo, opts: ReportOptions) => void;
|
|
23
|
+
export {};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The human reporter: findings grouped by file, each rule's why/fix once, then the PASS/FAIL
|
|
3
|
+
* line. Layout is deliberate — where, which rule, what happened, what to do — so a long run
|
|
4
|
+
* stays scannable. The JSON shape lives with `check()`; this file only prints for people.
|
|
5
|
+
*/
|
|
6
|
+
const WIDTH = 78;
|
|
7
|
+
/** Wrap prose to WIDTH with a hanging indent, so long `why` text stays readable. */
|
|
8
|
+
const wrap = (text, indent) => {
|
|
9
|
+
const width = WIDTH - indent;
|
|
10
|
+
const out = [];
|
|
11
|
+
let line = '';
|
|
12
|
+
for (const word of text.split(/\s+/)) {
|
|
13
|
+
if (line && `${line} ${word}`.length > width) {
|
|
14
|
+
out.push(line);
|
|
15
|
+
line = word;
|
|
16
|
+
}
|
|
17
|
+
else {
|
|
18
|
+
line = line ? `${line} ${word}` : word;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
if (line)
|
|
22
|
+
out.push(line);
|
|
23
|
+
return out;
|
|
24
|
+
};
|
|
25
|
+
export const printReport = (findings, registry, meta, opts) => {
|
|
26
|
+
const paint = (code) => (text) => (opts.color ? `\u001B[${code}m${text}\u001B[0m` : text);
|
|
27
|
+
const c = { red: paint('31'), redBold: paint('1;31'), yellow: paint('33'), yellowBold: paint('1;33'), green: paint('32'), cyan: paint('36'), bold: paint('1'), dim: paint('2') };
|
|
28
|
+
const infoOf = (name) => {
|
|
29
|
+
if (Object.hasOwn(meta, name))
|
|
30
|
+
return meta[name];
|
|
31
|
+
const rule = registry.byName.get(name)?.rule;
|
|
32
|
+
return rule ? { title: rule.title, why: rule.why, fix: 'fix' in rule ? rule.fix : undefined } : undefined;
|
|
33
|
+
};
|
|
34
|
+
const ordered = [...findings].sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
|
|
35
|
+
const byFile = new Map();
|
|
36
|
+
for (const f of ordered)
|
|
37
|
+
byFile.set(f.file, [...(byFile.get(f.file) ?? []), f]);
|
|
38
|
+
console.log();
|
|
39
|
+
for (const [file, list] of byFile) {
|
|
40
|
+
console.log(c.cyan(file));
|
|
41
|
+
for (const f of list) {
|
|
42
|
+
const label = f.level === 'error' ? c.redBold('error') : c.yellowBold('warn ');
|
|
43
|
+
console.log(` ${String(f.line).padStart(4)} ${label} ${c.bold(f.rule)} ${f.message}`);
|
|
44
|
+
}
|
|
45
|
+
console.log();
|
|
46
|
+
}
|
|
47
|
+
// The rationale once per rule, not once per finding.
|
|
48
|
+
const fired = [...new Set(ordered.map(f => f.rule))];
|
|
49
|
+
if (fired.length) {
|
|
50
|
+
console.log(c.dim('─'.repeat(WIDTH)));
|
|
51
|
+
for (const name of fired) {
|
|
52
|
+
const info = infoOf(name);
|
|
53
|
+
console.log(`${c.bold(name)} ${info?.title ?? 'unattributed finding'}`);
|
|
54
|
+
if (info?.why)
|
|
55
|
+
for (const [i, l] of wrap(info.why, 8).entries())
|
|
56
|
+
console.log(` ${i === 0 ? c.dim('why ') : ' '}${c.dim(l)}`);
|
|
57
|
+
if (info?.fix)
|
|
58
|
+
for (const [i, l] of wrap(info.fix, 8).entries())
|
|
59
|
+
console.log(` ${i === 0 ? c.dim('fix ') : ' '}${c.green(l)}`);
|
|
60
|
+
console.log();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const errors = findings.filter(f => f.level === 'error').length;
|
|
64
|
+
const warnings = findings.filter(f => f.level === 'warn').length;
|
|
65
|
+
const totals = [
|
|
66
|
+
errors ? c.red(`${errors} error${errors === 1 ? '' : 's'}`) : null,
|
|
67
|
+
warnings ? c.yellow(`${warnings} warning${warnings === 1 ? '' : 's'}`) : null,
|
|
68
|
+
].filter(Boolean).join(c.dim(' · '));
|
|
69
|
+
const scanned = c.dim(` — ${opts.scanned} file(s) checked`);
|
|
70
|
+
console.log(c.dim('─'.repeat(WIDTH)));
|
|
71
|
+
const selfTest = opts.selfTestFailed ? c.red('the self-test failed (see stderr)') : null;
|
|
72
|
+
if (errors === 0 && !selfTest)
|
|
73
|
+
console.log(`${c.green('PASS')} ${totals || 'no findings'}${scanned}`);
|
|
74
|
+
else {
|
|
75
|
+
console.log(`${c.red('FAIL')} ${[selfTest, totals].filter(Boolean).join(c.dim(' · '))}${scanned}`);
|
|
76
|
+
// Never advertise the escape hatch when every error is noIgnore or a meta rule.
|
|
77
|
+
const suppressible = findings.some(f => {
|
|
78
|
+
if (f.level !== 'error')
|
|
79
|
+
return false;
|
|
80
|
+
const rule = registry.byName.get(f.rule)?.rule;
|
|
81
|
+
return rule !== undefined && !('noIgnore' in rule && rule.noIgnore);
|
|
82
|
+
});
|
|
83
|
+
if (suppressible)
|
|
84
|
+
console.log(c.dim(' suppress a reviewed line with // codestyle-ignore: <rule> — <reason>'));
|
|
85
|
+
}
|
|
86
|
+
console.log();
|
|
87
|
+
};
|