@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,234 @@
|
|
|
1
|
+
import * as plugins from '../plugins.js';
|
|
2
|
+
import { rekeyStandardConfig } from './baselines.js';
|
|
3
|
+
import { checkStandard, detectFindings, rulesOfProfile } from './checker.js';
|
|
4
|
+
import { GitRepo } from './classes.gitrepo.js';
|
|
5
|
+
import { loadStandardProject, standardSideFiles } from './classes.standardproject.js';
|
|
6
|
+
import { FixBatch, StandardTree } from './classes.standardtree.js';
|
|
7
|
+
import type { ILoadedStandardConfig, IStandardConfigWriter } from './config.js';
|
|
8
|
+
import { StandardUsageError } from './errors.js';
|
|
9
|
+
import { dirOf, isRepoPath, isTypeScriptSource } from './helpers.paths.js';
|
|
10
|
+
import { fixOrder, standardRules } from './rules/index.js';
|
|
11
|
+
|
|
12
|
+
export interface IStandardFixOptions {
|
|
13
|
+
rootDir: string;
|
|
14
|
+
loaded: ILoadedStandardConfig;
|
|
15
|
+
/** Rules to fix; opt-in rules run only when named here. Default: every `auto` rule of the profile. */
|
|
16
|
+
rules?: readonly string[];
|
|
17
|
+
dryRun?: boolean;
|
|
18
|
+
/** Persists the configuration when moves rename files the allowlist or baselines name; without it such moves are refused. */
|
|
19
|
+
configWriter?: IStandardConfigWriter;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** How often one rule is re-run on the edited tree to apply edits that overlapped in an earlier run. */
|
|
23
|
+
const maxRunsPerRule = 5;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* `wcctools fix`: applies the fixes of the selected rules to an in-memory copy of the repository,
|
|
27
|
+
* one rule after another on the re-parsed result, validates that every path of the resulting plan
|
|
28
|
+
* stays inside the repository root, then writes the configuration, moves files with `git mv`, writes
|
|
29
|
+
* the edited files and checks again. Refuses to run on a dirty work tree and never commits, so a
|
|
30
|
+
* failure part-way through the writes is undone with `git reset --hard` and `git clean -fd`.
|
|
31
|
+
*/
|
|
32
|
+
export class StandardFixer {
|
|
33
|
+
private readonly options: IStandardFixOptions;
|
|
34
|
+
|
|
35
|
+
constructor(optionsArg: IStandardFixOptions) {
|
|
36
|
+
this.options = optionsArg;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private selectRules(): string[] {
|
|
40
|
+
const { profile } = this.options.loaded;
|
|
41
|
+
const inProfile = new Set(rulesOfProfile(profile).map((ruleArg) => ruleArg.id));
|
|
42
|
+
if (!this.options.rules || this.options.rules.length === 0) {
|
|
43
|
+
return fixOrder.filter((idArg) => inProfile.has(idArg) && standardRules.find((ruleArg) => ruleArg.id === idArg)!.fixMode === 'auto');
|
|
44
|
+
}
|
|
45
|
+
for (const id of this.options.rules) {
|
|
46
|
+
const rule = standardRules.find((ruleArg) => ruleArg.id === id);
|
|
47
|
+
if (!rule) {
|
|
48
|
+
throw new StandardUsageError(`Unknown rule '${id}'`);
|
|
49
|
+
}
|
|
50
|
+
if (!inProfile.has(id)) {
|
|
51
|
+
throw new StandardUsageError(`Rule '${id}' is not part of the '${profile}' profile`);
|
|
52
|
+
}
|
|
53
|
+
if (rule.fixMode === 'none') {
|
|
54
|
+
throw new StandardUsageError(`Rule '${id}' has no fix`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return fixOrder.filter((idArg) => this.options.rules!.includes(idArg));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
public async run(): Promise<plugins.interfaces.IStandardFixResult> {
|
|
61
|
+
const { rootDir, loaded } = this.options;
|
|
62
|
+
const selected = this.selectRules();
|
|
63
|
+
const git = new GitRepo(rootDir);
|
|
64
|
+
const topLevel = await git.topLevel();
|
|
65
|
+
if (!topLevel || plugins.fsSync.realpathSync(topLevel) !== plugins.fsSync.realpathSync(rootDir)) {
|
|
66
|
+
throw new StandardUsageError(`wcctools fix runs at the root of a git repository; ${rootDir} is not one`);
|
|
67
|
+
}
|
|
68
|
+
const status = await git.statusLines();
|
|
69
|
+
if (status.length > 0) {
|
|
70
|
+
throw new StandardUsageError(`wcctools fix needs a clean work tree; git status reports ${status.length} change(s). Commit or stash them first.`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const trackedFiles = await git.trackedFiles();
|
|
74
|
+
const files = new Map<string, string>();
|
|
75
|
+
for (const file of trackedFiles) {
|
|
76
|
+
if (isTypeScriptSource(file) || standardSideFiles.includes(file)) {
|
|
77
|
+
files.set(file, await plugins.fs.readFile(plugins.path.join(rootDir, file), 'utf8'));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const tree = new StandardTree(files, trackedFiles, (fileArg) => plugins.fsSync.existsSync(plugins.path.join(rootDir, fileArg)));
|
|
81
|
+
|
|
82
|
+
for (const ruleId of selected) {
|
|
83
|
+
const rule = standardRules.find((ruleArg) => ruleArg.id === ruleId)!;
|
|
84
|
+
for (let run = 0; run < maxRunsPerRule; run++) {
|
|
85
|
+
const fixable = detectFindings(tree.project(), loaded, [rule]).filter((detectedArg) => !detectedArg.allowlisted && detectedArg.finding.fix);
|
|
86
|
+
if (fixable.length === 0) {
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
const batch = new FixBatch();
|
|
90
|
+
fixable.forEach((detectedArg) => detectedArg.finding.fix!(batch));
|
|
91
|
+
if (tree.apply(rule.id, batch) === 0) {
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const plan = tree.plan();
|
|
98
|
+
await this.assertInsideRoot(plan);
|
|
99
|
+
const rekeyed = rekeyStandardConfig(loaded.config, plan.moves);
|
|
100
|
+
if (rekeyed.changed.length > 0 && !this.options.configWriter) {
|
|
101
|
+
throw new StandardUsageError(
|
|
102
|
+
`wcctools fix would move files that the standard configuration names (${rekeyed.changed.join(', ')}) but has no configuration writer`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
const loadedAfter: ILoadedStandardConfig = { ...loaded, config: rekeyed.config };
|
|
106
|
+
|
|
107
|
+
if (this.options.dryRun) {
|
|
108
|
+
return { dryRun: true, plan, remaining: checkStandard(tree.project(), loadedAfter) };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Every path is validated above. The configuration goes first: a refused write (the file changed
|
|
112
|
+
// since it was read) leaves the tree untouched. A failure after it leaves a partial result that the
|
|
113
|
+
// clean work tree fix started from restores (`git reset --hard`, `git clean -fd`).
|
|
114
|
+
if (rekeyed.changed.length > 0) {
|
|
115
|
+
await this.options.configWriter!.writeStandardConfig(rekeyed.config);
|
|
116
|
+
}
|
|
117
|
+
await this.commitMoves(git, plan.moves);
|
|
118
|
+
for (const file of [...plan.created, ...plan.edited.map((editedArg) => editedArg.file)]) {
|
|
119
|
+
await this.ensureDir(dirOf(file));
|
|
120
|
+
await plugins.fs.writeFile(plugins.path.join(rootDir, file), tree.files.get(file)!, 'utf8');
|
|
121
|
+
}
|
|
122
|
+
await this.removeEmptyDirs(plan.moves.map((moveArg) => dirOf(moveArg.from)));
|
|
123
|
+
return { dryRun: false, plan, remaining: checkStandard(await loadStandardProject(rootDir), loadedAfter) };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Refuses a plan with a path that leaves the repository root, lexically (an absolute path or a `..`
|
|
128
|
+
* segment) or physically (through a symbolic link that resolves outside the root or dangles).
|
|
129
|
+
* Runs before anything is written, so a refused plan leaves the repository and its surroundings as
|
|
130
|
+
* they were.
|
|
131
|
+
*/
|
|
132
|
+
private async assertInsideRoot(planArg: plugins.interfaces.IStandardFixPlan): Promise<void> {
|
|
133
|
+
const realRoot = await plugins.fs.realpath(this.options.rootDir);
|
|
134
|
+
const paths = [
|
|
135
|
+
...planArg.moves.flatMap((moveArg) => [moveArg.from, moveArg.to]),
|
|
136
|
+
...planArg.created,
|
|
137
|
+
...planArg.edited.map((editedArg) => editedArg.file),
|
|
138
|
+
];
|
|
139
|
+
const outside: string[] = [];
|
|
140
|
+
for (const file of new Set(paths)) {
|
|
141
|
+
if (!isRepoPath(file) || !(await this.resolvesInside(realRoot, file))) {
|
|
142
|
+
outside.push(file);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (outside.length > 0) {
|
|
146
|
+
throw new StandardUsageError(`wcctools fix refuses to write outside the repository root: ${outside.join(', ')}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** True when the file, or its nearest existing ancestor, resolves to a path inside the real root. */
|
|
151
|
+
private async resolvesInside(realRootArg: string, fileArg: string): Promise<boolean> {
|
|
152
|
+
let current = plugins.path.join(realRootArg, fileArg);
|
|
153
|
+
while (true) {
|
|
154
|
+
try {
|
|
155
|
+
const real = await plugins.fs.realpath(current);
|
|
156
|
+
return real === realRootArg || real.startsWith(`${realRootArg}${plugins.path.sep}`);
|
|
157
|
+
} catch (error) {
|
|
158
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
159
|
+
if (code !== 'ENOENT' && code !== 'ENOTDIR') {
|
|
160
|
+
throw error;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (await this.isSymbolicLink(current)) {
|
|
164
|
+
// A dangling link: writing through it would create its target, wherever that is.
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
const parent = plugins.path.dirname(current);
|
|
168
|
+
if (parent === current) {
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
current = parent;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private async isSymbolicLink(pathArg: string): Promise<boolean> {
|
|
176
|
+
try {
|
|
177
|
+
return (await plugins.fs.lstat(pathArg)).isSymbolicLink();
|
|
178
|
+
} catch (error) {
|
|
179
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
180
|
+
if (code === 'ENOENT' || code === 'ENOTDIR') {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
throw error;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** `git mv` in an order where no move overwrites a file that has not moved away yet. */
|
|
188
|
+
private async commitMoves(gitArg: GitRepo, movesArg: plugins.interfaces.IStandardFileMove[]): Promise<void> {
|
|
189
|
+
const pending = [...movesArg];
|
|
190
|
+
while (pending.length > 0) {
|
|
191
|
+
const index = pending.findIndex((moveArg) => !plugins.fsSync.existsSync(plugins.path.join(this.options.rootDir, moveArg.to)));
|
|
192
|
+
if (index === -1) {
|
|
193
|
+
throw new Error(`Cannot order the moves ${pending.map((moveArg) => `${moveArg.from} → ${moveArg.to}`).join(', ')}`);
|
|
194
|
+
}
|
|
195
|
+
const [move] = pending.splice(index, 1);
|
|
196
|
+
await this.ensureDir(dirOf(move.to));
|
|
197
|
+
await gitArg.move(move.from, move.to);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Creates a directory one path component at a time. */
|
|
202
|
+
private async ensureDir(dirArg: string): Promise<void> {
|
|
203
|
+
let current = this.options.rootDir;
|
|
204
|
+
for (const segment of dirArg.split('/').filter((segmentArg) => segmentArg && segmentArg !== '.')) {
|
|
205
|
+
current = plugins.path.join(current, segment);
|
|
206
|
+
try {
|
|
207
|
+
await plugins.fs.mkdir(current);
|
|
208
|
+
} catch (error) {
|
|
209
|
+
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') {
|
|
210
|
+
throw error;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Removes directories that moves left empty, walking up towards the repository root. */
|
|
217
|
+
private async removeEmptyDirs(dirsArg: string[]): Promise<void> {
|
|
218
|
+
for (const start of [...new Set(dirsArg)].sort((a, b) => b.length - a.length)) {
|
|
219
|
+
let dir = start;
|
|
220
|
+
while (dir && dir !== '.') {
|
|
221
|
+
try {
|
|
222
|
+
await plugins.fs.rmdir(plugins.path.join(this.options.rootDir, dir));
|
|
223
|
+
} catch (error) {
|
|
224
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
225
|
+
if (code === 'ENOTEMPTY' || code === 'EEXIST' || code === 'ENOENT') {
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
throw error;
|
|
229
|
+
}
|
|
230
|
+
dir = dirOf(dir);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import * as plugins from '../plugins.js';
|
|
2
|
+
import * as ast from './helpers.ast.js';
|
|
3
|
+
import { isDemoFile, isTypeScriptSource, resolveSpecifier, toPosix } from './helpers.paths.js';
|
|
4
|
+
|
|
5
|
+
const ts = plugins.typescript;
|
|
6
|
+
|
|
7
|
+
/** A class decorated with `@customElement(…)`. */
|
|
8
|
+
export interface IStandardElement {
|
|
9
|
+
file: string;
|
|
10
|
+
sourceFile: ast.TSourceFile;
|
|
11
|
+
classNode: ast.TClassDeclaration;
|
|
12
|
+
className: string | null;
|
|
13
|
+
decoratorCall: ast.TCallExpression;
|
|
14
|
+
/** The literal tag, or null when the decorator argument is not a string literal. */
|
|
15
|
+
tag: string | null;
|
|
16
|
+
exported: boolean;
|
|
17
|
+
inDemoFile: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A class member decorated with `@property(…)` or `@state(…)`. */
|
|
21
|
+
export interface IStandardProperty {
|
|
22
|
+
file: string;
|
|
23
|
+
sourceFile: ast.TSourceFile;
|
|
24
|
+
classNode: ast.TClassDeclaration;
|
|
25
|
+
member: ast.TClassElement;
|
|
26
|
+
name: string;
|
|
27
|
+
decorator: 'property' | 'state';
|
|
28
|
+
call: ast.TCallExpression;
|
|
29
|
+
/** The options object literal; null when the decorator has no argument or a non-literal one. */
|
|
30
|
+
options: ast.TObjectLiteralExpression | null;
|
|
31
|
+
/** True when the decorator has an argument that is not an object literal. */
|
|
32
|
+
dynamicOptions: boolean;
|
|
33
|
+
isPublic: boolean;
|
|
34
|
+
inDemoFile: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** An `export … from '<relative>'` declaration. */
|
|
38
|
+
export interface IStandardReexport {
|
|
39
|
+
file: string;
|
|
40
|
+
statement: plugins.typescript.ExportDeclaration;
|
|
41
|
+
specifier: string;
|
|
42
|
+
/** The resolved repository path, or null when it does not resolve to a known file. */
|
|
43
|
+
target: string | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type TParseCache = Map<string, { text: string; sourceFile: ast.TSourceFile }>;
|
|
47
|
+
|
|
48
|
+
/** The root barrel every reachability check starts from. */
|
|
49
|
+
export const rootBarrel = 'ts_web/index.ts';
|
|
50
|
+
export const elementsBarrel = 'ts_web/elements/index.ts';
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The syntax-only model of a catalog the rules read: its files, elements, reactive properties and
|
|
54
|
+
* barrel graph. Built from a map of repository paths to file texts, so the same model serves a
|
|
55
|
+
* repository on disk and the fixer's in-memory tree.
|
|
56
|
+
*/
|
|
57
|
+
export class StandardProject {
|
|
58
|
+
public readonly files: ReadonlyMap<string, string>;
|
|
59
|
+
private readonly existsOnDisk: (fileArg: string) => boolean;
|
|
60
|
+
private readonly parseCache: TParseCache;
|
|
61
|
+
private elementsCache: IStandardElement[] | null = null;
|
|
62
|
+
private propertiesCache: IStandardProperty[] | null = null;
|
|
63
|
+
private reexportsCache = new Map<string, IStandardReexport[]>();
|
|
64
|
+
|
|
65
|
+
constructor(filesArg: ReadonlyMap<string, string>, existsOnDiskArg: (fileArg: string) => boolean, parseCacheArg: TParseCache = new Map()) {
|
|
66
|
+
this.files = filesArg;
|
|
67
|
+
this.existsOnDisk = existsOnDiskArg;
|
|
68
|
+
this.parseCache = parseCacheArg;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** True when the file is part of the model or exists in the repository. */
|
|
72
|
+
public exists(fileArg: string): boolean {
|
|
73
|
+
return this.files.has(fileArg) || this.existsOnDisk(fileArg);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The `ts_web/**\/*.ts` sources, sorted. */
|
|
77
|
+
public get webFiles(): string[] {
|
|
78
|
+
return [...this.files.keys()].filter((fileArg) => fileArg.startsWith('ts_web/') && fileArg.endsWith('.ts') && isTypeScriptSource(fileArg)).sort();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
public sourceFile(fileArg: string): ast.TSourceFile {
|
|
82
|
+
const text = this.files.get(fileArg);
|
|
83
|
+
if (text === undefined) {
|
|
84
|
+
throw new Error(`Not a known file: ${fileArg}`);
|
|
85
|
+
}
|
|
86
|
+
const cached = this.parseCache.get(fileArg);
|
|
87
|
+
if (cached && cached.text === text) {
|
|
88
|
+
return cached.sourceFile;
|
|
89
|
+
}
|
|
90
|
+
const sourceFile = ast.parseSource(fileArg, text);
|
|
91
|
+
this.parseCache.set(fileArg, { text, sourceFile });
|
|
92
|
+
return sourceFile;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** `compilerOptions.experimentalDecorators` of tsconfig.json (its `extends` chain is not followed). */
|
|
96
|
+
public get experimentalDecorators(): boolean {
|
|
97
|
+
const text = this.files.get('tsconfig.json');
|
|
98
|
+
if (!text) {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
const parsed = ts.parseConfigFileTextToJson('tsconfig.json', text);
|
|
102
|
+
return parsed.config?.compilerOptions?.experimentalDecorators === true;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
public get elements(): IStandardElement[] {
|
|
106
|
+
if (!this.elementsCache) {
|
|
107
|
+
this.elementsCache = [];
|
|
108
|
+
for (const file of this.webFiles) {
|
|
109
|
+
const sourceFile = this.sourceFile(file);
|
|
110
|
+
const visit = (nodeArg: ast.TNode): void => {
|
|
111
|
+
if (ts.isClassDeclaration(nodeArg)) {
|
|
112
|
+
const decorator = ast.findDecoratorCall(nodeArg, ['customElement']);
|
|
113
|
+
if (decorator) {
|
|
114
|
+
const tagArgument = decorator.call.arguments[0];
|
|
115
|
+
this.elementsCache!.push({
|
|
116
|
+
file,
|
|
117
|
+
sourceFile,
|
|
118
|
+
classNode: nodeArg,
|
|
119
|
+
className: nodeArg.name?.text ?? null,
|
|
120
|
+
decoratorCall: decorator.call,
|
|
121
|
+
tag: decorator.call.arguments.length === 1 && ast.isStringLiteralLike(tagArgument) ? tagArgument.text : null,
|
|
122
|
+
exported: ast.hasModifier(nodeArg, ts.SyntaxKind.ExportKeyword),
|
|
123
|
+
inDemoFile: isDemoFile(file),
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
ts.forEachChild(nodeArg, visit);
|
|
128
|
+
};
|
|
129
|
+
visit(sourceFile);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return this.elementsCache;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
public get properties(): IStandardProperty[] {
|
|
136
|
+
if (!this.propertiesCache) {
|
|
137
|
+
this.propertiesCache = [];
|
|
138
|
+
for (const file of this.webFiles) {
|
|
139
|
+
const sourceFile = this.sourceFile(file);
|
|
140
|
+
const visit = (nodeArg: ast.TNode): void => {
|
|
141
|
+
if (ts.isClassDeclaration(nodeArg) || ts.isClassExpression(nodeArg)) {
|
|
142
|
+
for (const member of nodeArg.members) {
|
|
143
|
+
const decorator = ast.findDecoratorCall(member, ['property', 'state']);
|
|
144
|
+
const name = ast.memberNameText(member);
|
|
145
|
+
if (!decorator || name === null) {
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
const firstArgument = decorator.call.arguments[0];
|
|
149
|
+
const isPrivateName = !!member.name && ts.isPrivateIdentifier(member.name);
|
|
150
|
+
this.propertiesCache!.push({
|
|
151
|
+
file,
|
|
152
|
+
sourceFile,
|
|
153
|
+
classNode: nodeArg as ast.TClassDeclaration,
|
|
154
|
+
member,
|
|
155
|
+
name: isPrivateName && !name.startsWith('#') ? `#${name}` : name,
|
|
156
|
+
decorator: decorator.name as 'property' | 'state',
|
|
157
|
+
call: decorator.call,
|
|
158
|
+
options: firstArgument && ts.isObjectLiteralExpression(firstArgument) ? firstArgument : null,
|
|
159
|
+
dynamicOptions: !!firstArgument && !ts.isObjectLiteralExpression(firstArgument),
|
|
160
|
+
isPublic: !isPrivateName
|
|
161
|
+
&& !ast.hasModifier(member, ts.SyntaxKind.PrivateKeyword)
|
|
162
|
+
&& !ast.hasModifier(member, ts.SyntaxKind.ProtectedKeyword),
|
|
163
|
+
inDemoFile: isDemoFile(file),
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
ts.forEachChild(nodeArg, visit);
|
|
168
|
+
};
|
|
169
|
+
visit(sourceFile);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return this.propertiesCache;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The `export … from` declarations of a file with relative specifiers. */
|
|
176
|
+
public reexports(fileArg: string): IStandardReexport[] {
|
|
177
|
+
let reexports = this.reexportsCache.get(fileArg);
|
|
178
|
+
if (!reexports) {
|
|
179
|
+
reexports = [];
|
|
180
|
+
if (this.files.has(fileArg)) {
|
|
181
|
+
for (const statement of this.sourceFile(fileArg).statements) {
|
|
182
|
+
if (ts.isExportDeclaration(statement) && statement.moduleSpecifier && ast.isStringLiteralLike(statement.moduleSpecifier)) {
|
|
183
|
+
const specifier = statement.moduleSpecifier.text;
|
|
184
|
+
if (!specifier.startsWith('.')) {
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
const resolved = resolveSpecifier(fileArg, specifier, (candidateArg) => this.files.has(candidateArg));
|
|
188
|
+
reexports.push({ file: fileArg, statement, specifier, target: resolved?.target ?? null });
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
this.reexportsCache.set(fileArg, reexports);
|
|
193
|
+
}
|
|
194
|
+
return reexports;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Every file reached from a barrel by following `export … from` declarations. */
|
|
198
|
+
public reachableFrom(fileArg: string): Set<string> {
|
|
199
|
+
const reached = new Set<string>();
|
|
200
|
+
const queue = this.files.has(fileArg) ? [fileArg] : [];
|
|
201
|
+
while (queue.length > 0) {
|
|
202
|
+
const file = queue.shift()!;
|
|
203
|
+
if (reached.has(file)) {
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
reached.add(file);
|
|
207
|
+
for (const reexport of this.reexports(file)) {
|
|
208
|
+
if (reexport.target && !reached.has(reexport.target)) {
|
|
209
|
+
queue.push(reexport.target);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return reached;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
public lineAndColumn(fileArg: string, positionArg: number): { line: number; column: number } {
|
|
217
|
+
return ast.lineAndColumn(this.sourceFile(fileArg), positionArg);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** The files besides `ts_web/**` a check reads. */
|
|
222
|
+
export const standardSideFiles = ['html/index.ts', 'html/index.html', 'tsconfig.json', 'readme.md'];
|
|
223
|
+
|
|
224
|
+
/** Reads the files the standard covers from a repository on disk. */
|
|
225
|
+
export const loadStandardProject = async (rootDirArg: string): Promise<StandardProject> => {
|
|
226
|
+
const files = new Map<string, string>();
|
|
227
|
+
const webDir = plugins.path.join(rootDirArg, 'ts_web');
|
|
228
|
+
if (plugins.fsSync.existsSync(webDir)) {
|
|
229
|
+
const entries = await plugins.fs.readdir(webDir, { recursive: true, withFileTypes: true });
|
|
230
|
+
for (const entry of entries) {
|
|
231
|
+
if (!entry.isFile()) {
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
const relative = toPosix(plugins.path.relative(rootDirArg, plugins.path.join(entry.parentPath, entry.name)));
|
|
235
|
+
if (relative.split('/').some((segmentArg) => segmentArg === 'node_modules')) {
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
if (relative.endsWith('.ts') && isTypeScriptSource(relative)) {
|
|
239
|
+
files.set(relative, await plugins.fs.readFile(plugins.path.join(rootDirArg, relative), 'utf8'));
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
for (const sideFile of standardSideFiles) {
|
|
244
|
+
const absolute = plugins.path.join(rootDirArg, sideFile);
|
|
245
|
+
if (plugins.fsSync.existsSync(absolute)) {
|
|
246
|
+
files.set(sideFile, await plugins.fs.readFile(absolute, 'utf8'));
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return new StandardProject(files, (fileArg) => plugins.fsSync.existsSync(plugins.path.join(rootDirArg, fileArg)));
|
|
250
|
+
};
|