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