workspace-conformance 0.0.0 → 1.0.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/LICENSE +21 -0
- package/README.md +228 -0
- package/dist/cli.js +1600 -0
- package/dist/index.cjs +1613 -0
- package/dist/index.d.cts +561 -0
- package/dist/index.d.ts +561 -0
- package/dist/index.js +1580 -0
- package/package.json +94 -2
package/dist/index.js
ADDED
|
@@ -0,0 +1,1580 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { dirname, join, posix, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { escapePath, glob, isDynamicPattern } from "tinyglobby";
|
|
4
|
+
import { ModuleKind, ModuleResolutionKind, Node, Project, ScriptTarget, ts } from "ts-morph";
|
|
5
|
+
import { createJitiLoader, validateStandard } from "cosmiconfig-extends";
|
|
6
|
+
import { readFile, realpath } from "node:fs/promises";
|
|
7
|
+
import { parse } from "yaml";
|
|
8
|
+
import { execFile } from "node:child_process";
|
|
9
|
+
import { defineSection, layoutSection, loadSection } from "@exadev/config";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
//#region src/files.ts
|
|
12
|
+
/**
|
|
13
|
+
* Directories no check descends into: installed dependencies are not the workspace's own files.
|
|
14
|
+
*/
|
|
15
|
+
const INSTALLED_DEPENDENCIES_GLOB = "**/node_modules/**";
|
|
16
|
+
/**
|
|
17
|
+
* The name of the entry git puts at the top of a checkout: a directory in a clone, a file in a linked worktree.
|
|
18
|
+
*/
|
|
19
|
+
const GIT_ENTRY = ".git";
|
|
20
|
+
/**
|
|
21
|
+
* Directories no check descends into besides installed dependencies: git's own data, and every other checkout below `cwd` (a linked worktree or a nested clone, found by its own `.git` entry). Such a checkout is another copy of a repository, and its files would be judged as if they were part of this workspace.
|
|
22
|
+
*/
|
|
23
|
+
async function ignoredDirectories(cwd) {
|
|
24
|
+
const nested = (await glob([`**/${GIT_ENTRY}`], {
|
|
25
|
+
cwd,
|
|
26
|
+
dot: true,
|
|
27
|
+
onlyFiles: false,
|
|
28
|
+
expandDirectories: false,
|
|
29
|
+
ignore: [INSTALLED_DEPENDENCIES_GLOB, `**/${GIT_ENTRY}/*/**`]
|
|
30
|
+
})).map((entry) => entry.replace(/\/$/u, "")).filter((entry) => entry !== GIT_ENTRY);
|
|
31
|
+
return [`**/${GIT_ENTRY}/**`, ...nested.map((entry) => `${escapePath(entry.slice(0, -4))}**`)];
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The negative patterns that leave the paths matching `exclude` out of a search; none when there is no `exclude`.
|
|
35
|
+
*/
|
|
36
|
+
function excludedFrom(exclude) {
|
|
37
|
+
return exclude === void 0 ? [] : exclude.map((pattern) => `!${pattern}`);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The files under `cwd` matching any of `patterns`, relative to `cwd` with `/` separators and sorted, so results do not depend on directory order. Dotfiles match; installed dependencies, git's own data and other checkouts below `cwd` never do.
|
|
41
|
+
*/
|
|
42
|
+
async function findFiles(cwd, patterns) {
|
|
43
|
+
return (await glob([...patterns], {
|
|
44
|
+
cwd,
|
|
45
|
+
dot: true,
|
|
46
|
+
ignore: [INSTALLED_DEPENDENCIES_GLOB, ...await ignoredDirectories(cwd)],
|
|
47
|
+
onlyFiles: true
|
|
48
|
+
})).sort();
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The directories under `cwd` matching any of `patterns`, relative to `cwd` with `/` separators, without a trailing separator, and sorted.
|
|
52
|
+
*/
|
|
53
|
+
async function findDirectories(cwd, patterns) {
|
|
54
|
+
return (await glob([...patterns], {
|
|
55
|
+
cwd,
|
|
56
|
+
dot: true,
|
|
57
|
+
ignore: [INSTALLED_DEPENDENCIES_GLOB, ...await ignoredDirectories(cwd)],
|
|
58
|
+
onlyDirectories: true
|
|
59
|
+
})).map((directory) => directory.replace(/\/$/u, "")).sort();
|
|
60
|
+
}
|
|
61
|
+
//#endregion
|
|
62
|
+
//#region src/paths.ts
|
|
63
|
+
/**
|
|
64
|
+
* `path` with the platform separator replaced by `/`, the form paths take in violations and in options.
|
|
65
|
+
*/
|
|
66
|
+
function toPosix(path) {
|
|
67
|
+
return path.split(sep).join("/");
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* `absolute` relative to `cwd`, with `/` separators.
|
|
71
|
+
*/
|
|
72
|
+
function relativePosix(cwd, absolute) {
|
|
73
|
+
return toPosix(relative(cwd, absolute));
|
|
74
|
+
}
|
|
75
|
+
//#endregion
|
|
76
|
+
//#region src/errors.ts
|
|
77
|
+
/**
|
|
78
|
+
* A problem with how the tool was used or configured rather than a finding about the workspace: an unknown or disabled check, a missing or invalid section, a layout that cannot be resolved, a repository that is not a git checkout. The command line reports it and exits 2.
|
|
79
|
+
*/
|
|
80
|
+
var ConformanceError = class extends Error {
|
|
81
|
+
constructor(message, options) {
|
|
82
|
+
super(message, options);
|
|
83
|
+
this.name = "ConformanceError";
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
//#endregion
|
|
87
|
+
//#region src/template.ts
|
|
88
|
+
const PLACEHOLDER = /\{([a-z]+)\}/gu;
|
|
89
|
+
/**
|
|
90
|
+
* `template` with each `{name}` replaced by the value of that name. Throws `ConformanceError` for a placeholder with no value, so a typo in an option cannot produce a path that silently matches nothing.
|
|
91
|
+
*/
|
|
92
|
+
function expandTemplate(template, values) {
|
|
93
|
+
return template.replace(PLACEHOLDER, (placeholder, name) => {
|
|
94
|
+
const value = values[name];
|
|
95
|
+
if (value === void 0) throw new ConformanceError(`the placeholder ${placeholder} in '${template}' is not available here; use ${Object.keys(values).map((key) => `{${key}}`).join(", ")}`);
|
|
96
|
+
return value;
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
//#endregion
|
|
100
|
+
//#region src/ts-project.ts
|
|
101
|
+
/**
|
|
102
|
+
* The tsconfig used for compiler options and module resolution when the options name none.
|
|
103
|
+
*/
|
|
104
|
+
const DEFAULT_TS_CONFIG = "tsconfig.json";
|
|
105
|
+
/**
|
|
106
|
+
* A ts-morph project for the files matching `patterns`. Compiler options come from the tsconfig; its file list is not used, so only these files and what they import are loaded. Throws `ConformanceError` when the tsconfig does not exist or no file matches, naming the options and not their values.
|
|
107
|
+
*/
|
|
108
|
+
async function createProject(cwd, tsConfig, patterns, names) {
|
|
109
|
+
const tsConfigFilePath = resolve(cwd, tsConfig ?? "tsconfig.json");
|
|
110
|
+
if (!existsSync(tsConfigFilePath)) throw new ConformanceError(tsConfig === void 0 ? `checks.${names.check}: the working directory has no ${DEFAULT_TS_CONFIG}; name one with the 'tsConfig' option` : `checks.${names.check}.tsConfig: the file does not exist`);
|
|
111
|
+
const files = await findFiles(cwd, patterns);
|
|
112
|
+
if (files.length === 0) throw new ConformanceError(`checks.${names.check}.${names.files}: no file matches`);
|
|
113
|
+
const project = new Project({
|
|
114
|
+
tsConfigFilePath,
|
|
115
|
+
skipAddingFilesFromTsConfig: true
|
|
116
|
+
});
|
|
117
|
+
project.addSourceFilesAtPaths(files.map((file) => resolve(cwd, file)));
|
|
118
|
+
return {
|
|
119
|
+
project,
|
|
120
|
+
files
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
//#endregion
|
|
124
|
+
//#region src/checks/exported-types.ts
|
|
125
|
+
function isExportedType(declaration) {
|
|
126
|
+
return Node.isInterfaceDeclaration(declaration) || Node.isTypeAliasDeclaration(declaration);
|
|
127
|
+
}
|
|
128
|
+
function collectFromNamespace(namespace, prefix, found) {
|
|
129
|
+
for (const declaration of [...namespace.getInterfaces(), ...namespace.getTypeAliases()]) if (declaration.isExported()) found.set(`${prefix}${declaration.getName()}`, declaration);
|
|
130
|
+
for (const nested of namespace.getModules()) if (nested.isExported()) collectFromNamespace(nested, `${prefix}${nested.getName()}.`, found);
|
|
131
|
+
}
|
|
132
|
+
function collectFromFile(file, prefix, found, entered) {
|
|
133
|
+
for (const [name, declarations] of file.getExportedDeclarations()) {
|
|
134
|
+
const declaration = declarations.find(isExportedType);
|
|
135
|
+
if (declaration !== void 0) found.set(`${prefix}${name}`, declaration);
|
|
136
|
+
for (const member of declarations) if (Node.isModuleDeclaration(member)) collectFromNamespace(member, `${prefix}${name}.`, found);
|
|
137
|
+
else if (Node.isSourceFile(member) && !entered.has(member)) collectFromFile(member, `${prefix}${name}.`, found, /* @__PURE__ */ new Set([...entered, member]));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* The interfaces and type aliases `file` exports, by name, wherever they are declared: a re-export counts. Those inside an exported namespace, or in a module re-exported as a namespace (`export * as ns from './x'`), are included under their qualified name (`Namespace.Type`). Values, classes and enums are not types in this sense and are left out.
|
|
142
|
+
*/
|
|
143
|
+
function exportedTypes(file) {
|
|
144
|
+
const found = /* @__PURE__ */ new Map();
|
|
145
|
+
collectFromFile(file, "", found, /* @__PURE__ */ new Set([file]));
|
|
146
|
+
return found;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Where the declaration's name is written, as a file relative to `cwd`'s path and a line and column.
|
|
150
|
+
*/
|
|
151
|
+
function declarationLocation(declaration) {
|
|
152
|
+
const { line, column } = declaration.getSourceFile().getLineAndColumnAtPos(declaration.getNameNode().getStart());
|
|
153
|
+
return {
|
|
154
|
+
line,
|
|
155
|
+
column
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
//#endregion
|
|
159
|
+
//#region src/checks/aggregate-mappers.ts
|
|
160
|
+
/**
|
|
161
|
+
* Every exported interface and type alias of a contract file is an aggregate and has a mapper file where the template puts it, in each adapter directory when `adapters` is set.
|
|
162
|
+
*
|
|
163
|
+
* It looks for the file, not for what is in it, and it counts a type as exported when the contract re-exports it.
|
|
164
|
+
*/
|
|
165
|
+
const aggregateMappers = async ({ cwd, options }) => {
|
|
166
|
+
const { project, files } = await createProject(cwd, options.tsConfig, options.contracts, {
|
|
167
|
+
check: "aggregate-mappers",
|
|
168
|
+
files: "contracts"
|
|
169
|
+
});
|
|
170
|
+
const excluded = new Set(options.exclude);
|
|
171
|
+
const violations = [];
|
|
172
|
+
for (const contract of files) {
|
|
173
|
+
const contractValues = {
|
|
174
|
+
dir: posix.dirname(contract),
|
|
175
|
+
file: contract
|
|
176
|
+
};
|
|
177
|
+
let adapters = [void 0];
|
|
178
|
+
if (options.adapters !== void 0) {
|
|
179
|
+
const pattern = posix.normalize(expandTemplate(options.adapters, contractValues));
|
|
180
|
+
const matched = await findDirectories(cwd, [pattern]);
|
|
181
|
+
if (matched.length === 0) violations.push({
|
|
182
|
+
code: "aggregate-mappers/no-adapters",
|
|
183
|
+
message: `no adapter directory matches ${pattern}, so no aggregate of ${contract} can have a mapper`,
|
|
184
|
+
file: contract
|
|
185
|
+
});
|
|
186
|
+
adapters = matched;
|
|
187
|
+
}
|
|
188
|
+
const source = project.getSourceFileOrThrow(resolve(cwd, contract));
|
|
189
|
+
for (const [name, declaration] of exportedTypes(source)) {
|
|
190
|
+
if (excluded.has(name)) continue;
|
|
191
|
+
for (const adapter of adapters) {
|
|
192
|
+
const expected = posix.normalize(expandTemplate(options.mapper, adapter === void 0 ? {
|
|
193
|
+
...contractValues,
|
|
194
|
+
name
|
|
195
|
+
} : {
|
|
196
|
+
...contractValues,
|
|
197
|
+
name,
|
|
198
|
+
adapter
|
|
199
|
+
}));
|
|
200
|
+
if (!existsSync(resolve(cwd, expected))) violations.push({
|
|
201
|
+
code: "aggregate-mappers/missing-mapper",
|
|
202
|
+
message: `${name} has no mapper at ${expected}`,
|
|
203
|
+
file: relativePosix(cwd, declaration.getSourceFile().getFilePath()),
|
|
204
|
+
location: declarationLocation(declaration)
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return violations.sort((a, b) => a.file.localeCompare(b.file) || (a.location?.line ?? 0) - (b.location?.line ?? 0) || a.message.localeCompare(b.message));
|
|
210
|
+
};
|
|
211
|
+
//#endregion
|
|
212
|
+
//#region src/checks/command-types.ts
|
|
213
|
+
/**
|
|
214
|
+
* The last names of the generics that turn a schema into its type, used when the options name none: `z.infer`, `z.input`, `z.output`, `z.TypeOf` and Valibot's `InferInput` and `InferOutput`.
|
|
215
|
+
*/
|
|
216
|
+
const DEFAULT_INFERENCES = [
|
|
217
|
+
"infer",
|
|
218
|
+
"input",
|
|
219
|
+
"output",
|
|
220
|
+
"TypeOf",
|
|
221
|
+
"InferInput",
|
|
222
|
+
"InferOutput"
|
|
223
|
+
];
|
|
224
|
+
/**
|
|
225
|
+
* The member Standard Schema requires of every schema object, which is what makes a value a schema whatever library made it.
|
|
226
|
+
*/
|
|
227
|
+
const STANDARD_SCHEMA_MEMBER = "~standard";
|
|
228
|
+
/**
|
|
229
|
+
* The names a type reference goes by: the last name as written, and the name it was declared with when that differs, as for `Infer` in `import type { infer as Infer } from 'zod'`, which is also `infer`. A library may itself re-export a generic under another name, so both count.
|
|
230
|
+
*/
|
|
231
|
+
function namesOf(typeName) {
|
|
232
|
+
const identifier = Node.isQualifiedName(typeName) ? typeName.getRight() : typeName;
|
|
233
|
+
const symbol = identifier.getSymbol();
|
|
234
|
+
const declared = (symbol?.getAliasedSymbol() ?? symbol)?.getName();
|
|
235
|
+
return declared === void 0 ? [identifier.getText()] : [.../* @__PURE__ */ new Set([identifier.getText(), declared])];
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* The local alias without type parameters that a type reference names, or `undefined` when it names anything else. Such an alias is a name for the type it is written as, so it is judged by that.
|
|
239
|
+
*/
|
|
240
|
+
function aliasNamed(typeName) {
|
|
241
|
+
const symbol = typeName.getSymbol();
|
|
242
|
+
const declaration = (symbol?.getAliasedSymbol() ?? symbol)?.getDeclarations().find(Node.isTypeAliasDeclaration);
|
|
243
|
+
return declaration?.getTypeParameters().length === 0 ? declaration : void 0;
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Why a command type is not derived from a schema, or `undefined` when it is. `followed` holds the aliases already followed from the exported type, so a chain of aliases is followed to its end and a loop of them is not followed twice.
|
|
247
|
+
*/
|
|
248
|
+
function problem(declaration, name, inferences, followed = /* @__PURE__ */ new Set()) {
|
|
249
|
+
if (Node.isInterfaceDeclaration(declaration)) return {
|
|
250
|
+
reason: "hand-written-interface",
|
|
251
|
+
message: `${name} is a hand-written interface; derive the command from its schema`
|
|
252
|
+
};
|
|
253
|
+
const written = declaration.getTypeNode();
|
|
254
|
+
if (Node.isTypeReference(written)) {
|
|
255
|
+
const inference = namesOf(written.getTypeName()).find((candidate) => inferences.includes(candidate));
|
|
256
|
+
if (inference !== void 0) {
|
|
257
|
+
const [argument] = written.getTypeArguments();
|
|
258
|
+
if (argument !== void 0 && Node.isTypeQuery(argument) && argument.getExprName().getType().getProperty(STANDARD_SCHEMA_MEMBER) !== void 0) return;
|
|
259
|
+
return {
|
|
260
|
+
reason: "not-a-schema",
|
|
261
|
+
message: `${name} applies '${inference}' to something that is not a schema: its type has no '${STANDARD_SCHEMA_MEMBER}' member`
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
const alias = aliasNamed(written.getTypeName());
|
|
265
|
+
if (alias !== void 0 && !followed.has(alias)) return problem(alias, name, inferences, /* @__PURE__ */ new Set([...followed, alias]));
|
|
266
|
+
}
|
|
267
|
+
return {
|
|
268
|
+
reason: "hand-written-alias",
|
|
269
|
+
message: `${name} is a type alias that does not apply ${inferences.map((inference) => `'${inference}'`).join(", ")} to a schema; derive the command from its schema`
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Every exported interface and type alias of a command file is derived from a schema: an alias of `infer<typeof schema>` (or another generic in `inferences`) where the type of `schema` is a Standard Schema. Interfaces and other aliases are hand-written, and so are aliases of something that is not a schema.
|
|
274
|
+
*
|
|
275
|
+
* The schema is recognised by type, so it may be imported from another file or renamed; it does not need to come from Zod. A union of the commands is not a command: list its name in `exclude`.
|
|
276
|
+
*/
|
|
277
|
+
const commandTypes = async ({ cwd, options }) => {
|
|
278
|
+
const { project, files } = await createProject(cwd, options.tsConfig, options.commands, {
|
|
279
|
+
check: "command-types",
|
|
280
|
+
files: "commands"
|
|
281
|
+
});
|
|
282
|
+
const excluded = new Set(options.exclude);
|
|
283
|
+
const inferences = options.inferences ?? DEFAULT_INFERENCES;
|
|
284
|
+
const seen = /* @__PURE__ */ new Set();
|
|
285
|
+
const violations = [];
|
|
286
|
+
for (const file of files) for (const [name, declaration] of exportedTypes(project.getSourceFileOrThrow(resolve(cwd, file)))) {
|
|
287
|
+
if (excluded.has(name) || seen.has(declaration)) continue;
|
|
288
|
+
seen.add(declaration);
|
|
289
|
+
const found = problem(declaration, name, inferences);
|
|
290
|
+
if (found !== void 0) violations.push({
|
|
291
|
+
code: `command-types/${found.reason}`,
|
|
292
|
+
message: found.message,
|
|
293
|
+
file: relativePosix(cwd, declaration.getSourceFile().getFilePath()),
|
|
294
|
+
location: declarationLocation(declaration)
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
return violations.sort((a, b) => a.file.localeCompare(b.file) || (a.location?.line ?? 0) - (b.location?.line ?? 0));
|
|
298
|
+
};
|
|
299
|
+
//#endregion
|
|
300
|
+
//#region src/config-files.ts
|
|
301
|
+
/**
|
|
302
|
+
* A record: a non-null, non-array object.
|
|
303
|
+
*/
|
|
304
|
+
function isRecord(value) {
|
|
305
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* The default export of the config file at `file` (relative to `cwd`), evaluated through the shared jiti loader, with the `alias` and `fsCache` of `configFiles`, so a TypeScript config runs as its own tool would run it, or `undefined` when the file does not exist.
|
|
309
|
+
*/
|
|
310
|
+
async function evaluateConfigFile(cwd, file, configFiles) {
|
|
311
|
+
const absolute = resolve(cwd, file);
|
|
312
|
+
if (!existsSync(absolute)) return;
|
|
313
|
+
return createJitiLoader({
|
|
314
|
+
...configFiles?.alias === void 0 ? {} : { alias: configFiles.alias },
|
|
315
|
+
fsCache: configFiles?.fsCache ?? false
|
|
316
|
+
}).importer.importDefault(absolute);
|
|
317
|
+
}
|
|
318
|
+
//#endregion
|
|
319
|
+
//#region src/checks/commit-types.ts
|
|
320
|
+
const DEFAULT_COMMITLINT_CONFIG = "commitlint.config.ts";
|
|
321
|
+
const DEFAULT_RELEASE_CONFIG = "release.config.ts";
|
|
322
|
+
const COMMIT_ANALYZER = "@semantic-release/commit-analyzer";
|
|
323
|
+
const RELEASE_NOTES_GENERATOR = "@semantic-release/release-notes-generator";
|
|
324
|
+
function stringsOf(value) {
|
|
325
|
+
return Array.isArray(value) && value.every((item) => typeof item === "string") ? value : void 0;
|
|
326
|
+
}
|
|
327
|
+
const RULE_OFF = 0;
|
|
328
|
+
/**
|
|
329
|
+
* The `type-enum` rule of the commitlint config, or `undefined` when the config does not set it and so takes the rule of its preset. The rule is `[level, condition, types]`: `'always'` lists the only accepted types, `'never'` lists forbidden ones (every other type is accepted), and level 0 switches the rule off (every type is accepted).
|
|
330
|
+
*/
|
|
331
|
+
function commitTypeRule(config, file) {
|
|
332
|
+
const rules = config["rules"];
|
|
333
|
+
if (!isRecord(rules) || !("type-enum" in rules)) return;
|
|
334
|
+
const rule = rules["type-enum"];
|
|
335
|
+
const [level, condition, listed] = Array.isArray(rule) ? rule.map((part) => part) : [];
|
|
336
|
+
const types = stringsOf(listed);
|
|
337
|
+
if (typeof level !== "number" || condition !== "always" && condition !== "never" || types === void 0) throw new ConformanceError(`${file}: the 'type-enum' rule must be written as [level, 'always' or 'never', [types]] for its types to be read`);
|
|
338
|
+
if (level === RULE_OFF) return {
|
|
339
|
+
enumerated: void 0,
|
|
340
|
+
accepts: () => true
|
|
341
|
+
};
|
|
342
|
+
return condition === "always" ? {
|
|
343
|
+
enumerated: types,
|
|
344
|
+
accepts: (type) => types.includes(type)
|
|
345
|
+
} : {
|
|
346
|
+
enumerated: void 0,
|
|
347
|
+
accepts: (type) => !types.includes(type)
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* The options of the semantic-release plugin named `plugin`, or `undefined` when it is not configured or has no options.
|
|
352
|
+
*/
|
|
353
|
+
function pluginOptions(release, plugin) {
|
|
354
|
+
const plugins = release["plugins"];
|
|
355
|
+
if (!Array.isArray(plugins)) return;
|
|
356
|
+
for (const entry of plugins) if (Array.isArray(entry) && entry[0] === plugin && isRecord(entry[1])) return entry[1];
|
|
357
|
+
}
|
|
358
|
+
function typesOfEntries(entries, file, what) {
|
|
359
|
+
if (entries === void 0) return;
|
|
360
|
+
if (!Array.isArray(entries)) throw new ConformanceError(`${file}: ${what} must be an array for its types to be read`);
|
|
361
|
+
return entries.flatMap((entry) => isRecord(entry) && typeof entry["type"] === "string" ? [entry["type"]] : []);
|
|
362
|
+
}
|
|
363
|
+
async function readObject(cwd, file, configFiles) {
|
|
364
|
+
const config = await evaluateConfigFile(cwd, file, configFiles);
|
|
365
|
+
if (config === void 0) return;
|
|
366
|
+
if (!isRecord(config)) throw new ConformanceError(`${file}: the default export must be an object`);
|
|
367
|
+
return config;
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* The commit types the conventional presets know, which is the `type-enum` of `@commitlint/config-conventional` and the type list the conventional changelog presets label. Their release behaviour is the preset's own, so a preset type needs no release rule or changelog section to be accounted for; a type outside this list does.
|
|
371
|
+
*/
|
|
372
|
+
const PRESET_COMMIT_TYPES = [
|
|
373
|
+
"build",
|
|
374
|
+
"chore",
|
|
375
|
+
"ci",
|
|
376
|
+
"docs",
|
|
377
|
+
"feat",
|
|
378
|
+
"fix",
|
|
379
|
+
"perf",
|
|
380
|
+
"refactor",
|
|
381
|
+
"revert",
|
|
382
|
+
"style",
|
|
383
|
+
"test"
|
|
384
|
+
];
|
|
385
|
+
function isCustom(type) {
|
|
386
|
+
return !PRESET_COMMIT_TYPES.includes(type);
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* The commit types commitlint accepts are accounted for in the release config. A type no preset knows must have a release rule and, when the config lists changelog sections (`presetConfig.types` of the release notes generator), a section, or it can be committed and never released or shown; a release rule or changelog section for a type commitlint rejects can never apply. Types are enumerated only by a `type-enum` rule of the form `[level, 'always', [types]]`; a `'never'` rule forbids the types it lists and enumerates none, and a rule that is off (level 0) accepts every type. A preset type may be left out of either list: leaving out a type that does not release is how a release rule list is normally written.
|
|
390
|
+
*
|
|
391
|
+
* It compares only what is written. The check has nothing to say when the commit types are the preset's and every listed type is one of them. A commitlint config that does not set `type-enum` while the release config lists a type no preset knows is a violation, since the commit types cannot then be read. Both configs are evaluated, so a list may be derived from one shared constant.
|
|
392
|
+
*/
|
|
393
|
+
const commitTypes = async ({ cwd, options, configFiles }) => {
|
|
394
|
+
const commitlintFile = options.commitlint ?? DEFAULT_COMMITLINT_CONFIG;
|
|
395
|
+
const releaseFile = options.release ?? DEFAULT_RELEASE_CONFIG;
|
|
396
|
+
const commitlint = await readObject(cwd, commitlintFile, configFiles);
|
|
397
|
+
const release = await readObject(cwd, releaseFile, configFiles);
|
|
398
|
+
const violations = [];
|
|
399
|
+
if (commitlint === void 0) violations.push({
|
|
400
|
+
code: "commit-types/missing-config",
|
|
401
|
+
message: `${commitlintFile} does not exist`,
|
|
402
|
+
file: commitlintFile
|
|
403
|
+
});
|
|
404
|
+
if (release === void 0) violations.push({
|
|
405
|
+
code: "commit-types/missing-config",
|
|
406
|
+
message: `${releaseFile} does not exist`,
|
|
407
|
+
file: releaseFile
|
|
408
|
+
});
|
|
409
|
+
if (commitlint === void 0 || release === void 0) return violations;
|
|
410
|
+
const releaseRules = typesOfEntries(pluginOptions(release, COMMIT_ANALYZER)?.["releaseRules"], releaseFile, "releaseRules");
|
|
411
|
+
const presetConfig = pluginOptions(release, RELEASE_NOTES_GENERATOR)?.["presetConfig"];
|
|
412
|
+
const changelog = typesOfEntries(isRecord(presetConfig) ? presetConfig["types"] : void 0, releaseFile, "presetConfig.types");
|
|
413
|
+
const accepted = commitTypeRule(commitlint, commitlintFile);
|
|
414
|
+
if (accepted === void 0) {
|
|
415
|
+
const unknown = [releaseRules, changelog].flatMap((list) => list ?? []).filter(isCustom);
|
|
416
|
+
if (unknown.length > 0) violations.push({
|
|
417
|
+
code: "commit-types/no-type-enum",
|
|
418
|
+
message: `${releaseFile} lists ${unknown.map((type) => `'${type}'`).join(", ")}, which no preset knows, but ${commitlintFile} sets no 'type-enum' rule, so the commit types cannot be read`,
|
|
419
|
+
file: commitlintFile
|
|
420
|
+
});
|
|
421
|
+
return violations;
|
|
422
|
+
}
|
|
423
|
+
const lists = [{
|
|
424
|
+
listed: releaseRules,
|
|
425
|
+
required: true,
|
|
426
|
+
noun: "release rule",
|
|
427
|
+
absent: "no-release-rule",
|
|
428
|
+
extra: "release-rule-not-a-commit-type"
|
|
429
|
+
}, {
|
|
430
|
+
listed: changelog,
|
|
431
|
+
required: changelog !== void 0,
|
|
432
|
+
noun: "changelog section",
|
|
433
|
+
absent: "no-changelog-section",
|
|
434
|
+
extra: "changelog-section-not-a-commit-type"
|
|
435
|
+
}];
|
|
436
|
+
for (const { listed, required, noun, absent, extra } of lists) {
|
|
437
|
+
for (const type of required ? (accepted.enumerated ?? []).filter(isCustom) : []) if (listed?.includes(type) !== true) violations.push({
|
|
438
|
+
code: `commit-types/${absent}`,
|
|
439
|
+
message: `'${type}' is a commit type in ${commitlintFile} that no preset knows, and ${releaseFile} has no ${noun} for it`,
|
|
440
|
+
file: releaseFile
|
|
441
|
+
});
|
|
442
|
+
for (const type of (listed ?? []).filter((candidate) => !accepted.accepts(candidate))) violations.push({
|
|
443
|
+
code: `commit-types/${extra}`,
|
|
444
|
+
message: `'${type}' has a ${noun} in ${releaseFile} but is not a commit type in ${commitlintFile}`,
|
|
445
|
+
file: commitlintFile
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
return violations;
|
|
449
|
+
};
|
|
450
|
+
//#endregion
|
|
451
|
+
//#region src/checks/dockerfile-package-manager.ts
|
|
452
|
+
const DEFAULT_DOCKERFILES = [
|
|
453
|
+
"**/Dockerfile",
|
|
454
|
+
"**/Dockerfile.*",
|
|
455
|
+
"**/*.Dockerfile"
|
|
456
|
+
];
|
|
457
|
+
const DEFAULT_PACKAGE_JSON = "package.json";
|
|
458
|
+
/**
|
|
459
|
+
* A package manager and a version, `pnpm@12.4.1` or `pnpm@12.4.1+sha512.abc`, as `packageManager` and corepack write them.
|
|
460
|
+
*/
|
|
461
|
+
const PACKAGE_MANAGER = /^([a-z]+)@([^+\s]+)/u;
|
|
462
|
+
/**
|
|
463
|
+
* A variable reference in a version: `$NAME` or `${NAME}`, capturing the name.
|
|
464
|
+
*/
|
|
465
|
+
const VARIABLE = /\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/gu;
|
|
466
|
+
/**
|
|
467
|
+
* `<manager>@<version>` wherever a Dockerfile installs or activates a package manager. The manager is not the end of a longer name (`create-pnpm`, `@scope/pnpm`). The version is what a version is made of, plus variable references, and stops at anything else, such as a comma or a quote; a `+` suffix such as `+sha512.abc` is not part of it.
|
|
468
|
+
*/
|
|
469
|
+
const PIN = /(?<![\w./@-])(pnpm|yarn|npm)@((?:\$\{[A-Za-z_][A-Za-z0-9_]*\}|\$[A-Za-z_][A-Za-z0-9_]*|[0-9A-Za-z._-])+)/gu;
|
|
470
|
+
const INSTRUCTION = /^\s*(ARG|ENV)\s+(.*)$/iu;
|
|
471
|
+
const ASSIGNMENT = /([A-Za-z_][A-Za-z0-9_]*)=("[^"]*"|'[^']*'|\S*)/gu;
|
|
472
|
+
function unquoted(value) {
|
|
473
|
+
return value.replace(/^(["'])(.*)\1$/u, "$2");
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* The variables an `ARG` or `ENV` instruction sets, with the value it gives each, or `undefined` for an `ARG` without a default. `ARG NAME=value`, `ENV NAME=value NAME2=value2` and `ENV NAME value` are read; any other line sets nothing.
|
|
477
|
+
*/
|
|
478
|
+
function variablesSetBy(line) {
|
|
479
|
+
const [, instruction, body] = INSTRUCTION.exec(line) ?? [];
|
|
480
|
+
const variables = /* @__PURE__ */ new Map();
|
|
481
|
+
if (instruction === void 0 || body === void 0) return variables;
|
|
482
|
+
for (const [, name, value] of body.matchAll(ASSIGNMENT)) if (name !== void 0 && value !== void 0) variables.set(name, unquoted(value));
|
|
483
|
+
if (variables.size === 0) {
|
|
484
|
+
const [name, ...value] = body.trim().split(/\s+/u);
|
|
485
|
+
if (name !== void 0 && name !== "") variables.set(name, instruction.toUpperCase() === "ENV" && value.length > 0 ? unquoted(value.join(" ")) : void 0);
|
|
486
|
+
}
|
|
487
|
+
return variables;
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* The version a pin names once the variables it refers to are replaced by the values in effect, or `undefined` when a variable has no value there (an `ARG` without a default, or a variable this file does not set) or its value refers to another variable, which makes the version unknowable from the file. A `+sha` suffix is dropped.
|
|
491
|
+
*/
|
|
492
|
+
function versionOf(token, variables) {
|
|
493
|
+
const unknown = [];
|
|
494
|
+
const replaced = token.replace(VARIABLE, (_reference, variable) => {
|
|
495
|
+
const value = variables.get(variable);
|
|
496
|
+
if (value === void 0 || value.includes("$")) {
|
|
497
|
+
unknown.push(variable);
|
|
498
|
+
return "";
|
|
499
|
+
}
|
|
500
|
+
return value;
|
|
501
|
+
});
|
|
502
|
+
return unknown.length === 0 ? replaced.split("+")[0] : void 0;
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* Every pin of the package manager that `packageManager` names, in each Dockerfile, is the version `packageManager` names, with any `+sha` integrity suffix ignored on both sides. Pins of other package managers are not the workspace's package manager and are left alone.
|
|
506
|
+
*
|
|
507
|
+
* It reads `<manager>@<version>` as written on a line that is not a comment. A version that refers to a variable is compared with the value the file gives that variable by an `ARG` or `ENV` before the pin (the latest such value, whichever build stage it was in); one whose variable has no value in the file is not compared, since the build supplies it.
|
|
508
|
+
*/
|
|
509
|
+
const dockerfilePackageManager = async ({ cwd, options }) => {
|
|
510
|
+
const packageJson = options.packageJson ?? DEFAULT_PACKAGE_JSON;
|
|
511
|
+
const manifest = JSON.parse(await readFile(resolve(cwd, packageJson), "utf8"));
|
|
512
|
+
const declared = isRecord(manifest) && typeof manifest["packageManager"] === "string" ? PACKAGE_MANAGER.exec(manifest["packageManager"]) : null;
|
|
513
|
+
const name = declared?.[1];
|
|
514
|
+
const version = declared?.[2];
|
|
515
|
+
if (name === void 0 || version === void 0) return [{
|
|
516
|
+
code: "dockerfile-package-manager/no-package-manager",
|
|
517
|
+
message: `${packageJson} has no valid 'packageManager' field to compare Dockerfile pins with`,
|
|
518
|
+
file: packageJson
|
|
519
|
+
}];
|
|
520
|
+
const violations = [];
|
|
521
|
+
for (const file of await findFiles(cwd, [...options.dockerfiles ?? DEFAULT_DOCKERFILES, ...excludedFrom(options.exclude)])) {
|
|
522
|
+
const variables = /* @__PURE__ */ new Map();
|
|
523
|
+
(await readFile(resolve(cwd, file), "utf8")).split("\n").forEach((text, index) => {
|
|
524
|
+
if (text.trimStart().startsWith("#")) return;
|
|
525
|
+
for (const [variable, value] of variablesSetBy(text)) variables.set(variable, value);
|
|
526
|
+
for (const match of text.matchAll(PIN)) {
|
|
527
|
+
const pinned = versionOf(String(match[2]), variables);
|
|
528
|
+
if (match[1] === name && pinned !== void 0 && pinned !== version) violations.push({
|
|
529
|
+
code: "dockerfile-package-manager/version-mismatch",
|
|
530
|
+
message: `${file} pins ${name}@${pinned} but packageManager in ${packageJson} is ${name}@${version}`,
|
|
531
|
+
file,
|
|
532
|
+
location: {
|
|
533
|
+
line: index + 1,
|
|
534
|
+
column: match.index + 1
|
|
535
|
+
}
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
return violations;
|
|
541
|
+
};
|
|
542
|
+
//#endregion
|
|
543
|
+
//#region src/workspace/rules.ts
|
|
544
|
+
const REGEXP_SPECIAL = /[.*+?^${}()|[\]\\]/gu;
|
|
545
|
+
function escapeRegExp(text) {
|
|
546
|
+
return text.replace(REGEXP_SPECIAL, "\\$&");
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* A regular expression, as source text, matching every file inside any of the packages. The trailing `/` keeps `packages/a` from matching `packages/ab`.
|
|
550
|
+
*/
|
|
551
|
+
function packagesPattern(packages) {
|
|
552
|
+
return `^(${packages.map((member) => `${escapeRegExp(member.dir)}/`).join("|")})`;
|
|
553
|
+
}
|
|
554
|
+
/**
|
|
555
|
+
* A regular expression, as source text, matching the name an import uses for any of the named packages: the name itself or a subpath of it. It matches nothing but those names, and is `undefined` when no package has a name.
|
|
556
|
+
*/
|
|
557
|
+
function packageNamesPattern(packages) {
|
|
558
|
+
const names = packages.flatMap((member) => member.name === void 0 ? [] : [escapeRegExp(member.name)]);
|
|
559
|
+
return names.length === 0 ? void 0 : `^(${names.join("|")})(/|$)`;
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* The paths a graph of these packages is limited to: their files, and the names other packages import them by. An import that dependency-cruiser cannot resolve to a file (a package whose entry point is not built, or a workspace that is not installed) is known to it only by that name.
|
|
563
|
+
*/
|
|
564
|
+
function graphScopePattern(packages) {
|
|
565
|
+
const names = packageNamesPattern(packages);
|
|
566
|
+
return names === void 0 ? packagesPattern(packages) : `${packagesPattern(packages)}|${names}`;
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* The rules that forbid files of `from` to import `to`: one for the files the packages resolve to and, when any of them has a name, one for an import of the name that cannot be resolved to a file. Both are named after `name`, which is also each rule's comment prefix.
|
|
570
|
+
*/
|
|
571
|
+
function forbid(name, comment, from, to) {
|
|
572
|
+
const names = packageNamesPattern(to);
|
|
573
|
+
const byPath = {
|
|
574
|
+
name,
|
|
575
|
+
severity: "error",
|
|
576
|
+
comment,
|
|
577
|
+
from: { path: packagesPattern(from) },
|
|
578
|
+
to: { path: packagesPattern(to) }
|
|
579
|
+
};
|
|
580
|
+
return names === void 0 ? [byPath] : [byPath, {
|
|
581
|
+
name: `${name}-by-name`,
|
|
582
|
+
severity: "error",
|
|
583
|
+
comment,
|
|
584
|
+
from: { path: packagesPattern(from) },
|
|
585
|
+
to: {
|
|
586
|
+
path: names,
|
|
587
|
+
couldNotResolve: true
|
|
588
|
+
}
|
|
589
|
+
}];
|
|
590
|
+
}
|
|
591
|
+
function groupBy(packages, keyOf) {
|
|
592
|
+
const groups = /* @__PURE__ */ new Map();
|
|
593
|
+
for (const member of packages) {
|
|
594
|
+
const key = keyOf(member);
|
|
595
|
+
if (key === void 0) continue;
|
|
596
|
+
const existing = groups.get(key);
|
|
597
|
+
if (existing === void 0) groups.set(key, [member]);
|
|
598
|
+
else existing.push(member);
|
|
599
|
+
}
|
|
600
|
+
return groups;
|
|
601
|
+
}
|
|
602
|
+
/**
|
|
603
|
+
* A package's rank, or a `ConformanceError` naming every package that has none. A rank check that skipped an unranked package would look clean while checking less.
|
|
604
|
+
*/
|
|
605
|
+
function ranked(packages) {
|
|
606
|
+
const unranked = packages.filter((member) => member.rank === void 0);
|
|
607
|
+
if (unranked.length > 0) throw new ConformanceError(`no rank resolves for ${unranked.map((member) => member.dir).join(", ")}; give the group a rank, add a matching nameRanks pattern, or set defaultRank in the layout`);
|
|
608
|
+
return packages.flatMap((member) => member.rank === void 0 ? [] : [{
|
|
609
|
+
...member,
|
|
610
|
+
rank: member.rank
|
|
611
|
+
}]);
|
|
612
|
+
}
|
|
613
|
+
/**
|
|
614
|
+
* One rule per rank that has a higher rank above it: files of a package at that rank may not import a package at a strictly higher rank. A rule compares no numbers, so the ordering is expanded into path alternations here. Each rule comes with a `-by-name` rule for imports that do not resolve to a file.
|
|
615
|
+
*/
|
|
616
|
+
function uphillRules(packages) {
|
|
617
|
+
const members = ranked(packages);
|
|
618
|
+
return [...groupBy(members, (member) => member.rank).entries()].sort(([a], [b]) => a - b).flatMap(([rank, own]) => {
|
|
619
|
+
const higher = members.filter((member) => member.rank > rank);
|
|
620
|
+
return higher.length === 0 ? [] : forbid(`uphill-rank-${String(rank)}`, `a package of rank ${String(rank)} may not import a package of a higher rank`, own, higher);
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
/**
|
|
624
|
+
* One rule per rank with a package more than `rankSkip.maxDistance` ranks below it that is not an exempt rank.
|
|
625
|
+
*/
|
|
626
|
+
function rankSkipRules(packages, rankSkip) {
|
|
627
|
+
const members = ranked(packages);
|
|
628
|
+
return [...groupBy(members, (member) => member.rank).entries()].sort(([a], [b]) => a - b).flatMap(([rank, own]) => {
|
|
629
|
+
const skipped = members.filter((member) => member.rank < rank - rankSkip.maxDistance && !rankSkip.exemptRanks.includes(member.rank));
|
|
630
|
+
return skipped.length === 0 ? [] : forbid(`rank-skip-${String(rank)}`, `a package of rank ${String(rank)} may import at most ${String(rankSkip.maxDistance)} rank(s) below it, apart from the exempt ranks`, own, skipped);
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
/**
|
|
634
|
+
* One rule per slice: a package in it may not import a package that has a different slice. A package without a slice is in none and is never restricted.
|
|
635
|
+
*/
|
|
636
|
+
function crossSliceRules(packages) {
|
|
637
|
+
return [...groupBy(packages, (member) => member.slice).entries()].sort(([a], [b]) => a.localeCompare(b)).flatMap(([slice, own]) => {
|
|
638
|
+
const others = packages.filter((member) => member.slice !== void 0 && member.slice !== slice);
|
|
639
|
+
return others.length === 0 ? [] : forbid(`cross-slice-${slice}`, `a package in the '${slice}' slice may not import a package in another slice`, own, others);
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
/**
|
|
643
|
+
* Two rules per isolated pair, one for each direction.
|
|
644
|
+
*/
|
|
645
|
+
function isolatedGroupRules(packages, isolatedGroups) {
|
|
646
|
+
const byGroup = groupBy(packages, (member) => member.group);
|
|
647
|
+
return isolatedGroups.flatMap(([first, second]) => [{
|
|
648
|
+
from: first,
|
|
649
|
+
to: second
|
|
650
|
+
}, {
|
|
651
|
+
from: second,
|
|
652
|
+
to: first
|
|
653
|
+
}].flatMap(({ from, to }) => {
|
|
654
|
+
const own = byGroup.get(from);
|
|
655
|
+
const other = byGroup.get(to);
|
|
656
|
+
return own === void 0 || other === void 0 ? [] : forbid(`isolated-${from}-${to}`, `the '${from}' group may not import the '${to}' group`, own, other);
|
|
657
|
+
}));
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* The rule that forbids any import cycle among the packages' files.
|
|
661
|
+
*/
|
|
662
|
+
function cycleRule() {
|
|
663
|
+
return {
|
|
664
|
+
name: "import-cycle",
|
|
665
|
+
severity: "error",
|
|
666
|
+
comment: "files may not import each other in a cycle",
|
|
667
|
+
from: {},
|
|
668
|
+
to: { circular: true }
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
/**
|
|
672
|
+
* The rule that reports every import of a named package by a name that resolves to no file. It marks edges for the cycle check to follow, so a finding of it is an import and not a defect.
|
|
673
|
+
*
|
|
674
|
+
* It is `undefined` when no package has a name.
|
|
675
|
+
*/
|
|
676
|
+
function unresolvedNameImportRule(packages) {
|
|
677
|
+
const names = packageNamesPattern(packages);
|
|
678
|
+
return names === void 0 ? void 0 : {
|
|
679
|
+
name: "unresolved-name-import",
|
|
680
|
+
severity: "error",
|
|
681
|
+
comment: "marks an import of a workspace package by a name that resolves to no file",
|
|
682
|
+
from: { path: packagesPattern(packages) },
|
|
683
|
+
to: {
|
|
684
|
+
path: names,
|
|
685
|
+
couldNotResolve: true
|
|
686
|
+
}
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
//#endregion
|
|
690
|
+
//#region src/workspace/packages.ts
|
|
691
|
+
function segmentsOf(path) {
|
|
692
|
+
return path.split("/").filter((segment) => segment !== "" && segment !== ".");
|
|
693
|
+
}
|
|
694
|
+
function groupRoot(group) {
|
|
695
|
+
return segmentsOf(group.path ?? group.name);
|
|
696
|
+
}
|
|
697
|
+
function startsWithSegments(segments, prefix) {
|
|
698
|
+
return prefix.every((segment, index) => segments[index] === segment);
|
|
699
|
+
}
|
|
700
|
+
/**
|
|
701
|
+
* The group whose root is the longest prefix of `dir`; the first declared wins a tie. `undefined` when no group owns the directory.
|
|
702
|
+
*/
|
|
703
|
+
function owningGroup(dir, groups) {
|
|
704
|
+
const segments = segmentsOf(dir);
|
|
705
|
+
let best;
|
|
706
|
+
let bestLength = -1;
|
|
707
|
+
for (const group of groups) {
|
|
708
|
+
const root = groupRoot(group);
|
|
709
|
+
if (root.length > bestLength && startsWithSegments(segments, root)) {
|
|
710
|
+
best = group;
|
|
711
|
+
bestLength = root.length;
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
return best;
|
|
715
|
+
}
|
|
716
|
+
function rankOf(name, group, layout) {
|
|
717
|
+
if (name !== void 0) {
|
|
718
|
+
const rule = layout.nameRanks?.find((candidate) => new RegExp(candidate.pattern, "u").test(name));
|
|
719
|
+
if (rule !== void 0) return rule.rank;
|
|
720
|
+
}
|
|
721
|
+
return group.rank ?? layout.defaultRank;
|
|
722
|
+
}
|
|
723
|
+
/**
|
|
724
|
+
* The name without its npm scope: `@scope/store-cli` gives `store-cli`.
|
|
725
|
+
*/
|
|
726
|
+
function unscoped(name) {
|
|
727
|
+
return name.replace(/^@[^/]+\//u, "");
|
|
728
|
+
}
|
|
729
|
+
/**
|
|
730
|
+
* The longest slice value that is the whole unscoped name or its prefix up to a `-`.
|
|
731
|
+
*/
|
|
732
|
+
function sliceByNamePrefix(name, known) {
|
|
733
|
+
const bare = unscoped(name);
|
|
734
|
+
return [...known].filter((candidate) => bare === candidate || bare.startsWith(`${candidate}-`)).sort((a, b) => b.length - a.length)[0];
|
|
735
|
+
}
|
|
736
|
+
/**
|
|
737
|
+
* Classify discovered packages by the layout: group, rank and slice. Pure.
|
|
738
|
+
*
|
|
739
|
+
* A package no group owns is a configuration error, because every check would otherwise skip it without saying so. Nested package directories are one too: the checks attribute a file to the package whose directory is its prefix, which is ambiguous for a package inside another. A `namePrefix` slice takes the longest slice value that a `segment` group produced and that prefixes the package's unscoped name; a package without a name has no such slice.
|
|
740
|
+
*/
|
|
741
|
+
function classifyPackages(discovered, layout) {
|
|
742
|
+
const owned = discovered.map((found) => {
|
|
743
|
+
const group = owningGroup(found.dir, layout.groups);
|
|
744
|
+
if (group === void 0) throw new ConformanceError(`the package directory '${found.dir}' is not under any layout group's path; add a group whose path prefixes it, or narrow the workspace package globs`);
|
|
745
|
+
return {
|
|
746
|
+
found,
|
|
747
|
+
group
|
|
748
|
+
};
|
|
749
|
+
});
|
|
750
|
+
const knownSlices = /* @__PURE__ */ new Set();
|
|
751
|
+
for (const { found, group } of owned) if (group.slice !== void 0 && "segment" in group.slice) {
|
|
752
|
+
const value = segmentsOf(found.dir).slice(groupRoot(group).length)[group.slice.segment];
|
|
753
|
+
if (value !== void 0) knownSlices.add(value);
|
|
754
|
+
}
|
|
755
|
+
const packages = owned.map(({ found, group }) => {
|
|
756
|
+
let slice;
|
|
757
|
+
if (group.slice !== void 0 && "segment" in group.slice) slice = segmentsOf(found.dir).slice(groupRoot(group).length)[group.slice.segment];
|
|
758
|
+
else if (group.slice !== void 0 && found.name !== void 0) slice = sliceByNamePrefix(found.name, knownSlices);
|
|
759
|
+
return {
|
|
760
|
+
...found,
|
|
761
|
+
group: group.name,
|
|
762
|
+
rank: rankOf(found.name, group, layout),
|
|
763
|
+
slice
|
|
764
|
+
};
|
|
765
|
+
});
|
|
766
|
+
for (const outer of packages) {
|
|
767
|
+
const nested = packages.find((inner) => inner.dir !== outer.dir && inner.dir.startsWith(`${outer.dir}/`));
|
|
768
|
+
if (nested !== void 0) throw new ConformanceError(`the package directory '${nested.dir}' is inside the package directory '${outer.dir}'; workspace packages must not nest`);
|
|
769
|
+
}
|
|
770
|
+
return packages;
|
|
771
|
+
}
|
|
772
|
+
/**
|
|
773
|
+
* The workspace root: `layout.root` resolved against `cwd`, or `cwd` itself.
|
|
774
|
+
*/
|
|
775
|
+
function workspaceRoot(cwd, layout) {
|
|
776
|
+
return layout.root === void 0 ? cwd : resolve(cwd, layout.root);
|
|
777
|
+
}
|
|
778
|
+
/**
|
|
779
|
+
* The package globs: `layout.packages`, else the `packages` list of `pnpm-workspace.yaml` in `root`.
|
|
780
|
+
*/
|
|
781
|
+
async function workspacePatterns(root, layout) {
|
|
782
|
+
if (layout.packages !== void 0) return layout.packages;
|
|
783
|
+
const file = join(root, "pnpm-workspace.yaml");
|
|
784
|
+
let source;
|
|
785
|
+
try {
|
|
786
|
+
source = await readFile(file, "utf8");
|
|
787
|
+
} catch (error) {
|
|
788
|
+
throw new ConformanceError(`the layout has no 'packages' and ${file} cannot be read`, { cause: error });
|
|
789
|
+
}
|
|
790
|
+
const document = parse(source);
|
|
791
|
+
if (typeof document !== "object" || document === null || !("packages" in document) || !Array.isArray(document.packages)) throw new ConformanceError(`${file} has no 'packages' list`);
|
|
792
|
+
const patterns = document.packages;
|
|
793
|
+
if (!patterns.every((pattern) => typeof pattern === "string")) throw new ConformanceError(`${file}: every entry of 'packages' must be a string`);
|
|
794
|
+
return patterns;
|
|
795
|
+
}
|
|
796
|
+
async function readName(manifest) {
|
|
797
|
+
let content;
|
|
798
|
+
try {
|
|
799
|
+
content = JSON.parse(await readFile(manifest, "utf8"));
|
|
800
|
+
} catch (error) {
|
|
801
|
+
throw new ConformanceError(`${manifest} cannot be read as JSON`, { cause: error });
|
|
802
|
+
}
|
|
803
|
+
if (typeof content === "object" && content !== null && "name" in content && typeof content.name === "string") return content.name;
|
|
804
|
+
}
|
|
805
|
+
/**
|
|
806
|
+
* The directories under `root` that the pnpm-style `patterns` select and that hold a `package.json`, sorted. A `!` pattern excludes. The root package itself (`.`) is not a member of any group and is left out.
|
|
807
|
+
*/
|
|
808
|
+
async function discoverPackages(root, patterns) {
|
|
809
|
+
const manifests = patterns.map((pattern) => pattern.startsWith("!") ? `!${pattern.slice(1)}/**` : `${pattern}/package.json`);
|
|
810
|
+
const dirs = (await glob(manifests, {
|
|
811
|
+
cwd: root,
|
|
812
|
+
dot: true,
|
|
813
|
+
ignore: [INSTALLED_DEPENDENCIES_GLOB],
|
|
814
|
+
onlyFiles: true
|
|
815
|
+
})).map((manifest) => manifest.replace(/\/?package\.json$/u, "")).filter((dir) => dir !== "");
|
|
816
|
+
return Promise.all([...new Set(dirs)].sort().map(async (dir) => ({
|
|
817
|
+
dir,
|
|
818
|
+
name: await readName(join(root, dir, "package.json"))
|
|
819
|
+
})));
|
|
820
|
+
}
|
|
821
|
+
/**
|
|
822
|
+
* The layout's packages found under the workspace root.
|
|
823
|
+
*/
|
|
824
|
+
async function readWorkspacePackages(cwd, layout) {
|
|
825
|
+
const root = workspaceRoot(cwd, layout);
|
|
826
|
+
return classifyPackages(await discoverPackages(root, await workspacePatterns(root, layout)), layout);
|
|
827
|
+
}
|
|
828
|
+
//#endregion
|
|
829
|
+
//#region src/checks/import-graph.ts
|
|
830
|
+
/**
|
|
831
|
+
* Paths left out of the import graph unless the options say otherwise: a package's own installed dependencies.
|
|
832
|
+
*/
|
|
833
|
+
const DEFAULT_GRAPH_EXCLUDES = ["(^|/)node_modules/"];
|
|
834
|
+
/**
|
|
835
|
+
* Paths whose files are imported but whose own imports are not followed unless the options say otherwise: build output, which repeats the sources it was built from. A package that exports its build output is imported through it, so the output has to stay in the graph as a target.
|
|
836
|
+
*/
|
|
837
|
+
const DEFAULT_DO_NOT_FOLLOW = ["(^|/)dist/"];
|
|
838
|
+
/**
|
|
839
|
+
* The package a path in the graph belongs to: the one whose directory holds the file, and only when no directory does, the one whose name the path is (an import that resolved to no file is known only by the name it was written with, or a subpath of it). The directory wins because an unscoped package name can equal the leading directory of another package's files. It throws `ConformanceError` for a path the graph should not contain.
|
|
840
|
+
*/
|
|
841
|
+
function packageOfPath(packages, path) {
|
|
842
|
+
const found = packages.find((member) => path.startsWith(`${member.dir}/`)) ?? packages.find((member) => member.name !== void 0 && (path === member.name || path.startsWith(`${member.name}/`)));
|
|
843
|
+
if (found === void 0) throw new ConformanceError(`${path} is in no workspace package, although the graph is limited to the packages' files and names`);
|
|
844
|
+
return found;
|
|
845
|
+
}
|
|
846
|
+
/**
|
|
847
|
+
* The tsconfig as dependency-cruiser should read its compiler options, with `baseUrl` set when the tsconfig has none.
|
|
848
|
+
*
|
|
849
|
+
* dependency-cruiser hands its path alias resolver a `baseUrl` of `./` when the parsed tsconfig has none, and that resolves against the working directory of the process, not against the tsconfig. `paths` without `baseUrl` is the form TypeScript 4.1 and later accepts (`baseUrl` is deprecated), and it means relative to the tsconfig. Given any `baseUrl`, dependency-cruiser leaves the alias resolver to read the tsconfig file itself, which applies that meaning wherever the process runs. The value set here is only that signal and is never used as a directory.
|
|
850
|
+
*/
|
|
851
|
+
function withBaseUrl(parsed, tsConfigFile) {
|
|
852
|
+
return "baseUrl" in parsed.options ? parsed : {
|
|
853
|
+
...parsed,
|
|
854
|
+
options: {
|
|
855
|
+
...parsed.options,
|
|
856
|
+
baseUrl: dirname(tsConfigFile)
|
|
857
|
+
}
|
|
858
|
+
};
|
|
859
|
+
}
|
|
860
|
+
/**
|
|
861
|
+
* Throws `ConformanceError` unless dependency-cruiser can load the installed TypeScript. Without it dependency-cruiser cruises no `.ts` file and reports success, so every import check would pass whatever the workspace imports.
|
|
862
|
+
*/
|
|
863
|
+
function assertTypeScriptAvailable(transpilers) {
|
|
864
|
+
const typescript = transpilers.find((transpiler) => transpiler.name === "typescript");
|
|
865
|
+
if (typescript?.available !== true) throw new ConformanceError(`dependency-cruiser cannot load TypeScript, so the import checks would find no TypeScript file and report nothing; install ${typescript === void 0 ? "a version it supports" : `a version in ${typescript.version}`} as the 'typescript' of this workspace`);
|
|
866
|
+
}
|
|
867
|
+
/**
|
|
868
|
+
* The violations of `rules` among the files of `packages`. The graph is limited to the packages' own files, so nothing outside them is a dependant or a dependency.
|
|
869
|
+
*
|
|
870
|
+
* The base directory is the real path of the root: dependency-cruiser resolves symbolic links, so the path of a file behind one would otherwise not be relative to it.
|
|
871
|
+
*
|
|
872
|
+
* The result is read from the cruise summary: `cruise()` reports success in its exit code whatever the summary holds, so the code is not consulted.
|
|
873
|
+
*/
|
|
874
|
+
async function cruiseViolations(input) {
|
|
875
|
+
const { cwd, root, packages, rules, options } = input;
|
|
876
|
+
if (rules.length === 0) return [];
|
|
877
|
+
const tsConfigFile = options.tsConfig === void 0 ? void 0 : resolve(cwd, options.tsConfig);
|
|
878
|
+
const { cruise, getAvailableTranspilers } = await import("dependency-cruiser");
|
|
879
|
+
assertTypeScriptAvailable(getAvailableTranspilers());
|
|
880
|
+
const { default: extractTsConfig } = await import("dependency-cruiser/config-utl/extract-ts-config");
|
|
881
|
+
const result = await cruise(packages.map((member) => member.dir), {
|
|
882
|
+
baseDir: await realpath(root),
|
|
883
|
+
exclude: { path: [...options.exclude ?? DEFAULT_GRAPH_EXCLUDES] },
|
|
884
|
+
doNotFollow: { path: [...options.doNotFollow ?? DEFAULT_DO_NOT_FOLLOW] },
|
|
885
|
+
includeOnly: { path: graphScopePattern(packages) },
|
|
886
|
+
ruleSet: { forbidden: [...rules] },
|
|
887
|
+
tsPreCompilationDeps: true,
|
|
888
|
+
validate: true,
|
|
889
|
+
...tsConfigFile === void 0 ? {} : { tsConfig: { fileName: tsConfigFile } }
|
|
890
|
+
}, {}, tsConfigFile === void 0 ? {} : { tsConfig: withBaseUrl(extractTsConfig(tsConfigFile), tsConfigFile) });
|
|
891
|
+
if (typeof result.output === "string") throw new ConformanceError("dependency-cruiser returned text where a result object was requested");
|
|
892
|
+
return result.output.summary.violations;
|
|
893
|
+
}
|
|
894
|
+
/**
|
|
895
|
+
* Run one import check: generate its rules from the layout, cruise the packages and report each finding.
|
|
896
|
+
*/
|
|
897
|
+
async function runImportCheck(context, spec) {
|
|
898
|
+
const root = workspaceRoot(context.cwd, context.layout);
|
|
899
|
+
const packages = await readWorkspacePackages(context.cwd, context.layout);
|
|
900
|
+
const rules = spec.rules(packages, context);
|
|
901
|
+
return (await cruiseViolations({
|
|
902
|
+
cwd: context.cwd,
|
|
903
|
+
root,
|
|
904
|
+
packages,
|
|
905
|
+
rules,
|
|
906
|
+
options: context.options
|
|
907
|
+
})).map((finding) => {
|
|
908
|
+
const from = packageOfPath(packages, finding.from);
|
|
909
|
+
const to = packageOfPath(packages, finding.to);
|
|
910
|
+
return {
|
|
911
|
+
code: `${spec.name}/${spec.reason}`,
|
|
912
|
+
message: spec.message({
|
|
913
|
+
from,
|
|
914
|
+
to,
|
|
915
|
+
fromFile: finding.from,
|
|
916
|
+
toFile: finding.to
|
|
917
|
+
}),
|
|
918
|
+
file: relativePosix(context.cwd, join(root, finding.from))
|
|
919
|
+
};
|
|
920
|
+
}).sort((a, b) => a.file.localeCompare(b.file) || a.message.localeCompare(b.message));
|
|
921
|
+
}
|
|
922
|
+
/**
|
|
923
|
+
* How a package is named in a message: its declared name, else its directory.
|
|
924
|
+
*/
|
|
925
|
+
function describePackage(member) {
|
|
926
|
+
return member.name ?? member.dir;
|
|
927
|
+
}
|
|
928
|
+
/**
|
|
929
|
+
* How an import's target is named in a message: the file and the package it is in, or just the name when the import resolved to no file and is known only by the name it was written with.
|
|
930
|
+
*/
|
|
931
|
+
function describeTarget(edge) {
|
|
932
|
+
const { to, toFile } = edge;
|
|
933
|
+
return !toFile.startsWith(`${to.dir}/`) && to.name !== void 0 && (toFile === to.name || toFile.startsWith(`${to.name}/`)) ? toFile : `${toFile} in ${describePackage(to)}`;
|
|
934
|
+
}
|
|
935
|
+
//#endregion
|
|
936
|
+
//#region src/checks/import-cross-slice.ts
|
|
937
|
+
/**
|
|
938
|
+
* A package imports a package in a different slice.
|
|
939
|
+
*/
|
|
940
|
+
const importCrossSlice = async (context) => runImportCheck(context, {
|
|
941
|
+
name: "import-cross-slice",
|
|
942
|
+
reason: "other-slice",
|
|
943
|
+
rules: (packages) => {
|
|
944
|
+
if (!packages.some((member) => member.slice !== void 0)) throw new ConformanceError("the 'import-cross-slice' check is enabled but no package in the layout has a slice");
|
|
945
|
+
return crossSliceRules(packages);
|
|
946
|
+
},
|
|
947
|
+
message: (edge) => `${describePackage(edge.from)} (slice '${String(edge.from.slice)}') imports ${describeTarget(edge)} (slice '${String(edge.to.slice)}')`
|
|
948
|
+
});
|
|
949
|
+
//#endregion
|
|
950
|
+
//#region src/checks/import-cycles.ts
|
|
951
|
+
/**
|
|
952
|
+
* `items` rotated to start at the one with the smallest key, so the same ring found from any of its elements has one form.
|
|
953
|
+
*/
|
|
954
|
+
function rotatedToSmallest(items, keyOf) {
|
|
955
|
+
const keys = items.map(keyOf);
|
|
956
|
+
const smallest = keys.toSorted()[0];
|
|
957
|
+
if (smallest === void 0) throw new ConformanceError("an import cycle with no members was reported");
|
|
958
|
+
const start = keys.indexOf(smallest);
|
|
959
|
+
return [...items.slice(start), ...items.slice(0, start)];
|
|
960
|
+
}
|
|
961
|
+
/**
|
|
962
|
+
* The cycles among files that dependency-cruiser found, each as its files in import order from its smallest path. A cycle is one ring of files, so two distinct rings over the same files are two cycles.
|
|
963
|
+
*/
|
|
964
|
+
function fileCycles(findings) {
|
|
965
|
+
const cycles = /* @__PURE__ */ new Map();
|
|
966
|
+
for (const finding of findings) {
|
|
967
|
+
const ring = [finding.from, ...(finding.cycle ?? []).map((step) => step.name)];
|
|
968
|
+
const ordered = rotatedToSmallest(ring[ring.length - 1] === finding.from ? ring.slice(0, -1) : ring, (file) => file);
|
|
969
|
+
cycles.set(ordered.join("\n"), ordered);
|
|
970
|
+
}
|
|
971
|
+
return cycles;
|
|
972
|
+
}
|
|
973
|
+
/**
|
|
974
|
+
* The shortest ring of packages that starts and ends at the package in `startDir` along the name imports, or `undefined` when none returns to it.
|
|
975
|
+
*/
|
|
976
|
+
function shortestRing(startDir, edges) {
|
|
977
|
+
const reached = /* @__PURE__ */ new Map();
|
|
978
|
+
let frontier = [[]];
|
|
979
|
+
while (frontier.length > 0) {
|
|
980
|
+
const next = [];
|
|
981
|
+
for (const path of frontier) {
|
|
982
|
+
const last = path[path.length - 1];
|
|
983
|
+
for (const edge of edges.get(last === void 0 ? startDir : last.to.dir) ?? []) {
|
|
984
|
+
const ring = [...path, edge];
|
|
985
|
+
if (edge.to.dir === startDir) return ring;
|
|
986
|
+
if (!reached.has(edge.to.dir)) {
|
|
987
|
+
reached.set(edge.to.dir, ring);
|
|
988
|
+
next.push(ring);
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
frontier = next;
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
/**
|
|
996
|
+
* The cycles among packages that import each other by a name that resolves to no file (a workspace that is not installed, or whose entry points are not built). There is no file to follow, so the packages stand for their files, and a package importing its own name is not a cycle.
|
|
997
|
+
*/
|
|
998
|
+
function nameCycles(imports) {
|
|
999
|
+
const edges = /* @__PURE__ */ new Map();
|
|
1000
|
+
for (const edge of imports) {
|
|
1001
|
+
if (edge.from.dir === edge.to.dir) continue;
|
|
1002
|
+
const existing = edges.get(edge.from.dir);
|
|
1003
|
+
if (existing === void 0) edges.set(edge.from.dir, [edge]);
|
|
1004
|
+
else if (!existing.some((known) => known.to.dir === edge.to.dir)) existing.push(edge);
|
|
1005
|
+
}
|
|
1006
|
+
const cycles = /* @__PURE__ */ new Map();
|
|
1007
|
+
for (const dir of [...edges.keys()].sort()) {
|
|
1008
|
+
const ring = shortestRing(dir, edges);
|
|
1009
|
+
if (ring !== void 0) {
|
|
1010
|
+
const ordered = rotatedToSmallest(ring, (edge) => edge.from.dir);
|
|
1011
|
+
cycles.set(ordered.map((edge) => edge.from.dir).join("\n"), ordered);
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
return cycles;
|
|
1015
|
+
}
|
|
1016
|
+
/**
|
|
1017
|
+
* Files of the workspace's packages import each other in a cycle, and so do packages that import each other by a name that resolves to no file. Each cycle is reported once, at its first file (or, for packages, the file of the first import), starting from its smallest path.
|
|
1018
|
+
*
|
|
1019
|
+
* Cycles among packages are found only when every import in the ring is by a name that resolves to no file. A ring that mixes such imports with imports that do resolve to files has no complete path through either graph and is not seen.
|
|
1020
|
+
*/
|
|
1021
|
+
const importCycles = async (context) => {
|
|
1022
|
+
const root = workspaceRoot(context.cwd, context.layout);
|
|
1023
|
+
const packages = await readWorkspacePackages(context.cwd, context.layout);
|
|
1024
|
+
const nameRule = unresolvedNameImportRule(packages);
|
|
1025
|
+
const found = await cruiseViolations({
|
|
1026
|
+
cwd: context.cwd,
|
|
1027
|
+
root,
|
|
1028
|
+
packages,
|
|
1029
|
+
rules: nameRule === void 0 ? [cycleRule()] : [cycleRule(), nameRule],
|
|
1030
|
+
options: context.options
|
|
1031
|
+
});
|
|
1032
|
+
const inFiles = fileCycles(found.filter((finding) => finding.rule.name === cycleRule().name));
|
|
1033
|
+
const inPackages = nameCycles(found.filter((finding) => finding.rule.name === nameRule?.name).map((finding) => ({
|
|
1034
|
+
from: packageOfPath(packages, finding.from),
|
|
1035
|
+
to: packageOfPath(packages, finding.to),
|
|
1036
|
+
file: finding.from
|
|
1037
|
+
})));
|
|
1038
|
+
return [...[...inFiles.values()].map((ordered) => {
|
|
1039
|
+
const [first] = ordered;
|
|
1040
|
+
if (first === void 0) throw new ConformanceError("an import cycle with no files was reported");
|
|
1041
|
+
return {
|
|
1042
|
+
code: "import-cycles/cycle",
|
|
1043
|
+
message: `import cycle: ${[...ordered, first].join(" -> ")}`,
|
|
1044
|
+
file: relativePosix(context.cwd, join(root, first))
|
|
1045
|
+
};
|
|
1046
|
+
}), ...[...inPackages.values()].map((ring) => {
|
|
1047
|
+
const [first] = ring;
|
|
1048
|
+
if (first === void 0) throw new ConformanceError("an import cycle with no packages was reported");
|
|
1049
|
+
return {
|
|
1050
|
+
code: "import-cycles/cycle",
|
|
1051
|
+
message: `import cycle between packages that import each other by a name that resolves to no file: ${[...ring.map((edge) => describePackage(edge.from)), describePackage(first.from)].join(" -> ")}`,
|
|
1052
|
+
file: relativePosix(context.cwd, join(root, first.file))
|
|
1053
|
+
};
|
|
1054
|
+
})].sort((a, b) => a.file.localeCompare(b.file) || a.message.localeCompare(b.message));
|
|
1055
|
+
};
|
|
1056
|
+
//#endregion
|
|
1057
|
+
//#region src/checks/import-isolated-groups.ts
|
|
1058
|
+
/**
|
|
1059
|
+
* A package imports a package in a group the layout isolates from its own.
|
|
1060
|
+
*/
|
|
1061
|
+
const importIsolatedGroups = async (context) => runImportCheck(context, {
|
|
1062
|
+
name: "import-isolated-groups",
|
|
1063
|
+
reason: "isolated-group",
|
|
1064
|
+
rules: (packages) => {
|
|
1065
|
+
if (context.layout.isolatedGroups === void 0) throw new ConformanceError("the 'import-isolated-groups' check is enabled but the layout has no 'isolatedGroups'");
|
|
1066
|
+
return isolatedGroupRules(packages, context.layout.isolatedGroups);
|
|
1067
|
+
},
|
|
1068
|
+
message: (edge) => `${describePackage(edge.from)} (group '${edge.from.group}') imports ${describeTarget(edge)} (group '${edge.to.group}'), which the layout isolates from it`
|
|
1069
|
+
});
|
|
1070
|
+
//#endregion
|
|
1071
|
+
//#region src/checks/import-rank-skip.ts
|
|
1072
|
+
/**
|
|
1073
|
+
* A package imports a package more than `rankSkip.maxDistance` ranks below it that is not in `rankSkip.exemptRanks`.
|
|
1074
|
+
*/
|
|
1075
|
+
const importRankSkip = async (context) => runImportCheck(context, {
|
|
1076
|
+
name: "import-rank-skip",
|
|
1077
|
+
reason: "skipped-rank",
|
|
1078
|
+
rules: (packages) => {
|
|
1079
|
+
if (context.layout.rankSkip === void 0) throw new ConformanceError("the 'import-rank-skip' check is enabled but the layout has no 'rankSkip'");
|
|
1080
|
+
return rankSkipRules(packages, context.layout.rankSkip);
|
|
1081
|
+
},
|
|
1082
|
+
message: (edge) => `${describePackage(edge.from)} (rank ${String(edge.from.rank)}) imports ${describeTarget(edge)} (rank ${String(edge.to.rank)}), further below than the layout allows`
|
|
1083
|
+
});
|
|
1084
|
+
//#endregion
|
|
1085
|
+
//#region src/checks/import-uphill.ts
|
|
1086
|
+
/**
|
|
1087
|
+
* A package imports a package of a strictly higher rank: dependencies run downhill only.
|
|
1088
|
+
*/
|
|
1089
|
+
const importUphill = async (context) => runImportCheck(context, {
|
|
1090
|
+
name: "import-uphill",
|
|
1091
|
+
reason: "higher-rank",
|
|
1092
|
+
rules: (packages) => uphillRules(packages),
|
|
1093
|
+
message: (edge) => `${describePackage(edge.from)} (rank ${String(edge.from.rank)}) imports ${describeTarget(edge)} (rank ${String(edge.to.rank)}), a higher rank`
|
|
1094
|
+
});
|
|
1095
|
+
async function git(cwd, args) {
|
|
1096
|
+
return new Promise((resolve, reject) => {
|
|
1097
|
+
execFile("git", [...args], {
|
|
1098
|
+
cwd,
|
|
1099
|
+
encoding: "utf8"
|
|
1100
|
+
}, (error, stdout) => {
|
|
1101
|
+
if (error === null) resolve(stdout);
|
|
1102
|
+
else reject(new ConformanceError(`git ${args.join(" ")} failed in ${cwd}; this check reads the git index, so it needs a git working tree`, { cause: error }));
|
|
1103
|
+
});
|
|
1104
|
+
});
|
|
1105
|
+
}
|
|
1106
|
+
/**
|
|
1107
|
+
* The index entry for `path` (relative to `cwd`), or `undefined` when git does not track it.
|
|
1108
|
+
*/
|
|
1109
|
+
async function indexEntry(cwd, path) {
|
|
1110
|
+
const listing = await git(cwd, [
|
|
1111
|
+
"ls-files",
|
|
1112
|
+
"--stage",
|
|
1113
|
+
"--",
|
|
1114
|
+
path
|
|
1115
|
+
]);
|
|
1116
|
+
const match = /^(\d+) ([0-9a-f]+) \d\t/u.exec(listing);
|
|
1117
|
+
const mode = match?.[1];
|
|
1118
|
+
const blob = match?.[2];
|
|
1119
|
+
return mode === void 0 || blob === void 0 ? void 0 : {
|
|
1120
|
+
mode,
|
|
1121
|
+
blob
|
|
1122
|
+
};
|
|
1123
|
+
}
|
|
1124
|
+
/**
|
|
1125
|
+
* The content of a blob. For a symbolic link it is the link's target.
|
|
1126
|
+
*/
|
|
1127
|
+
async function blobContent(cwd, blob) {
|
|
1128
|
+
return git(cwd, [
|
|
1129
|
+
"cat-file",
|
|
1130
|
+
"blob",
|
|
1131
|
+
blob
|
|
1132
|
+
]);
|
|
1133
|
+
}
|
|
1134
|
+
//#endregion
|
|
1135
|
+
//#region src/checks/instruction-symlinks.ts
|
|
1136
|
+
/**
|
|
1137
|
+
* The instruction file names checked when the options name none.
|
|
1138
|
+
*/
|
|
1139
|
+
const DEFAULT_INSTRUCTION_FILES = ["AGENTS.md", "CLAUDE.md"];
|
|
1140
|
+
async function directoriesOf(cwd, patterns) {
|
|
1141
|
+
const found = await Promise.all(patterns.map(async (pattern) => isDynamicPattern(pattern) ? findDirectories(cwd, [pattern]) : [posix.normalize(pattern)]));
|
|
1142
|
+
return [...new Set(found.flat())].sort();
|
|
1143
|
+
}
|
|
1144
|
+
/**
|
|
1145
|
+
* Whether the tracked symbolic link `file` (with index entry `entry`) leads to `destination`: its target, resolved against the directory of the link, is `destination`, or is another tracked symbolic link that leads there. A link back to a link already followed leads nowhere.
|
|
1146
|
+
*/
|
|
1147
|
+
async function leadsTo(cwd, file, entry, destination) {
|
|
1148
|
+
const followed = /* @__PURE__ */ new Set();
|
|
1149
|
+
let current = {
|
|
1150
|
+
file,
|
|
1151
|
+
entry
|
|
1152
|
+
};
|
|
1153
|
+
for (;;) {
|
|
1154
|
+
const linked = (await blobContent(cwd, current.entry.blob)).trim();
|
|
1155
|
+
if (posix.isAbsolute(linked)) return false;
|
|
1156
|
+
const resolved = posix.join(posix.dirname(current.file), linked);
|
|
1157
|
+
if (resolved === destination) return true;
|
|
1158
|
+
const next = followed.has(resolved) ? void 0 : await indexEntry(cwd, resolved);
|
|
1159
|
+
if (next?.mode !== "120000") return false;
|
|
1160
|
+
followed.add(resolved);
|
|
1161
|
+
current = {
|
|
1162
|
+
file: resolved,
|
|
1163
|
+
entry: next
|
|
1164
|
+
};
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
/**
|
|
1168
|
+
* Each agent instruction file is tracked by git as a symbolic link (mode 120000) whose target is the README beside it, so the two cannot diverge. The link may be written in any form that resolves there (`./README.md`), and may lead through other tracked links, as in `CLAUDE.md` to `AGENTS.md` to `README.md`. It reads the index, not the working tree, so a checkout with `core.symlinks` false, where a link is written out as a plain file, is judged by what is committed.
|
|
1169
|
+
*/
|
|
1170
|
+
const instructionSymlinks = async ({ cwd, options }) => {
|
|
1171
|
+
const files = options.files ?? DEFAULT_INSTRUCTION_FILES;
|
|
1172
|
+
const target = options.target ?? "README.md";
|
|
1173
|
+
const violations = [];
|
|
1174
|
+
for (const directory of await directoriesOf(cwd, options.directories ?? ["."])) {
|
|
1175
|
+
const readme = posix.join(directory, target);
|
|
1176
|
+
for (const name of files) {
|
|
1177
|
+
const file = posix.join(directory, name);
|
|
1178
|
+
const entry = await indexEntry(cwd, file);
|
|
1179
|
+
if (entry === void 0) violations.push({
|
|
1180
|
+
code: "instruction-symlinks/missing",
|
|
1181
|
+
message: `${file} is not tracked; commit it as a symbolic link to ${target}`,
|
|
1182
|
+
file
|
|
1183
|
+
});
|
|
1184
|
+
else if (entry.mode !== "120000") violations.push({
|
|
1185
|
+
code: "instruction-symlinks/not-a-symlink",
|
|
1186
|
+
message: `${file} is committed as a regular file (mode ${entry.mode}); commit it as a symbolic link to ${target}`,
|
|
1187
|
+
file
|
|
1188
|
+
});
|
|
1189
|
+
else if (!await leadsTo(cwd, file, entry, readme)) {
|
|
1190
|
+
const linked = (await blobContent(cwd, entry.blob)).trim();
|
|
1191
|
+
violations.push({
|
|
1192
|
+
code: "instruction-symlinks/wrong-target",
|
|
1193
|
+
message: `${file} links to ${linked}; it should link to ${target}`,
|
|
1194
|
+
file
|
|
1195
|
+
});
|
|
1196
|
+
} else if (await indexEntry(cwd, readme) === void 0) violations.push({
|
|
1197
|
+
code: "instruction-symlinks/dangling",
|
|
1198
|
+
message: `${file} links to ${target}, which is not tracked`,
|
|
1199
|
+
file
|
|
1200
|
+
});
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
return violations;
|
|
1204
|
+
};
|
|
1205
|
+
//#endregion
|
|
1206
|
+
//#region src/checks/single-storybook.ts
|
|
1207
|
+
const STORYBOOK_DIRECTORY = ".storybook";
|
|
1208
|
+
/**
|
|
1209
|
+
* At most one Storybook exists, in the permitted directory (the workspace root unless the options say otherwise), and none in a package such as a UI package. A Storybook is a `.storybook` directory.
|
|
1210
|
+
*/
|
|
1211
|
+
const singleStorybook = async ({ cwd, options }) => {
|
|
1212
|
+
const location = posix.normalize(options.location ?? ".");
|
|
1213
|
+
const permitted = posix.normalize(posix.join(location, STORYBOOK_DIRECTORY));
|
|
1214
|
+
return (await findDirectories(cwd, [`**/${STORYBOOK_DIRECTORY}`, ...excludedFrom(options.exclude)])).filter((directory) => directory !== permitted).map((directory) => ({
|
|
1215
|
+
code: "single-storybook/misplaced",
|
|
1216
|
+
message: `${directory} is a Storybook outside ${location === "." ? "the workspace root" : location}; keep the one Storybook there`,
|
|
1217
|
+
file: directory
|
|
1218
|
+
}));
|
|
1219
|
+
};
|
|
1220
|
+
//#endregion
|
|
1221
|
+
//#region src/config.ts
|
|
1222
|
+
const path = z.string().min(1);
|
|
1223
|
+
const paths = z.array(path).min(1);
|
|
1224
|
+
const names = z.array(z.string().min(1));
|
|
1225
|
+
function compiles(source) {
|
|
1226
|
+
try {
|
|
1227
|
+
new RegExp(source);
|
|
1228
|
+
return true;
|
|
1229
|
+
} catch {
|
|
1230
|
+
return false;
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
/**
|
|
1234
|
+
* Source text of a regular expression that dependency-cruiser compiles as written. It is checked here because dependency-cruiser reports one that does not compile as a slow pattern and quotes it.
|
|
1235
|
+
*/
|
|
1236
|
+
const pattern = z.string().min(1).refine(compiles, "not a valid regular expression");
|
|
1237
|
+
const patterns = z.array(pattern);
|
|
1238
|
+
const importGraph = z.strictObject({
|
|
1239
|
+
exclude: z.exactOptional(patterns),
|
|
1240
|
+
doNotFollow: z.exactOptional(patterns),
|
|
1241
|
+
tsConfig: z.exactOptional(path)
|
|
1242
|
+
});
|
|
1243
|
+
/**
|
|
1244
|
+
* A check's setting: `false`, or its options. A union reports only "Invalid input" for a setting that is wrong, so the issues of the options branch, which is the one the person meant unless they wrote `false`, are put in the message.
|
|
1245
|
+
*/
|
|
1246
|
+
const enabled = (options) => z.union([z.literal(false), options], { error: (issue) => {
|
|
1247
|
+
const [, optionsIssues] = issue.errors;
|
|
1248
|
+
if (optionsIssues === void 0) return;
|
|
1249
|
+
return `expected false or an options object: ${optionsIssues.map((found) => found.path.length === 0 ? found.message : `${found.path.map(String).join(".")}: ${found.message}`).join("; ")}`;
|
|
1250
|
+
} });
|
|
1251
|
+
const checks = z.strictObject({
|
|
1252
|
+
"aggregate-mappers": z.exactOptional(enabled(z.strictObject({
|
|
1253
|
+
contracts: paths,
|
|
1254
|
+
mapper: path,
|
|
1255
|
+
adapters: z.exactOptional(path),
|
|
1256
|
+
exclude: z.exactOptional(names),
|
|
1257
|
+
tsConfig: z.exactOptional(path)
|
|
1258
|
+
}))),
|
|
1259
|
+
"command-types": z.exactOptional(enabled(z.strictObject({
|
|
1260
|
+
commands: paths,
|
|
1261
|
+
exclude: z.exactOptional(names),
|
|
1262
|
+
inferences: z.exactOptional(paths),
|
|
1263
|
+
tsConfig: z.exactOptional(path)
|
|
1264
|
+
}))),
|
|
1265
|
+
"import-uphill": z.exactOptional(enabled(importGraph)),
|
|
1266
|
+
"import-rank-skip": z.exactOptional(enabled(importGraph)),
|
|
1267
|
+
"import-cross-slice": z.exactOptional(enabled(importGraph)),
|
|
1268
|
+
"import-isolated-groups": z.exactOptional(enabled(importGraph)),
|
|
1269
|
+
"import-cycles": z.exactOptional(enabled(importGraph)),
|
|
1270
|
+
"instruction-symlinks": z.exactOptional(enabled(z.strictObject({
|
|
1271
|
+
files: z.exactOptional(paths),
|
|
1272
|
+
directories: z.exactOptional(paths),
|
|
1273
|
+
target: z.exactOptional(path)
|
|
1274
|
+
}))),
|
|
1275
|
+
"single-storybook": z.exactOptional(enabled(z.strictObject({
|
|
1276
|
+
location: z.exactOptional(path),
|
|
1277
|
+
exclude: z.exactOptional(paths)
|
|
1278
|
+
}))),
|
|
1279
|
+
"commit-types": z.exactOptional(enabled(z.strictObject({
|
|
1280
|
+
commitlint: z.exactOptional(path),
|
|
1281
|
+
release: z.exactOptional(path)
|
|
1282
|
+
}))),
|
|
1283
|
+
"dockerfile-package-manager": z.exactOptional(enabled(z.strictObject({
|
|
1284
|
+
dockerfiles: z.exactOptional(paths),
|
|
1285
|
+
exclude: z.exactOptional(paths),
|
|
1286
|
+
packageJson: z.exactOptional(path)
|
|
1287
|
+
})))
|
|
1288
|
+
});
|
|
1289
|
+
/**
|
|
1290
|
+
* The Standard Schema for {@link ConformanceConfig}. It rejects unknown keys at every level, so a misspelt check name or option fails when the section loads.
|
|
1291
|
+
*/
|
|
1292
|
+
const conformanceSchema = z.strictObject({ checks });
|
|
1293
|
+
/**
|
|
1294
|
+
* The `conformance` section. Pass it to `withSections` beside `layoutSection` when authoring `exadev.config.ts`.
|
|
1295
|
+
*/
|
|
1296
|
+
const conformanceSection = defineSection("conformance", conformanceSchema);
|
|
1297
|
+
//#endregion
|
|
1298
|
+
//#region src/registry.ts
|
|
1299
|
+
/**
|
|
1300
|
+
* The options of an enabled check, or `undefined` when the setting is absent or `false`.
|
|
1301
|
+
*/
|
|
1302
|
+
function enabledOptions(setting) {
|
|
1303
|
+
return setting === false ? void 0 : setting;
|
|
1304
|
+
}
|
|
1305
|
+
function plain(spec) {
|
|
1306
|
+
return {
|
|
1307
|
+
name: spec.name,
|
|
1308
|
+
description: spec.description,
|
|
1309
|
+
requiresLayout: false,
|
|
1310
|
+
isEnabled: (checks) => spec.select(checks) !== void 0,
|
|
1311
|
+
run: async ({ cwd, checks, configFiles }) => {
|
|
1312
|
+
const options = spec.select(checks);
|
|
1313
|
+
if (options === void 0) throw new ConformanceError(`the check '${spec.name}' is not enabled`);
|
|
1314
|
+
return spec.check({
|
|
1315
|
+
cwd,
|
|
1316
|
+
options,
|
|
1317
|
+
...configFiles === void 0 ? {} : { configFiles }
|
|
1318
|
+
});
|
|
1319
|
+
}
|
|
1320
|
+
};
|
|
1321
|
+
}
|
|
1322
|
+
function withLayout(spec) {
|
|
1323
|
+
return {
|
|
1324
|
+
name: spec.name,
|
|
1325
|
+
description: spec.description,
|
|
1326
|
+
requiresLayout: true,
|
|
1327
|
+
isEnabled: (checks) => spec.select(checks) !== void 0,
|
|
1328
|
+
run: async ({ cwd, checks, layout }) => {
|
|
1329
|
+
const options = spec.select(checks);
|
|
1330
|
+
if (options === void 0) throw new ConformanceError(`the check '${spec.name}' is not enabled`);
|
|
1331
|
+
if (layout === void 0) throw new ConformanceError(`the check '${spec.name}' reads the workspace layout, which was not supplied`);
|
|
1332
|
+
return spec.check({
|
|
1333
|
+
cwd,
|
|
1334
|
+
options,
|
|
1335
|
+
layout
|
|
1336
|
+
});
|
|
1337
|
+
}
|
|
1338
|
+
};
|
|
1339
|
+
}
|
|
1340
|
+
/**
|
|
1341
|
+
* Every check, by name, in the order they run. The key is the name used in the `checks` map, on the command line and in violation codes.
|
|
1342
|
+
*/
|
|
1343
|
+
const registry = {
|
|
1344
|
+
"aggregate-mappers": plain({
|
|
1345
|
+
name: "aggregate-mappers",
|
|
1346
|
+
description: "Every exported aggregate type in a contract file has a mapper file where the template puts it",
|
|
1347
|
+
select: (checks) => enabledOptions(checks["aggregate-mappers"]),
|
|
1348
|
+
check: aggregateMappers
|
|
1349
|
+
}),
|
|
1350
|
+
"command-types": plain({
|
|
1351
|
+
name: "command-types",
|
|
1352
|
+
description: "Every exported command type is derived from a schema, not hand-written",
|
|
1353
|
+
select: (checks) => enabledOptions(checks["command-types"]),
|
|
1354
|
+
check: commandTypes
|
|
1355
|
+
}),
|
|
1356
|
+
"import-uphill": withLayout({
|
|
1357
|
+
name: "import-uphill",
|
|
1358
|
+
description: "No package imports a package of a higher rank",
|
|
1359
|
+
select: (checks) => enabledOptions(checks["import-uphill"]),
|
|
1360
|
+
check: importUphill
|
|
1361
|
+
}),
|
|
1362
|
+
"import-rank-skip": withLayout({
|
|
1363
|
+
name: "import-rank-skip",
|
|
1364
|
+
description: "No package imports further below itself than the layout allows",
|
|
1365
|
+
select: (checks) => enabledOptions(checks["import-rank-skip"]),
|
|
1366
|
+
check: importRankSkip
|
|
1367
|
+
}),
|
|
1368
|
+
"import-cross-slice": withLayout({
|
|
1369
|
+
name: "import-cross-slice",
|
|
1370
|
+
description: "No package imports a package in a different slice",
|
|
1371
|
+
select: (checks) => enabledOptions(checks["import-cross-slice"]),
|
|
1372
|
+
check: importCrossSlice
|
|
1373
|
+
}),
|
|
1374
|
+
"import-isolated-groups": withLayout({
|
|
1375
|
+
name: "import-isolated-groups",
|
|
1376
|
+
description: "No package imports a package in a group the layout isolates from its own",
|
|
1377
|
+
select: (checks) => enabledOptions(checks["import-isolated-groups"]),
|
|
1378
|
+
check: importIsolatedGroups
|
|
1379
|
+
}),
|
|
1380
|
+
"import-cycles": withLayout({
|
|
1381
|
+
name: "import-cycles",
|
|
1382
|
+
description: "No files of the workspace packages import each other in a cycle",
|
|
1383
|
+
select: (checks) => enabledOptions(checks["import-cycles"]),
|
|
1384
|
+
check: importCycles
|
|
1385
|
+
}),
|
|
1386
|
+
"instruction-symlinks": plain({
|
|
1387
|
+
name: "instruction-symlinks",
|
|
1388
|
+
description: "Agent instruction files are git symlinks to the README beside them",
|
|
1389
|
+
select: (checks) => enabledOptions(checks["instruction-symlinks"]),
|
|
1390
|
+
check: instructionSymlinks
|
|
1391
|
+
}),
|
|
1392
|
+
"single-storybook": plain({
|
|
1393
|
+
name: "single-storybook",
|
|
1394
|
+
description: "At most one Storybook exists, at the workspace root, and none inside a package",
|
|
1395
|
+
select: (checks) => enabledOptions(checks["single-storybook"]),
|
|
1396
|
+
check: singleStorybook
|
|
1397
|
+
}),
|
|
1398
|
+
"commit-types": plain({
|
|
1399
|
+
name: "commit-types",
|
|
1400
|
+
description: "The commit types commitlint accepts match the release rules and changelog sections",
|
|
1401
|
+
select: (checks) => enabledOptions(checks["commit-types"]),
|
|
1402
|
+
check: commitTypes
|
|
1403
|
+
}),
|
|
1404
|
+
"dockerfile-package-manager": plain({
|
|
1405
|
+
name: "dockerfile-package-manager",
|
|
1406
|
+
description: "A package manager version pinned in a Dockerfile is the version packageManager names",
|
|
1407
|
+
select: (checks) => enabledOptions(checks["dockerfile-package-manager"]),
|
|
1408
|
+
check: dockerfilePackageManager
|
|
1409
|
+
})
|
|
1410
|
+
};
|
|
1411
|
+
/**
|
|
1412
|
+
* The names of all checks, in the order they run.
|
|
1413
|
+
*/
|
|
1414
|
+
const checkNames = Object.keys(registry).filter(isCheckName);
|
|
1415
|
+
/**
|
|
1416
|
+
* Whether `name` is the name of a check.
|
|
1417
|
+
*/
|
|
1418
|
+
function isCheckName(name) {
|
|
1419
|
+
return Object.hasOwn(registry, name);
|
|
1420
|
+
}
|
|
1421
|
+
//#endregion
|
|
1422
|
+
//#region src/run-checks.ts
|
|
1423
|
+
/**
|
|
1424
|
+
* The process exit codes of the command line: nothing found, something found, or the checks could not run.
|
|
1425
|
+
*/
|
|
1426
|
+
const EXIT_CODES = {
|
|
1427
|
+
clean: 0,
|
|
1428
|
+
violations: 1,
|
|
1429
|
+
failed: 2
|
|
1430
|
+
};
|
|
1431
|
+
async function conformanceOf(options) {
|
|
1432
|
+
if (options.config !== void 0) return validateStandard(conformanceSection.schema, options.config, `'${conformanceSection.name}' section passed to runChecks`);
|
|
1433
|
+
const loaded = await loadSection(conformanceSection, {
|
|
1434
|
+
...options.configFiles,
|
|
1435
|
+
cwd: options.cwd
|
|
1436
|
+
});
|
|
1437
|
+
if (loaded === void 0) throw new ConformanceError(`no 'conformance' section in ${options.cwd}; define it in exadev.config.ts or in exadev.conformance.config.ts`);
|
|
1438
|
+
return loaded;
|
|
1439
|
+
}
|
|
1440
|
+
async function layoutOf(options, needed) {
|
|
1441
|
+
if (options.layout !== void 0) return validateStandard(layoutSection.schema, options.layout, `'${layoutSection.name}' section passed to runChecks`);
|
|
1442
|
+
if (needed.length === 0) return;
|
|
1443
|
+
const loaded = await loadSection(layoutSection, {
|
|
1444
|
+
...options.configFiles,
|
|
1445
|
+
cwd: options.cwd
|
|
1446
|
+
});
|
|
1447
|
+
if (loaded === void 0) throw new ConformanceError(`${needed.join(", ")} read the workspace layout, and there is no 'layout' section in ${options.cwd}; define it in exadev.config.ts or in exadev.layout.config.ts`);
|
|
1448
|
+
return loaded;
|
|
1449
|
+
}
|
|
1450
|
+
function selected(config, requested) {
|
|
1451
|
+
const enabled = checkNames.filter((name) => registry[name].isEnabled(config.checks));
|
|
1452
|
+
if (requested === void 0) {
|
|
1453
|
+
if (enabled.length === 0) throw new ConformanceError("no check is enabled; add one to the 'checks' of the 'conformance' section");
|
|
1454
|
+
return enabled;
|
|
1455
|
+
}
|
|
1456
|
+
const disabled = requested.filter((name) => !enabled.includes(name));
|
|
1457
|
+
if (disabled.length > 0) throw new ConformanceError(`${disabled.join(", ")} ${disabled.length === 1 ? "is" : "are"} not enabled in the 'conformance' section`);
|
|
1458
|
+
return checkNames.filter((name) => requested.includes(name));
|
|
1459
|
+
}
|
|
1460
|
+
/**
|
|
1461
|
+
* Run the enabled checks, or the enabled ones among `checks`, and return what they found. Sections not supplied in the options are loaded from `cwd` with `@exadev/config`.
|
|
1462
|
+
*
|
|
1463
|
+
* Violations do not throw. It throws `ConformanceError` when the checks cannot run (nothing enabled, a requested check disabled, a section missing) and `ConfigValidationError` when a section fails its schema, whether it was loaded from `cwd` or passed in.
|
|
1464
|
+
*/
|
|
1465
|
+
async function runChecks(options) {
|
|
1466
|
+
const config = await conformanceOf(options);
|
|
1467
|
+
const names = selected(config, options.checks);
|
|
1468
|
+
const layout = await layoutOf(options, names.filter((name) => registry[name].requiresLayout));
|
|
1469
|
+
const results = [];
|
|
1470
|
+
for (const name of names) results.push({
|
|
1471
|
+
check: name,
|
|
1472
|
+
violations: await registry[name].run({
|
|
1473
|
+
cwd: options.cwd,
|
|
1474
|
+
checks: config.checks,
|
|
1475
|
+
layout,
|
|
1476
|
+
configFiles: options.configFiles
|
|
1477
|
+
})
|
|
1478
|
+
});
|
|
1479
|
+
const violations = results.flatMap((result) => result.violations);
|
|
1480
|
+
return {
|
|
1481
|
+
results,
|
|
1482
|
+
violations,
|
|
1483
|
+
exitCode: violations.length === 0 ? EXIT_CODES.clean : EXIT_CODES.violations
|
|
1484
|
+
};
|
|
1485
|
+
}
|
|
1486
|
+
//#endregion
|
|
1487
|
+
//#region src/type-level.ts
|
|
1488
|
+
/**
|
|
1489
|
+
* Raised when a handler map is not exhaustive as claimed, or when the map cannot be found.
|
|
1490
|
+
*/
|
|
1491
|
+
var ExhaustivenessError = class extends Error {
|
|
1492
|
+
constructor(message) {
|
|
1493
|
+
super(message);
|
|
1494
|
+
this.name = "ExhaustivenessError";
|
|
1495
|
+
}
|
|
1496
|
+
};
|
|
1497
|
+
/**
|
|
1498
|
+
* Compiler options for the in-memory programs: strict, ESM, no libraries beyond the language's own, and no ambient types. Only what a self-contained snippet needs.
|
|
1499
|
+
*/
|
|
1500
|
+
const SNIPPET_COMPILER_OPTIONS = {
|
|
1501
|
+
strict: true,
|
|
1502
|
+
noEmit: true,
|
|
1503
|
+
target: ScriptTarget.ES2022,
|
|
1504
|
+
module: ModuleKind.ESNext,
|
|
1505
|
+
moduleResolution: ModuleResolutionKind.Bundler,
|
|
1506
|
+
types: []
|
|
1507
|
+
};
|
|
1508
|
+
function inMemoryProject(files, options) {
|
|
1509
|
+
const project = new Project({
|
|
1510
|
+
useInMemoryFileSystem: true,
|
|
1511
|
+
compilerOptions: {
|
|
1512
|
+
...SNIPPET_COMPILER_OPTIONS,
|
|
1513
|
+
...options
|
|
1514
|
+
}
|
|
1515
|
+
});
|
|
1516
|
+
for (const [name, source] of Object.entries(files)) project.createSourceFile(name, source);
|
|
1517
|
+
return project;
|
|
1518
|
+
}
|
|
1519
|
+
/**
|
|
1520
|
+
* Compile the files in memory and return every error the type checker reports. Names are paths in the in-memory file system, so one file can import another with a relative path.
|
|
1521
|
+
*/
|
|
1522
|
+
function typeErrors(files, options = {}) {
|
|
1523
|
+
return inMemoryProject(files, options).getPreEmitDiagnostics().map((diagnostic) => ({
|
|
1524
|
+
file: diagnostic.getSourceFile()?.getBaseName() ?? "",
|
|
1525
|
+
line: diagnostic.getLineNumber() ?? 0,
|
|
1526
|
+
code: diagnostic.getCode(),
|
|
1527
|
+
message: ts.flattenDiagnosticMessageText(diagnostic.compilerObject.messageText, "\n")
|
|
1528
|
+
}));
|
|
1529
|
+
}
|
|
1530
|
+
function handlerMap(source, map) {
|
|
1531
|
+
let initializer = source.getVariableDeclaration(map)?.getInitializer();
|
|
1532
|
+
while (initializer !== void 0 && (Node.isSatisfiesExpression(initializer) || Node.isAsExpression(initializer) || Node.isParenthesizedExpression(initializer))) initializer = initializer.getExpression();
|
|
1533
|
+
if (initializer === void 0 || !Node.isObjectLiteralExpression(initializer)) throw new ExhaustivenessError(`${source.getBaseName()} has no variable '${map}' initialised with an object literal`);
|
|
1534
|
+
return initializer;
|
|
1535
|
+
}
|
|
1536
|
+
/**
|
|
1537
|
+
* The names of the cases of the handler map `map`, an object literal assigned to a variable of that name in `source`.
|
|
1538
|
+
*/
|
|
1539
|
+
function handlerCases(source, map) {
|
|
1540
|
+
return handlerMap(inMemoryProject({ "cases.ts": source }, {}).getSourceFileOrThrow("cases.ts"), map).getProperties().flatMap((property) => Node.isPropertyNamed(property) ? [property.getName()] : []);
|
|
1541
|
+
}
|
|
1542
|
+
/**
|
|
1543
|
+
* `source` with the case `key` deleted from the handler map `map`.
|
|
1544
|
+
*/
|
|
1545
|
+
function removeCase(source, map, key) {
|
|
1546
|
+
const file = inMemoryProject({ "cases.ts": source }, {}).getSourceFileOrThrow("cases.ts");
|
|
1547
|
+
const property = handlerMap(file, map).getProperty(key);
|
|
1548
|
+
if (property === void 0) throw new ExhaustivenessError(`the handler map '${map}' has no case '${key}'`);
|
|
1549
|
+
property.remove();
|
|
1550
|
+
return file.getFullText();
|
|
1551
|
+
}
|
|
1552
|
+
/**
|
|
1553
|
+
* Prove that the handler map `map` in `files[file]` is checked for exhaustiveness by the compiler: the files type-check as written, and deleting any one case (each in `cases`, else every case of the map) makes them fail. Throws {@link ExhaustivenessError} otherwise.
|
|
1554
|
+
*
|
|
1555
|
+
* Write the handler map's type so the compiler demands every case, for example a mapped type over the union of command types, and call this from an ordinary test:
|
|
1556
|
+
*
|
|
1557
|
+
* ```ts
|
|
1558
|
+
* proveExhaustive({ files: { 'handlers.ts': source }, file: 'handlers.ts', map: 'handlers' });
|
|
1559
|
+
* ```
|
|
1560
|
+
*/
|
|
1561
|
+
function proveExhaustive(input) {
|
|
1562
|
+
const { files, file, map, compilerOptions = {} } = input;
|
|
1563
|
+
const source = files[file];
|
|
1564
|
+
if (source === void 0) throw new ExhaustivenessError(`no file '${file}' among ${Object.keys(files).join(", ")}`);
|
|
1565
|
+
const baseline = typeErrors(files, compilerOptions);
|
|
1566
|
+
if (baseline.length > 0) throw new ExhaustivenessError(`the files do not type-check as written: ${baseline.map((error) => `${error.file}:${String(error.line)} TS${String(error.code)} ${error.message}`).join("; ")}`);
|
|
1567
|
+
return { removals: (input.cases ?? handlerCases(source, map)).map((name) => {
|
|
1568
|
+
const errors = typeErrors({
|
|
1569
|
+
...files,
|
|
1570
|
+
[file]: removeCase(source, map, name)
|
|
1571
|
+
}, compilerOptions);
|
|
1572
|
+
if (errors.length === 0) throw new ExhaustivenessError(`removing the case '${name}' from '${map}' still type-checks, so the map is not checked for exhaustiveness`);
|
|
1573
|
+
return {
|
|
1574
|
+
case: name,
|
|
1575
|
+
errors
|
|
1576
|
+
};
|
|
1577
|
+
}) };
|
|
1578
|
+
}
|
|
1579
|
+
//#endregion
|
|
1580
|
+
export { ConformanceError, DEFAULT_INFERENCES, EXIT_CODES, ExhaustivenessError, PRESET_COMMIT_TYPES, aggregateMappers, checkNames, classifyPackages, commandTypes, commitTypes, conformanceSchema, conformanceSection, crossSliceRules, cycleRule, dockerfilePackageManager, handlerCases, importCrossSlice, importCycles, importIsolatedGroups, importRankSkip, importUphill, instructionSymlinks, isCheckName, isolatedGroupRules, packagesPattern, proveExhaustive, rankSkipRules, registry, removeCase, runChecks, singleStorybook, typeErrors, uphillRules };
|