@design.estate/wcctools 6.0.0 → 6.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/changelog.md +13 -0
- package/dist_shell/bundle.js +3 -3
- package/dist_shell/bundle.js.map +1 -1
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/cli.js +8 -2
- package/dist_ts/plugins.d.ts +7 -2
- package/dist_ts/plugins.js +9 -3
- package/dist_ts/standards/baselines.d.ts +27 -0
- package/dist_ts/standards/baselines.js +83 -0
- package/dist_ts/standards/checker.d.ts +21 -0
- package/dist_ts/standards/checker.js +88 -0
- package/dist_ts/standards/classes.gitrepo.d.ts +13 -0
- package/dist_ts/standards/classes.gitrepo.js +43 -0
- package/dist_ts/standards/classes.sourceedits.d.ts +15 -0
- package/dist_ts/standards/classes.sourceedits.js +54 -0
- package/dist_ts/standards/classes.standardfixer.d.ts +40 -0
- package/dist_ts/standards/classes.standardfixer.js +210 -0
- package/dist_ts/standards/classes.standardproject.d.ts +80 -0
- package/dist_ts/standards/classes.standardproject.js +195 -0
- package/dist_ts/standards/classes.standardtree.d.ts +56 -0
- package/dist_ts/standards/classes.standardtree.js +207 -0
- package/dist_ts/standards/cli.d.ts +7 -0
- package/dist_ts/standards/cli.js +89 -0
- package/dist_ts/standards/config.d.ts +33 -0
- package/dist_ts/standards/config.js +139 -0
- package/dist_ts/standards/errors.d.ts +9 -0
- package/dist_ts/standards/errors.js +17 -0
- package/dist_ts/standards/helpers.ast.d.ts +56 -0
- package/dist_ts/standards/helpers.ast.js +172 -0
- package/dist_ts/standards/helpers.paths.d.ts +36 -0
- package/dist_ts/standards/helpers.paths.js +86 -0
- package/dist_ts/standards/index.d.ts +14 -0
- package/dist_ts/standards/index.js +15 -0
- package/dist_ts/standards/output.d.ts +7 -0
- package/dist_ts/standards/output.js +57 -0
- package/dist_ts/standards/ratchet.d.ts +15 -0
- package/dist_ts/standards/ratchet.js +45 -0
- package/dist_ts/standards/rule.d.ts +50 -0
- package/dist_ts/standards/rule.js +9 -0
- package/dist_ts/standards/rules/barrel.element-index.d.ts +7 -0
- package/dist_ts/standards/rules/barrel.element-index.js +108 -0
- package/dist_ts/standards/rules/barrel.no-demo-export.d.ts +3 -0
- package/dist_ts/standards/rules/barrel.no-demo-export.js +29 -0
- package/dist_ts/standards/rules/demo.fixture-not-exported.d.ts +3 -0
- package/dist_ts/standards/rules/demo.fixture-not-exported.js +29 -0
- package/dist_ts/standards/rules/demo.groups.d.ts +6 -0
- package/dist_ts/standards/rules/demo.groups.js +72 -0
- package/dist_ts/standards/rules/demo.required.d.ts +3 -0
- package/dist_ts/standards/rules/demo.required.js +48 -0
- package/dist_ts/standards/rules/demo.separate-file.d.ts +3 -0
- package/dist_ts/standards/rules/demo.separate-file.js +38 -0
- package/dist_ts/standards/rules/demo.shape.d.ts +3 -0
- package/dist_ts/standards/rules/demo.shape.js +38 -0
- package/dist_ts/standards/rules/docs.component-index.d.ts +8 -0
- package/dist_ts/standards/rules/docs.component-index.js +84 -0
- package/dist_ts/standards/rules/element.exported.d.ts +3 -0
- package/dist_ts/standards/rules/element.exported.js +33 -0
- package/dist_ts/standards/rules/element.literal-tag.d.ts +3 -0
- package/dist_ts/standards/rules/element.literal-tag.js +30 -0
- package/dist_ts/standards/rules/element.reachable.d.ts +3 -0
- package/dist_ts/standards/rules/element.reachable.js +48 -0
- package/dist_ts/standards/rules/element.tag-map.d.ts +3 -0
- package/dist_ts/standards/rules/element.tag-map.js +83 -0
- package/dist_ts/standards/rules/i18n.literals.d.ts +3 -0
- package/dist_ts/standards/rules/i18n.literals.js +52 -0
- package/dist_ts/standards/rules/index.d.ts +12 -0
- package/dist_ts/standards/rules/index.js +83 -0
- package/dist_ts/standards/rules/layout.element-folder.d.ts +3 -0
- package/dist_ts/standards/rules/layout.element-folder.js +47 -0
- package/dist_ts/standards/rules/layout.entry-file-name.d.ts +3 -0
- package/dist_ts/standards/rules/layout.entry-file-name.js +71 -0
- package/dist_ts/standards/rules/layout.entry.d.ts +3 -0
- package/dist_ts/standards/rules/layout.entry.js +38 -0
- package/dist_ts/standards/rules/layout.group-folder.d.ts +3 -0
- package/dist_ts/standards/rules/layout.group-folder.js +22 -0
- package/dist_ts/standards/rules/layout.shared-00.d.ts +3 -0
- package/dist_ts/standards/rules/layout.shared-00.js +36 -0
- package/dist_ts/standards/rules/naming.class-matches-tag.d.ts +3 -0
- package/dist_ts/standards/rules/naming.class-matches-tag.js +26 -0
- package/dist_ts/standards/rules/naming.lowercase-files.d.ts +3 -0
- package/dist_ts/standards/rules/naming.lowercase-files.js +26 -0
- package/dist_ts/standards/rules/naming.tag-prefix.d.ts +3 -0
- package/dist_ts/standards/rules/naming.tag-prefix.js +23 -0
- package/dist_ts/standards/rules/props.accessor.d.ts +3 -0
- package/dist_ts/standards/rules/props.accessor.js +35 -0
- package/dist_ts/standards/rules/props.object-no-attribute.d.ts +3 -0
- package/dist_ts/standards/rules/props.object-no-attribute.js +42 -0
- package/dist_ts/standards/rules/props.reflect-primitive.d.ts +5 -0
- package/dist_ts/standards/rules/props.reflect-primitive.js +41 -0
- package/dist_ts/standards/rules/props.typed.d.ts +6 -0
- package/dist_ts/standards/rules/props.typed.js +64 -0
- package/dist_ts/standards/rules/shared.d.ts +38 -0
- package/dist_ts/standards/rules/shared.js +103 -0
- package/dist_ts/standards/rules/style.bdtheme.d.ts +5 -0
- package/dist_ts/standards/rules/style.bdtheme.js +28 -0
- package/dist_ts_interfaces/index.d.ts +1 -0
- package/dist_ts_interfaces/index.js +2 -1
- package/dist_ts_interfaces/standard.d.ts +93 -0
- package/dist_ts_interfaces/standard.js +6 -0
- package/dist_ts_web/00_commitinfo_data.js +1 -1
- package/package.json +4 -2
- package/readme.md +34 -1
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/cli.ts +8 -1
- package/ts/plugins.ts +10 -2
- package/ts/standards/baselines.ts +100 -0
- package/ts/standards/checker.ts +114 -0
- package/ts/standards/classes.gitrepo.ts +48 -0
- package/ts/standards/classes.sourceedits.ts +58 -0
- package/ts/standards/classes.standardfixer.ts +234 -0
- package/ts/standards/classes.standardproject.ts +250 -0
- package/ts/standards/classes.standardtree.ts +220 -0
- package/ts/standards/cli.ts +86 -0
- package/ts/standards/config.ts +160 -0
- package/ts/standards/errors.ts +18 -0
- package/ts/standards/helpers.ast.ts +208 -0
- package/ts/standards/helpers.paths.ts +119 -0
- package/ts/standards/index.ts +22 -0
- package/ts/standards/output.ts +65 -0
- package/ts/standards/ratchet.ts +63 -0
- package/ts/standards/rule.ts +58 -0
- package/ts/standards/rules/barrel.element-index.ts +112 -0
- package/ts/standards/rules/barrel.no-demo-export.ts +30 -0
- package/ts/standards/rules/demo.fixture-not-exported.ts +30 -0
- package/ts/standards/rules/demo.groups.ts +74 -0
- package/ts/standards/rules/demo.required.ts +49 -0
- package/ts/standards/rules/demo.separate-file.ts +40 -0
- package/ts/standards/rules/demo.shape.ts +42 -0
- package/ts/standards/rules/docs.component-index.ts +88 -0
- package/ts/standards/rules/element.exported.ts +35 -0
- package/ts/standards/rules/element.literal-tag.ts +30 -0
- package/ts/standards/rules/element.reachable.ts +49 -0
- package/ts/standards/rules/element.tag-map.ts +87 -0
- package/ts/standards/rules/i18n.literals.ts +58 -0
- package/ts/standards/rules/index.ts +87 -0
- package/ts/standards/rules/layout.element-folder.ts +49 -0
- package/ts/standards/rules/layout.entry-file-name.ts +73 -0
- package/ts/standards/rules/layout.entry.ts +40 -0
- package/ts/standards/rules/layout.group-folder.ts +23 -0
- package/ts/standards/rules/layout.shared-00.ts +37 -0
- package/ts/standards/rules/naming.class-matches-tag.ts +27 -0
- package/ts/standards/rules/naming.lowercase-files.ts +27 -0
- package/ts/standards/rules/naming.tag-prefix.ts +24 -0
- package/ts/standards/rules/props.accessor.ts +37 -0
- package/ts/standards/rules/props.object-no-attribute.ts +45 -0
- package/ts/standards/rules/props.reflect-primitive.ts +44 -0
- package/ts/standards/rules/props.typed.ts +70 -0
- package/ts/standards/rules/shared.ts +127 -0
- package/ts/standards/rules/style.bdtheme.ts +31 -0
- package/ts_web/00_commitinfo_data.ts +1 -1
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import * as plugins from '../plugins.js';
|
|
2
|
+
import * as ast from './helpers.ast.js';
|
|
3
|
+
import { baseOf, isRepoPath, isTypeScriptSource, resolveSpecifier, specifierFor } from './helpers.paths.js';
|
|
4
|
+
import { SourceEdits } from './classes.sourceedits.js';
|
|
5
|
+
import { StandardProject, type TParseCache } from './classes.standardproject.js';
|
|
6
|
+
import type { IFixBuilder } from './rule.js';
|
|
7
|
+
|
|
8
|
+
const ts = plugins.typescript;
|
|
9
|
+
|
|
10
|
+
/** The changes one rule run collected. */
|
|
11
|
+
export class FixBatch implements IFixBuilder {
|
|
12
|
+
public readonly edits = new Map<string, Array<ast.ITextEdit>>();
|
|
13
|
+
public readonly creates = new Map<string, string>();
|
|
14
|
+
public readonly moves = new Map<string, string>();
|
|
15
|
+
public readonly redirects = new Map<string, string>();
|
|
16
|
+
|
|
17
|
+
public edit(fileArg: string, startArg: number, endArg: number, textArg: string): void {
|
|
18
|
+
this.edits.set(fileArg, [...(this.edits.get(fileArg) ?? []), { start: startArg, end: endArg, text: textArg }]);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
public create(fileArg: string, textArg: string): void {
|
|
22
|
+
if (!this.creates.has(fileArg)) {
|
|
23
|
+
this.creates.set(fileArg, textArg);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
public move(fromArg: string, toArg: string): void {
|
|
28
|
+
if (!this.moves.has(fromArg)) {
|
|
29
|
+
this.moves.set(fromArg, toArg);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
public redirectBarrelExports(targetArg: string, barrelTargetArg: string): void {
|
|
34
|
+
this.redirects.set(targetArg, barrelTargetArg);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The fixer's in-memory copy of a repository: the tracked TypeScript sources and the side files the
|
|
40
|
+
* standard reads, with the moves and edits applied so far. Nothing touches the disk until the
|
|
41
|
+
* fixer commits the tree. Every path a batch names must be a repository path (see isRepoPath);
|
|
42
|
+
* a batch with any other path is rejected before it changes the tree.
|
|
43
|
+
*/
|
|
44
|
+
export class StandardTree {
|
|
45
|
+
public readonly files: Map<string, string>;
|
|
46
|
+
private readonly originalTexts = new Map<string, string>();
|
|
47
|
+
/** Current path → original path, for moved files. */
|
|
48
|
+
private readonly originOf = new Map<string, string>();
|
|
49
|
+
/** Original paths that were moved away. */
|
|
50
|
+
private readonly vacated = new Set<string>();
|
|
51
|
+
public readonly created = new Set<string>();
|
|
52
|
+
/** Current path → ids of the rules that changed the file. */
|
|
53
|
+
private readonly changedBy = new Map<string, Set<string>>();
|
|
54
|
+
private readonly parseCache: TParseCache = new Map();
|
|
55
|
+
/** Every tracked file of the repository, including those the tree holds no text for (`.json`, `.css`, `.d.ts`, …). */
|
|
56
|
+
private readonly trackedFiles: ReadonlySet<string>;
|
|
57
|
+
private readonly existsOnDisk: (fileArg: string) => boolean;
|
|
58
|
+
|
|
59
|
+
constructor(filesArg: Map<string, string>, trackedFilesArg: Iterable<string>, existsOnDiskArg: (fileArg: string) => boolean) {
|
|
60
|
+
this.files = new Map(filesArg);
|
|
61
|
+
for (const [file, text] of filesArg) {
|
|
62
|
+
this.originalTexts.set(file, text);
|
|
63
|
+
}
|
|
64
|
+
this.trackedFiles = new Set(trackedFilesArg);
|
|
65
|
+
this.existsOnDisk = existsOnDiskArg;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
public exists(fileArg: string): boolean {
|
|
69
|
+
return this.files.has(fileArg) || (!this.vacated.has(fileArg) && this.existsOnDisk(fileArg));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The model of the tree's current state. */
|
|
73
|
+
public project(): StandardProject {
|
|
74
|
+
return new StandardProject(this.files, (fileArg) => this.exists(fileArg), this.parseCache);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private markChanged(fileArg: string, ruleIdArg: string): void {
|
|
78
|
+
this.changedBy.set(fileArg, new Set([...(this.changedBy.get(fileArg) ?? []), ruleIdArg]));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Applies a batch: edits, then new files, then moves with import rewriting. Returns the number of
|
|
83
|
+
* applied changes. Throws without changing the tree when the batch names a path that is not a
|
|
84
|
+
* repository path.
|
|
85
|
+
*/
|
|
86
|
+
public apply(ruleIdArg: string, batchArg: FixBatch): number {
|
|
87
|
+
const outside = [
|
|
88
|
+
...batchArg.edits.keys(),
|
|
89
|
+
...batchArg.creates.keys(),
|
|
90
|
+
...[...batchArg.moves].flat(),
|
|
91
|
+
...[...batchArg.redirects].flat(),
|
|
92
|
+
].filter((pathArg) => !isRepoPath(pathArg));
|
|
93
|
+
if (outside.length > 0) {
|
|
94
|
+
throw new Error(`The fix of ${ruleIdArg} names paths outside the repository root: ${[...new Set(outside)].join(', ')}`);
|
|
95
|
+
}
|
|
96
|
+
let applied = 0;
|
|
97
|
+
for (const [file, edits] of batchArg.edits) {
|
|
98
|
+
const text = this.files.get(file);
|
|
99
|
+
if (text === undefined) {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
const sourceEdits = new SourceEdits();
|
|
103
|
+
edits.forEach((editArg) => sourceEdits.add(editArg));
|
|
104
|
+
const next = sourceEdits.apply(text);
|
|
105
|
+
if (next !== text) {
|
|
106
|
+
this.files.set(file, next);
|
|
107
|
+
this.markChanged(file, ruleIdArg);
|
|
108
|
+
applied += sourceEdits.size;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
for (const [file, text] of batchArg.creates) {
|
|
112
|
+
if (this.exists(file)) {
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
this.files.set(file, text);
|
|
116
|
+
this.created.add(file);
|
|
117
|
+
this.markChanged(file, ruleIdArg);
|
|
118
|
+
applied++;
|
|
119
|
+
}
|
|
120
|
+
const moves = new Map<string, string>();
|
|
121
|
+
const targets = new Set<string>();
|
|
122
|
+
for (const [from, to] of batchArg.moves) {
|
|
123
|
+
if (from === to || !this.files.has(from) || targets.has(to) || (this.exists(to) && !batchArg.moves.has(to))) {
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
moves.set(from, to);
|
|
127
|
+
targets.add(to);
|
|
128
|
+
}
|
|
129
|
+
if (moves.size > 0) {
|
|
130
|
+
this.rewriteSpecifiers(ruleIdArg, moves, batchArg.redirects);
|
|
131
|
+
const texts = new Map([...moves.keys()].map((fromArg) => [fromArg, this.files.get(fromArg)!]));
|
|
132
|
+
for (const [from, to] of moves) {
|
|
133
|
+
this.files.delete(from);
|
|
134
|
+
if (this.created.delete(from)) {
|
|
135
|
+
this.created.add(to);
|
|
136
|
+
} else {
|
|
137
|
+
const origin = this.originOf.get(from) ?? from;
|
|
138
|
+
this.originOf.delete(from);
|
|
139
|
+
if (origin !== to) {
|
|
140
|
+
this.originOf.set(to, origin);
|
|
141
|
+
}
|
|
142
|
+
if (origin === from) {
|
|
143
|
+
this.vacated.add(from);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const changed = this.changedBy.get(from);
|
|
147
|
+
this.changedBy.delete(from);
|
|
148
|
+
if (changed) {
|
|
149
|
+
this.changedBy.set(to, changed);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
for (const [from, to] of moves) {
|
|
153
|
+
this.files.set(to, texts.get(from)!);
|
|
154
|
+
this.vacated.delete(to);
|
|
155
|
+
}
|
|
156
|
+
applied += moves.size;
|
|
157
|
+
}
|
|
158
|
+
return applied;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Rewrites every relative module specifier whose importing file or target moves, keeping its
|
|
163
|
+
* extension style. A specifier resolves to any tracked file (sources, `.d.ts`, `.json`, `.css`, …),
|
|
164
|
+
* so a moved file's imports of files that stay keep pointing at them. Barrel re-exports of a
|
|
165
|
+
* redirected target point at the redirect instead.
|
|
166
|
+
*/
|
|
167
|
+
private rewriteSpecifiers(ruleIdArg: string, movesArg: Map<string, string>, redirectsArg: Map<string, string>): void {
|
|
168
|
+
const known = (fileArg: string): boolean => this.files.has(fileArg) || (this.trackedFiles.has(fileArg) && !this.vacated.has(fileArg));
|
|
169
|
+
const project = this.project();
|
|
170
|
+
for (const [file, text] of [...this.files]) {
|
|
171
|
+
if (!isTypeScriptSource(file)) {
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
const newFile = movesArg.get(file) ?? file;
|
|
175
|
+
const sourceFile = project.sourceFile(file);
|
|
176
|
+
const edits = new SourceEdits();
|
|
177
|
+
for (const literal of ast.moduleSpecifierLiterals(sourceFile)) {
|
|
178
|
+
const resolved = resolveSpecifier(file, literal.text, known);
|
|
179
|
+
if (!resolved) {
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
let target = movesArg.get(resolved.target) ?? resolved.target;
|
|
183
|
+
let viaFolder = resolved.viaFolder;
|
|
184
|
+
const redirect = redirectsArg.get(resolved.target);
|
|
185
|
+
if (redirect && redirect !== newFile && ts.isExportDeclaration(literal.parent) && baseOf(newFile) === 'index.ts') {
|
|
186
|
+
target = redirect;
|
|
187
|
+
viaFolder = true;
|
|
188
|
+
}
|
|
189
|
+
if (newFile === file && target === resolved.target) {
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
const specifier = specifierFor(newFile, target, resolved.style, viaFolder);
|
|
193
|
+
if (specifier !== literal.text) {
|
|
194
|
+
edits.add({ start: literal.getStart() + 1, end: literal.end - 1, text: specifier });
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (edits.size > 0) {
|
|
198
|
+
this.files.set(file, edits.apply(text));
|
|
199
|
+
this.markChanged(file, ruleIdArg);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** The tree's changes against the repository it was read from. */
|
|
205
|
+
public plan(): plugins.interfaces.IStandardFixPlan {
|
|
206
|
+
const moves = [...this.originOf].map(([to, from]) => ({ from, to })).sort((a, b) => a.from.localeCompare(b.from));
|
|
207
|
+
const created = [...this.created].sort();
|
|
208
|
+
const edited: plugins.interfaces.IStandardFixPlan['edited'] = [];
|
|
209
|
+
for (const [file, ruleIds] of [...this.changedBy].sort(([a], [b]) => a.localeCompare(b))) {
|
|
210
|
+
if (this.created.has(file)) {
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
const origin = this.originOf.get(file) ?? file;
|
|
214
|
+
if (this.files.get(file) !== this.originalTexts.get(origin)) {
|
|
215
|
+
edited.push({ file, ruleIds: [...ruleIds].sort() });
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return { moves, created, edited };
|
|
219
|
+
}
|
|
220
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import * as plugins from '../plugins.js';
|
|
2
|
+
import { checkStandard } from './checker.js';
|
|
3
|
+
import { loadStandardProject } from './classes.standardproject.js';
|
|
4
|
+
import { StandardFixer } from './classes.standardfixer.js';
|
|
5
|
+
import { openStandardConfig } from './config.js';
|
|
6
|
+
import { StandardUsageError } from './errors.js';
|
|
7
|
+
import { renderFixResult, renderStandardReport } from './output.js';
|
|
8
|
+
import { initBaselines, updateBaselines } from './ratchet.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Registers `wcctools check` and `wcctools fix`. Both work on the current directory, the
|
|
12
|
+
* repository root that holds `.smartconfig.json`. Exit codes: 0 ok, 1 failing findings or a
|
|
13
|
+
* refused baseline update, 2 configuration or usage error (StandardConfigError/StandardUsageError).
|
|
14
|
+
*/
|
|
15
|
+
export const registerStandardCommands = (out: plugins.smartconsole.SmartConsole): void => {
|
|
16
|
+
out.cli.command({
|
|
17
|
+
name: 'check',
|
|
18
|
+
description: 'Check the catalog against the wcctools component standard (https://code.foss.global/design.estate/wcctools/src/branch/main/docs/standard.md)',
|
|
19
|
+
options: {
|
|
20
|
+
json: { type: 'boolean', description: 'Print the report as one JSON document (IStandardReport) and nothing else' },
|
|
21
|
+
verbose: { type: 'boolean', description: 'Also list info findings, findings covered by the allowlist or baselines, and baseline slack' },
|
|
22
|
+
initBaseline: { type: 'boolean', description: 'Record the current error counts as standard.baselines (only while none exist)' },
|
|
23
|
+
updateBaseline: { type: 'boolean', description: 'Lower standard.baselines to the current counts; refuses to raise any count' },
|
|
24
|
+
},
|
|
25
|
+
}, async ({ options }) => {
|
|
26
|
+
if (options.initBaseline && options.updateBaseline) {
|
|
27
|
+
throw new StandardUsageError('--init-baseline and --update-baseline exclude each other');
|
|
28
|
+
}
|
|
29
|
+
const rootDir = process.cwd();
|
|
30
|
+
const opened = openStandardConfig(rootDir);
|
|
31
|
+
const notes: string[] = [];
|
|
32
|
+
let report: plugins.interfaces.IStandardReport;
|
|
33
|
+
let refused = false;
|
|
34
|
+
if (options.initBaseline || options.updateBaseline) {
|
|
35
|
+
const result = options.initBaseline ? await initBaselines(rootDir, opened) : await updateBaselines(rootDir, opened);
|
|
36
|
+
report = result.report;
|
|
37
|
+
if (result.raised.length > 0) {
|
|
38
|
+
refused = true;
|
|
39
|
+
for (const raise of result.raised) {
|
|
40
|
+
await out.error(`refusing to raise the ${raise.ruleId} baseline of ${raise.file}: ${raise.baseline} → ${raise.current}`);
|
|
41
|
+
}
|
|
42
|
+
} else {
|
|
43
|
+
notes.push(`standard.baselines written: ${result.writtenFiles} file(s)`);
|
|
44
|
+
}
|
|
45
|
+
} else {
|
|
46
|
+
report = checkStandard(await loadStandardProject(rootDir), opened.loaded);
|
|
47
|
+
}
|
|
48
|
+
if (options.json) {
|
|
49
|
+
await out.log(JSON.stringify(report));
|
|
50
|
+
} else {
|
|
51
|
+
for (const line of [...renderStandardReport(report, !!options.verbose), ...notes]) {
|
|
52
|
+
await out.log(line);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
process.exitCode = report.ok && !refused ? 0 : 1;
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
out.cli.command({
|
|
59
|
+
name: 'fix',
|
|
60
|
+
description: 'Fix findings of the wcctools component standard with span edits and git mv; needs a clean work tree, never commits',
|
|
61
|
+
options: {
|
|
62
|
+
rule: { type: 'strings', description: 'Fix only this rule (repeatable); opt-in rules run only when named' },
|
|
63
|
+
dryRun: { type: 'boolean', description: 'Show the changes without writing anything' },
|
|
64
|
+
json: { type: 'boolean', description: 'Print the result as one JSON document (IStandardFixResult) and nothing else' },
|
|
65
|
+
verbose: { type: 'boolean', description: 'Also list info and covered findings of the remaining check' },
|
|
66
|
+
},
|
|
67
|
+
}, async ({ options }) => {
|
|
68
|
+
const rootDir = process.cwd();
|
|
69
|
+
const opened = openStandardConfig(rootDir);
|
|
70
|
+
const result = await new StandardFixer({
|
|
71
|
+
rootDir,
|
|
72
|
+
loaded: opened.loaded,
|
|
73
|
+
rules: options.rule,
|
|
74
|
+
dryRun: !!options.dryRun,
|
|
75
|
+
configWriter: opened.writer,
|
|
76
|
+
}).run();
|
|
77
|
+
if (options.json) {
|
|
78
|
+
await out.log(JSON.stringify(result));
|
|
79
|
+
} else {
|
|
80
|
+
for (const line of renderFixResult(result, !!options.verbose)) {
|
|
81
|
+
await out.log(line);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
process.exitCode = result.remaining.ok ? 0 : 1;
|
|
85
|
+
});
|
|
86
|
+
};
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import * as plugins from '../plugins.js';
|
|
2
|
+
import { StandardConfigError } from './errors.js';
|
|
3
|
+
import { severityIn } from './rule.js';
|
|
4
|
+
import { standardRules } from './rules/index.js';
|
|
5
|
+
|
|
6
|
+
type IStandardConfig = plugins.interfaces.IStandardConfig;
|
|
7
|
+
type TStandardProfile = plugins.interfaces.TStandardProfile;
|
|
8
|
+
|
|
9
|
+
/** The key of wcctools' configuration in `.smartconfig.json`. */
|
|
10
|
+
export const wcctoolsConfigKey = '@design.estate/wcctools';
|
|
11
|
+
|
|
12
|
+
/** Persists the `standard` configuration of the repository it was read from. */
|
|
13
|
+
export interface IStandardConfigWriter {
|
|
14
|
+
writeStandardConfig(configArg: IStandardConfig): Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ILoadedStandardConfig {
|
|
18
|
+
config: IStandardConfig;
|
|
19
|
+
profile: TStandardProfile;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A validated configuration together with the writer bound to the `.smartconfig.json` it was read from. */
|
|
23
|
+
export interface IOpenedStandardConfig {
|
|
24
|
+
loaded: ILoadedStandardConfig;
|
|
25
|
+
writer: IStandardConfigWriter;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Writes through the Smartconfig instance that read the configuration, so a write is refused
|
|
30
|
+
* (SmartconfigChangedError) when `.smartconfig.json` changed in between. Other keys of the
|
|
31
|
+
* `@design.estate/wcctools` block keep their values.
|
|
32
|
+
*/
|
|
33
|
+
export class SmartconfigStandardConfigWriter implements IStandardConfigWriter {
|
|
34
|
+
private readonly smartconfig: plugins.smartconfig.Smartconfig;
|
|
35
|
+
|
|
36
|
+
constructor(smartconfigArg: plugins.smartconfig.Smartconfig) {
|
|
37
|
+
this.smartconfig = smartconfigArg;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
public async writeStandardConfig(configArg: IStandardConfig): Promise<void> {
|
|
41
|
+
const block = this.smartconfig.dataFor<Record<string, unknown>>(wcctoolsConfigKey, {});
|
|
42
|
+
await this.smartconfig.writeDataFor(wcctoolsConfigKey, { ...block, standard: configArg });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const isPlainObject = (valueArg: unknown): valueArg is Record<string, unknown> =>
|
|
47
|
+
typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg);
|
|
48
|
+
|
|
49
|
+
const knownKeys = ['profile', 'tagPrefix', 'groupNames', 'allowlist', 'baselines'];
|
|
50
|
+
|
|
51
|
+
/** Validates the `standard` object; collects every problem into one StandardConfigError. */
|
|
52
|
+
export const validateStandardConfig = (valueArg: unknown): ILoadedStandardConfig => {
|
|
53
|
+
const problems: string[] = [];
|
|
54
|
+
if (valueArg === undefined) {
|
|
55
|
+
throw new StandardConfigError([`"${wcctoolsConfigKey}".standard is missing`]);
|
|
56
|
+
}
|
|
57
|
+
if (!isPlainObject(valueArg)) {
|
|
58
|
+
throw new StandardConfigError([`"${wcctoolsConfigKey}".standard must be an object`]);
|
|
59
|
+
}
|
|
60
|
+
for (const key of Object.keys(valueArg)) {
|
|
61
|
+
if (!knownKeys.includes(key)) {
|
|
62
|
+
problems.push(`standard.${key} is not a known key (${knownKeys.join(', ')})`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const profile = valueArg.profile ?? 'base';
|
|
66
|
+
if (profile !== 'base' && profile !== 'dees') {
|
|
67
|
+
problems.push(`standard.profile must be 'base' or 'dees'`);
|
|
68
|
+
}
|
|
69
|
+
if (valueArg.tagPrefix === undefined) {
|
|
70
|
+
problems.push('standard.tagPrefix is missing');
|
|
71
|
+
} else if (typeof valueArg.tagPrefix !== 'string' || !/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/.test(valueArg.tagPrefix)) {
|
|
72
|
+
problems.push('standard.tagPrefix must be a lowercase tag prefix without the trailing hyphen, e.g. "dees"');
|
|
73
|
+
}
|
|
74
|
+
if (valueArg.groupNames !== undefined) {
|
|
75
|
+
if (!isPlainObject(valueArg.groupNames)) {
|
|
76
|
+
problems.push('standard.groupNames must be an object of area → display name');
|
|
77
|
+
} else {
|
|
78
|
+
for (const [area, name] of Object.entries(valueArg.groupNames)) {
|
|
79
|
+
if (typeof name !== 'string' || name.length === 0) {
|
|
80
|
+
problems.push(`standard.groupNames.${area} must be a non-empty string`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const rulesById = new Map(standardRules.map((ruleArg) => [ruleArg.id, ruleArg]));
|
|
86
|
+
if (valueArg.allowlist !== undefined) {
|
|
87
|
+
if (!isPlainObject(valueArg.allowlist)) {
|
|
88
|
+
problems.push('standard.allowlist must be an object of rule id → entries');
|
|
89
|
+
} else {
|
|
90
|
+
for (const [ruleId, entries] of Object.entries(valueArg.allowlist)) {
|
|
91
|
+
const rule = rulesById.get(ruleId);
|
|
92
|
+
if (!rule) {
|
|
93
|
+
problems.push(`standard.allowlist: unknown rule '${ruleId}'`);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (!rule.allowlistCategories) {
|
|
97
|
+
problems.push(`standard.allowlist: rule '${ruleId}' takes no allowlist entries`);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (!isPlainObject(entries)) {
|
|
101
|
+
problems.push(`standard.allowlist['${ruleId}'] must be an object of '<file>#<member>' → '<category>: <reason>'`);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
for (const [key, value] of Object.entries(entries)) {
|
|
105
|
+
const match = typeof value === 'string' ? /^([a-z-]+):\s*(\S.*)$/.exec(value) : null;
|
|
106
|
+
if (!match) {
|
|
107
|
+
problems.push(`standard.allowlist['${ruleId}']['${key}'] must be '<category>: <reason>'`);
|
|
108
|
+
} else if (!rule.allowlistCategories.includes(match[1])) {
|
|
109
|
+
problems.push(`standard.allowlist['${ruleId}']['${key}']: unknown category '${match[1]}' (${rule.allowlistCategories.join(', ')})`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (valueArg.baselines !== undefined) {
|
|
116
|
+
if (!isPlainObject(valueArg.baselines)) {
|
|
117
|
+
problems.push('standard.baselines must be an object of rule id → file → count');
|
|
118
|
+
} else {
|
|
119
|
+
for (const [ruleId, counts] of Object.entries(valueArg.baselines)) {
|
|
120
|
+
const rule = rulesById.get(ruleId);
|
|
121
|
+
if (!rule) {
|
|
122
|
+
problems.push(`standard.baselines: unknown rule '${ruleId}'`);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if ((profile === 'base' || profile === 'dees') && severityIn(rule, profile) !== 'error') {
|
|
126
|
+
problems.push(`standard.baselines: '${ruleId}' is not an error rule of the '${profile}' profile; baselines hold error rules only`);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (!isPlainObject(counts)) {
|
|
130
|
+
problems.push(`standard.baselines['${ruleId}'] must be an object of file → count`);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
for (const [file, count] of Object.entries(counts)) {
|
|
134
|
+
if (typeof count !== 'number' || !Number.isInteger(count) || count < 1) {
|
|
135
|
+
problems.push(`standard.baselines['${ruleId}']['${file}'] must be an integer of at least 1`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (problems.length > 0) {
|
|
142
|
+
throw new StandardConfigError(problems);
|
|
143
|
+
}
|
|
144
|
+
return { config: valueArg as unknown as IStandardConfig, profile: profile as TStandardProfile };
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/** Reads and validates `.smartconfig.json` → `@design.estate/wcctools` → `standard` of a repository. */
|
|
148
|
+
export const openStandardConfig = (rootDirArg: string): IOpenedStandardConfig => {
|
|
149
|
+
let smartconfig: plugins.smartconfig.Smartconfig;
|
|
150
|
+
try {
|
|
151
|
+
smartconfig = new plugins.smartconfig.Smartconfig(rootDirArg);
|
|
152
|
+
} catch (error) {
|
|
153
|
+
throw new StandardConfigError([`.smartconfig.json cannot be read: ${error instanceof Error ? error.message : String(error)}`]);
|
|
154
|
+
}
|
|
155
|
+
if (!smartconfig.smartconfigJsonExists) {
|
|
156
|
+
throw new StandardConfigError([`${rootDirArg} has no .smartconfig.json`]);
|
|
157
|
+
}
|
|
158
|
+
const wcctoolsConfig = smartconfig.dataFor<plugins.interfaces.IWccToolsConfig>(wcctoolsConfigKey, {});
|
|
159
|
+
return { loaded: validateStandardConfig(wcctoolsConfig.standard), writer: new SmartconfigStandardConfigWriter(smartconfig) };
|
|
160
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** The `standard` configuration is missing or invalid; the CLI exits with code 2. */
|
|
2
|
+
export class StandardConfigError extends Error {
|
|
3
|
+
public readonly problems: string[];
|
|
4
|
+
|
|
5
|
+
constructor(problemsArg: string[]) {
|
|
6
|
+
super(`Invalid wcctools standard configuration in .smartconfig.json:\n${problemsArg.map((problemArg) => ` - ${problemArg}`).join('\n')}`);
|
|
7
|
+
this.name = 'StandardConfigError';
|
|
8
|
+
this.problems = problemsArg;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** A precondition or argument of `check`/`fix` is not met; the CLI exits with code 2. */
|
|
13
|
+
export class StandardUsageError extends Error {
|
|
14
|
+
constructor(messageArg: string) {
|
|
15
|
+
super(messageArg);
|
|
16
|
+
this.name = 'StandardUsageError';
|
|
17
|
+
}
|
|
18
|
+
}
|