@hussmartinez/sdk-config 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +114 -2
- package/biome/base.jsonc +59 -0
- package/package.json +94 -3
- package/src/commitlint/index.js +65 -0
- package/src/dependency-cruiser/cli.js +57 -0
- package/src/dependency-cruiser/index.js +171 -0
- package/src/dependency-cruiser/lint-deps.js +330 -0
- package/src/lint-staged/index.js +19 -0
- package/src/secretlint/index.js +21 -0
- package/src/stylelint/index.js +69 -0
- package/src/stylelint/plugins/adr.js +11 -0
- package/src/stylelint/plugins/layer-name-pattern.js +96 -0
- package/src/stylelint/plugins/named-colors.js +157 -0
- package/src/stylelint/plugins/no-literal-values.js +249 -0
- package/src/stylelint/plugins/selector-scope.js +190 -0
- package/tsconfig/base.json +28 -0
- package/tsconfig/browser.json +7 -0
- package/tsconfig/node.json +7 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Matches a layer folder in a single-package repository (`src/core/model/…`)
|
|
3
|
+
* and in a workspace package (`packages/sdk-core/src/core/model/…`).
|
|
4
|
+
*
|
|
5
|
+
* @param {string} folder Path below `src/`, such as `core/model`.
|
|
6
|
+
* @returns {string}
|
|
7
|
+
*/
|
|
8
|
+
const layer = (folder) => `(?:^|/)src/${folder}/`;
|
|
9
|
+
|
|
10
|
+
const TEST_FILE = "\\.test\\.[cm]?[jt]sx?$";
|
|
11
|
+
/**
|
|
12
|
+
* Matches an npm package, whether dependency-cruiser resolved it into
|
|
13
|
+
* `node_modules` or left it as a bare name because it isn't installed.
|
|
14
|
+
*
|
|
15
|
+
* @param {string} names Alternation of package names, such as `react|react-dom`.
|
|
16
|
+
* @returns {string}
|
|
17
|
+
*/
|
|
18
|
+
const npmPackage = (names) => `(?:^|/node_modules/)(?:${names})(?:/|$)`;
|
|
19
|
+
|
|
20
|
+
const UI_PACKAGES = npmPackage("react|react-dom|@hussmartinez/ui");
|
|
21
|
+
const SDK_CORE = npmPackage("@hussmartinez/sdk-core");
|
|
22
|
+
|
|
23
|
+
/** Layers outside the core, which the core never imports. */
|
|
24
|
+
const OUTER_LAYERS = ["adapters", "features", "react", "element", "workers"];
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Forbidden edges between the layers of ADR 0008, as clarified by ADR 0017.
|
|
28
|
+
* Each rule names a layer and the layers it must never import; anything not
|
|
29
|
+
* listed is allowed. The inner core rules only cover edges within the core,
|
|
30
|
+
* because `layer-core` already keeps the whole core away from the outer layers.
|
|
31
|
+
*
|
|
32
|
+
* @type {Array<{ name: string, from: string, notTo: string[], comment: string }>}
|
|
33
|
+
*/
|
|
34
|
+
const LAYER_RULES = [
|
|
35
|
+
{
|
|
36
|
+
name: "layer-model",
|
|
37
|
+
from: layer("core/model"),
|
|
38
|
+
notTo: ["core/operations", "core/ports", "core/session"],
|
|
39
|
+
comment: "core/model holds pure domain types and imports no other layer.",
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: "layer-operations",
|
|
43
|
+
from: layer("core/operations"),
|
|
44
|
+
notTo: ["core/ports", "core/session"],
|
|
45
|
+
comment: "core/operations may import only core/model and core/errors.",
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
name: "layer-ports",
|
|
49
|
+
from: layer("core/ports"),
|
|
50
|
+
notTo: ["core/operations", "core/session"],
|
|
51
|
+
comment: "core/ports may import only core/model and core/errors.",
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
name: "layer-core",
|
|
55
|
+
from: layer("core"),
|
|
56
|
+
notTo: OUTER_LAYERS,
|
|
57
|
+
comment: "The core never depends on adapters, features or UI layers.",
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
name: "layer-adapters",
|
|
61
|
+
from: layer("adapters"),
|
|
62
|
+
notTo: ["core/operations", "core/session", "features", "react", "element"],
|
|
63
|
+
comment: "Adapters implement ports and may import only ports, model, errors and sdk-core.",
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
name: "layer-features",
|
|
67
|
+
from: layer("features"),
|
|
68
|
+
notTo: ["adapters", "element", "workers"],
|
|
69
|
+
comment: "Features build on the core and ui, never on a specific adapter.",
|
|
70
|
+
},
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* dependency-cruiser preset for the SDK family. It enforces the layer rules of
|
|
75
|
+
* ADR 0008, as clarified by ADR 0017, and keeps production code away from
|
|
76
|
+
* tests, cycles and undeclared packages. Paths work both in `<kind>-sdk`
|
|
77
|
+
* repositories and in workspaces.
|
|
78
|
+
*
|
|
79
|
+
* @type {import("dependency-cruiser").IConfiguration}
|
|
80
|
+
*/
|
|
81
|
+
const config = {
|
|
82
|
+
forbidden: [
|
|
83
|
+
...LAYER_RULES.map(({ name, from, notTo, comment }) => ({
|
|
84
|
+
name,
|
|
85
|
+
comment,
|
|
86
|
+
severity: /** @type {const} */ ("error"),
|
|
87
|
+
from: { path: from },
|
|
88
|
+
to: { path: notTo.map(layer) },
|
|
89
|
+
})),
|
|
90
|
+
{
|
|
91
|
+
name: "layer-errors",
|
|
92
|
+
comment: "core/errors is a leaf that imports only core/model and sdk-core (ADR 0017).",
|
|
93
|
+
severity: "error",
|
|
94
|
+
from: { path: layer("core/errors") },
|
|
95
|
+
to: {
|
|
96
|
+
pathNot: [
|
|
97
|
+
layer("core/errors"),
|
|
98
|
+
layer("core/model"),
|
|
99
|
+
SDK_CORE,
|
|
100
|
+
// Already reported by layer-core and core-no-ui.
|
|
101
|
+
...OUTER_LAYERS.map(layer),
|
|
102
|
+
UI_PACKAGES,
|
|
103
|
+
],
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
name: "core-no-ui",
|
|
108
|
+
comment: "The core is headless and server-safe, so no React or ui (ADR 0017).",
|
|
109
|
+
severity: "error",
|
|
110
|
+
from: { path: layer("core") },
|
|
111
|
+
to: { path: UI_PACKAGES },
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
name: "no-circular",
|
|
115
|
+
comment: "Cycles make load order fragile and layers impossible to separate.",
|
|
116
|
+
severity: "error",
|
|
117
|
+
from: {},
|
|
118
|
+
to: { circular: true },
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
name: "not-to-unresolvable",
|
|
122
|
+
comment: "Every import must resolve to a file or an installed package.",
|
|
123
|
+
severity: "error",
|
|
124
|
+
from: {},
|
|
125
|
+
to: { couldNotResolve: true },
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
name: "no-non-package-json",
|
|
129
|
+
comment: "Every package used must be declared in the closest package.json.",
|
|
130
|
+
severity: "error",
|
|
131
|
+
from: {},
|
|
132
|
+
to: { dependencyTypes: ["npm-no-pkg", "npm-unknown"] },
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
name: "not-to-dev-dep",
|
|
136
|
+
comment: "Published code must not need a devDependency at runtime.",
|
|
137
|
+
severity: "error",
|
|
138
|
+
from: { path: "(?:^|/)src/", pathNot: TEST_FILE },
|
|
139
|
+
to: {
|
|
140
|
+
dependencyTypes: ["npm-dev"],
|
|
141
|
+
dependencyTypesNot: ["npm-peer", "type-only"],
|
|
142
|
+
pathNot: "(?:^|/)node_modules/@types/",
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
name: "not-to-test",
|
|
147
|
+
comment: "Production code must not import tests.",
|
|
148
|
+
severity: "error",
|
|
149
|
+
from: { pathNot: TEST_FILE },
|
|
150
|
+
to: { path: TEST_FILE },
|
|
151
|
+
},
|
|
152
|
+
],
|
|
153
|
+
options: {
|
|
154
|
+
doNotFollow: { path: "(?:^|/)node_modules/" },
|
|
155
|
+
// Build output (dist, and temp for tsc declarations and api-extractor)
|
|
156
|
+
// and coverage aren't source. Fixtures break the rules on purpose.
|
|
157
|
+
exclude: { path: "(?:^|/)(?:coverage|dist|fixtures|temp)/" },
|
|
158
|
+
tsPreCompilationDeps: true,
|
|
159
|
+
combinedDependencies: false,
|
|
160
|
+
enhancedResolveOptions: {
|
|
161
|
+
exportsFields: ["exports"],
|
|
162
|
+
conditionNames: ["types", "import", "node", "default"],
|
|
163
|
+
extensions: [".ts", ".tsx", ".mts", ".js", ".jsx", ".mjs", ".json"],
|
|
164
|
+
},
|
|
165
|
+
reporterOptions: {
|
|
166
|
+
text: { highlightFocused: true },
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
export default config;
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { allExtensions, cruise, format, getAvailableTranspilers } from "dependency-cruiser";
|
|
4
|
+
import extractDepcruiseConfig from "dependency-cruiser/config-utl/extract-depcruise-config";
|
|
5
|
+
import extractTSConfig from "dependency-cruiser/config-utl/extract-ts-config";
|
|
6
|
+
|
|
7
|
+
/** Exit code when the cruise is clean and covers every source file. */
|
|
8
|
+
export const EXIT_OK = 0;
|
|
9
|
+
/** Exit code for rule violations or source files the cruise didn't analyse. */
|
|
10
|
+
export const EXIT_FAILED = 1;
|
|
11
|
+
/** Exit code for usage and configuration errors. */
|
|
12
|
+
export const EXIT_USAGE = 2;
|
|
13
|
+
|
|
14
|
+
const MAX_LISTED_FILES = 20;
|
|
15
|
+
const DECLARATION_FILE = /\.d\.[cm]?ts$/;
|
|
16
|
+
const TYPESCRIPT_EXTENSION = /^\.[cm]?tsx?$/;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* An error the user can fix, reported as a message without a stack trace.
|
|
20
|
+
*/
|
|
21
|
+
export class UsageError extends Error {
|
|
22
|
+
/** @override */
|
|
23
|
+
name = "UsageError";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @typedef {object} LintDepsOptions
|
|
28
|
+
* @property {string[]} paths Files and folders to cruise, relative to `cwd`.
|
|
29
|
+
* @property {string} [cwd] Working directory. Defaults to `process.cwd()`.
|
|
30
|
+
* @property {string} [configFile] dependency-cruiser configuration file.
|
|
31
|
+
* Defaults to `.dependency-cruiser.js`.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @typedef {object} LintDepsResult
|
|
36
|
+
* @property {number} exitCode {@link EXIT_OK}, {@link EXIT_FAILED} or {@link EXIT_USAGE}.
|
|
37
|
+
* @property {string} report dependency-cruiser's error report, for stdout.
|
|
38
|
+
* @property {string} problems Coverage and usage problems, for stderr.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Runs dependency-cruiser once with the repository's configuration, and also
|
|
43
|
+
* fails when the cruise didn't analyse a tracked source file. A missing parser
|
|
44
|
+
* makes dependency-cruiser skip files silently: without TypeScript, every
|
|
45
|
+
* `.ts` file is left out, or kept as a module with no dependencies when a
|
|
46
|
+
* JavaScript file imports it, and the run still passes.
|
|
47
|
+
*
|
|
48
|
+
* The expected files are those tracked by git under `paths` whose extension
|
|
49
|
+
* the configuration resolves (`enhancedResolveOptions.extensions`) and
|
|
50
|
+
* dependency-cruiser can parse in principle, minus declaration files and the
|
|
51
|
+
* configuration's `exclude`, `doNotFollow` and `includeOnly` filters.
|
|
52
|
+
*
|
|
53
|
+
* @param {LintDepsOptions} options
|
|
54
|
+
* @returns {Promise<LintDepsResult>}
|
|
55
|
+
*/
|
|
56
|
+
export async function lintDeps({
|
|
57
|
+
paths,
|
|
58
|
+
cwd = process.cwd(),
|
|
59
|
+
configFile = ".dependency-cruiser.js",
|
|
60
|
+
}) {
|
|
61
|
+
try {
|
|
62
|
+
if (paths.length === 0) {
|
|
63
|
+
throw new UsageError("Pass at least one file or folder to check.");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const config = await loadConfig(resolve(cwd, configFile), cwd);
|
|
67
|
+
const { options = {}, ...ruleSet } = config;
|
|
68
|
+
const expected = expectedSourceFiles(listTrackedFiles(paths, cwd), options);
|
|
69
|
+
if (expected.length === 0) {
|
|
70
|
+
throw new UsageError(
|
|
71
|
+
`git tracks no source files under ${paths.join(", ")}, so there is nothing to check.`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const result = await runCruise(paths, cwd, options, ruleSet);
|
|
76
|
+
const { output: report, exitCode: violations } = await format(result, { outputType: "err" });
|
|
77
|
+
|
|
78
|
+
const missing = unanalysedFiles(expected, result);
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
exitCode: violations > 0 || missing.length > 0 ? EXIT_FAILED : EXIT_OK,
|
|
82
|
+
report: typeof report === "string" ? report : "",
|
|
83
|
+
problems: missing.length > 0 ? describeMissing(missing) : "",
|
|
84
|
+
};
|
|
85
|
+
} catch (error) {
|
|
86
|
+
const message =
|
|
87
|
+
error instanceof UsageError
|
|
88
|
+
? error.message
|
|
89
|
+
: `dependency-cruiser failed: ${errorText(error)}`;
|
|
90
|
+
return { exitCode: EXIT_USAGE, report: "", problems: `hm-lint-deps: ${message}\n` };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* @param {string} configPath
|
|
96
|
+
* @param {string} cwd
|
|
97
|
+
* @returns {Promise<import("dependency-cruiser").IConfiguration>}
|
|
98
|
+
*/
|
|
99
|
+
async function loadConfig(configPath, cwd) {
|
|
100
|
+
/** @type {import("dependency-cruiser").IConfiguration} */
|
|
101
|
+
let config;
|
|
102
|
+
try {
|
|
103
|
+
config = await extractDepcruiseConfig(configPath, new Set(), cwd);
|
|
104
|
+
} catch (error) {
|
|
105
|
+
throw new UsageError(`Can't load the configuration ${configPath}: ${errorText(error)}`);
|
|
106
|
+
}
|
|
107
|
+
for (const option of /** @type {const} */ (["webpackConfig", "babelConfig"])) {
|
|
108
|
+
if (config.options?.[option]) {
|
|
109
|
+
throw new UsageError(`The ${option} option isn't supported. Use depcruise directly.`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return config;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Lists tracked files, with `/` separators and relative to `cwd`.
|
|
117
|
+
*
|
|
118
|
+
* @param {string[]} paths
|
|
119
|
+
* @param {string} cwd
|
|
120
|
+
* @returns {string[]}
|
|
121
|
+
*/
|
|
122
|
+
function listTrackedFiles(paths, cwd) {
|
|
123
|
+
assertInsideWorkTree(cwd);
|
|
124
|
+
const output = git(["ls-files", "-z", "--", ...paths], cwd);
|
|
125
|
+
return output.split("\0").filter(Boolean);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Fails with a dedicated message only when `cwd` really isn't in a work tree.
|
|
130
|
+
* Any other git problem is reported in git's own words.
|
|
131
|
+
*
|
|
132
|
+
* @param {string} cwd
|
|
133
|
+
*/
|
|
134
|
+
function assertInsideWorkTree(cwd) {
|
|
135
|
+
/** @type {string} */
|
|
136
|
+
let inside;
|
|
137
|
+
try {
|
|
138
|
+
inside = git(["rev-parse", "--is-inside-work-tree"], cwd).trim();
|
|
139
|
+
} catch (error) {
|
|
140
|
+
if (!(error instanceof GitError && /not a git repository/i.test(error.message))) {
|
|
141
|
+
throw error;
|
|
142
|
+
}
|
|
143
|
+
inside = "false";
|
|
144
|
+
}
|
|
145
|
+
if (inside !== "true") {
|
|
146
|
+
throw new UsageError(
|
|
147
|
+
`${cwd} isn't inside a git work tree. hm-lint-deps compares the cruise with the files git tracks.`,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* A git command failed. The message is git's first line of error output.
|
|
154
|
+
*/
|
|
155
|
+
class GitError extends UsageError {
|
|
156
|
+
/** @override */
|
|
157
|
+
name = "GitError";
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Runs git and returns its output, or throws a {@link GitError} with git's own
|
|
162
|
+
* first error line, such as `git ls-files failed: … is outside repository`.
|
|
163
|
+
*
|
|
164
|
+
* @param {string[]} args
|
|
165
|
+
* @param {string} cwd
|
|
166
|
+
* @returns {string}
|
|
167
|
+
*/
|
|
168
|
+
function git(args, cwd) {
|
|
169
|
+
try {
|
|
170
|
+
return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
171
|
+
} catch (error) {
|
|
172
|
+
const command = `git ${args[0]}`;
|
|
173
|
+
if (/** @type {NodeJS.ErrnoException} */ (error).code === "ENOENT") {
|
|
174
|
+
throw new GitError(`${command} failed: git isn't installed or isn't on the PATH.`);
|
|
175
|
+
}
|
|
176
|
+
const stderr = String(/** @type {{ stderr?: unknown }} */ (error).stderr ?? "");
|
|
177
|
+
const firstLine = stderr.split("\n").find((line) => line.trim() !== "") ?? errorText(error);
|
|
178
|
+
throw new GitError(`${command} failed: ${firstLine.replace(/^(?:fatal|error): /, "")}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Selects the files the cruise must analyse.
|
|
184
|
+
*
|
|
185
|
+
* @param {string[]} files Tracked files, relative to the working directory.
|
|
186
|
+
* @param {import("dependency-cruiser").ICruiseOptions} options
|
|
187
|
+
* @returns {string[]}
|
|
188
|
+
*/
|
|
189
|
+
export function expectedSourceFiles(files, options) {
|
|
190
|
+
const configured = options.enhancedResolveOptions?.extensions;
|
|
191
|
+
if (!configured || configured.length === 0) {
|
|
192
|
+
throw new UsageError(
|
|
193
|
+
"The configuration sets no enhancedResolveOptions.extensions, so the expected files are unknown.",
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
const parseable = new Set(allExtensions.map(({ extension }) => extension));
|
|
197
|
+
const extensions = configured.filter((extension) => parseable.has(extension));
|
|
198
|
+
const exclude = patterns(options.exclude);
|
|
199
|
+
const doNotFollow = patterns(options.doNotFollow);
|
|
200
|
+
const includeOnly = patterns(options.includeOnly);
|
|
201
|
+
|
|
202
|
+
return files.filter(
|
|
203
|
+
(file) =>
|
|
204
|
+
extensions.some((extension) => file.endsWith(extension)) &&
|
|
205
|
+
!DECLARATION_FILE.test(file) &&
|
|
206
|
+
!exclude.some((pattern) => pattern.test(file)) &&
|
|
207
|
+
!doNotFollow.some((pattern) => pattern.test(file)) &&
|
|
208
|
+
(includeOnly.length === 0 || includeOnly.some((pattern) => pattern.test(file))),
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* A file counts as analysed when it's in the cruise result and
|
|
214
|
+
* dependency-cruiser can parse its extension. The second check matters,
|
|
215
|
+
* because a file reached only through an import appears in the result even
|
|
216
|
+
* when it couldn't be parsed.
|
|
217
|
+
*
|
|
218
|
+
* @param {string[]} expected
|
|
219
|
+
* @param {import("dependency-cruiser").ICruiseResult} result
|
|
220
|
+
* @returns {string[]}
|
|
221
|
+
*/
|
|
222
|
+
function unanalysedFiles(expected, result) {
|
|
223
|
+
const cruised = new Set(result.modules.map((module) => module.source));
|
|
224
|
+
const unparseable = allExtensions
|
|
225
|
+
.filter(({ available }) => !available)
|
|
226
|
+
.map(({ extension }) => extension);
|
|
227
|
+
return expected.filter(
|
|
228
|
+
(file) =>
|
|
229
|
+
!cruised.has(file) || unparseable.some((extension) => extensionOf(file) === extension),
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* The longest known extension a file ends with, so `.d.ts` wins over `.ts`.
|
|
235
|
+
*
|
|
236
|
+
* @param {string} file
|
|
237
|
+
* @returns {string | undefined}
|
|
238
|
+
*/
|
|
239
|
+
function extensionOf(file) {
|
|
240
|
+
return allExtensions
|
|
241
|
+
.map(({ extension }) => extension)
|
|
242
|
+
.filter((extension) => file.endsWith(extension))
|
|
243
|
+
.sort((a, b) => b.length - a.length)[0];
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* @param {string | string[] | { path?: string | string[] } | undefined} option
|
|
248
|
+
* @returns {RegExp[]}
|
|
249
|
+
*/
|
|
250
|
+
function patterns(option) {
|
|
251
|
+
const value = typeof option === "object" && !Array.isArray(option) ? option.path : option;
|
|
252
|
+
if (value === undefined) {
|
|
253
|
+
return [];
|
|
254
|
+
}
|
|
255
|
+
return (Array.isArray(value) ? value : [value]).map((pattern) => new RegExp(pattern));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* @param {string[]} paths
|
|
260
|
+
* @param {string} cwd
|
|
261
|
+
* @param {import("dependency-cruiser").ICruiseOptions} options
|
|
262
|
+
* @param {import("dependency-cruiser").IFlattenedRuleSet} ruleSet
|
|
263
|
+
* @returns {Promise<import("dependency-cruiser").ICruiseResult>}
|
|
264
|
+
*/
|
|
265
|
+
async function runCruise(paths, cwd, options, ruleSet) {
|
|
266
|
+
const tsConfigFile = options.tsConfig?.fileName;
|
|
267
|
+
const tsConfig = tsConfigFile ? extractTSConfig(resolve(cwd, tsConfigFile)) : undefined;
|
|
268
|
+
const { output } = await cruise(
|
|
269
|
+
paths,
|
|
270
|
+
{ ...options, baseDir: cwd, ruleSet, validate: true },
|
|
271
|
+
{},
|
|
272
|
+
tsConfig ? { tsConfig } : {},
|
|
273
|
+
);
|
|
274
|
+
if (typeof output === "string") {
|
|
275
|
+
throw new Error("expected a structured cruise result");
|
|
276
|
+
}
|
|
277
|
+
return output;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* @param {string[]} missing
|
|
282
|
+
* @returns {string}
|
|
283
|
+
*/
|
|
284
|
+
function describeMissing(missing) {
|
|
285
|
+
const listed = missing.slice(0, MAX_LISTED_FILES).map((file) => ` ${file}`);
|
|
286
|
+
if (missing.length > MAX_LISTED_FILES) {
|
|
287
|
+
listed.push(` … and ${missing.length - MAX_LISTED_FILES} more`);
|
|
288
|
+
}
|
|
289
|
+
const count =
|
|
290
|
+
missing.length === 1 ? "1 tracked source file" : `${missing.length} tracked source files`;
|
|
291
|
+
return [
|
|
292
|
+
`hm-lint-deps: dependency-cruiser didn't analyse ${count}:`,
|
|
293
|
+
...listed,
|
|
294
|
+
"",
|
|
295
|
+
likelyCause(missing),
|
|
296
|
+
"",
|
|
297
|
+
].join("\n");
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* @param {string[]} missing
|
|
302
|
+
* @returns {string}
|
|
303
|
+
*/
|
|
304
|
+
function likelyCause(missing) {
|
|
305
|
+
const unavailable = allExtensions
|
|
306
|
+
.filter(
|
|
307
|
+
({ available, extension }) =>
|
|
308
|
+
!available && missing.some((file) => extensionOf(file) === extension),
|
|
309
|
+
)
|
|
310
|
+
.map(({ extension }) => extension);
|
|
311
|
+
|
|
312
|
+
if (unavailable.length === 0) {
|
|
313
|
+
return "Likely cause: these files aren't reachable from the paths passed. Check them and the configuration's exclude, doNotFollow and includeOnly options.";
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const cause = `Likely cause: dependency-cruiser can't parse ${unavailable.join(", ")} files in this environment, because their parser isn't installed or isn't a supported version.`;
|
|
317
|
+
if (!unavailable.some((extension) => TYPESCRIPT_EXTENSION.test(extension))) {
|
|
318
|
+
return `${cause} Run "depcruise --info" to see which parsers it found.`;
|
|
319
|
+
}
|
|
320
|
+
const typescript = getAvailableTranspilers().find(({ name }) => name === "typescript");
|
|
321
|
+
return `${cause} For TypeScript, install a version dependency-cruiser supports (typescript ${typescript?.version ?? "see depcruise --info"}).`;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* @param {unknown} error
|
|
326
|
+
* @returns {string}
|
|
327
|
+
*/
|
|
328
|
+
function errorText(error) {
|
|
329
|
+
return error instanceof Error ? error.message : String(error);
|
|
330
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lint-staged preset for the `pre-commit` hook (ADR 0016): a secret scan,
|
|
3
|
+
* Biome with safe fixes, and stylelint for CSS, on staged files only.
|
|
4
|
+
*
|
|
5
|
+
* The globs don't overlap, because lint-staged runs globs concurrently and two
|
|
6
|
+
* tools writing the same file would race. Within a glob, the secret scan runs
|
|
7
|
+
* first, before anything rewrites the file.
|
|
8
|
+
*/
|
|
9
|
+
const SECRETLINT = "secretlint --maskSecrets";
|
|
10
|
+
const BIOME = "biome check --write --no-errors-on-unmatched --files-ignore-unknown=true";
|
|
11
|
+
const STYLELINT = "stylelint --allow-empty-input";
|
|
12
|
+
|
|
13
|
+
/** @type {Record<string, string[]>} */
|
|
14
|
+
const config = {
|
|
15
|
+
"*.css": [SECRETLINT, BIOME, STYLELINT],
|
|
16
|
+
"!(*.css)": [SECRETLINT, BIOME],
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export default config;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { fileURLToPath } from "node:url";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* secretlint preset for the SDK family: the recommended rules, which detect
|
|
5
|
+
* cloud, npm, GitHub, Slack and other credentials and private keys (ADR 0016).
|
|
6
|
+
*
|
|
7
|
+
* secretlint resolves rule ids from its own install location, so the rule
|
|
8
|
+
* package is resolved here, from the preset, and passed as an absolute path.
|
|
9
|
+
* Consumers don't need to install it themselves.
|
|
10
|
+
*
|
|
11
|
+
* @type {{ rules: Array<{ id: string }> }}
|
|
12
|
+
*/
|
|
13
|
+
const config = {
|
|
14
|
+
rules: [
|
|
15
|
+
{
|
|
16
|
+
id: fileURLToPath(import.meta.resolve("@secretlint/secretlint-rule-preset-recommend")),
|
|
17
|
+
},
|
|
18
|
+
],
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export default config;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import layerNamePattern from "./plugins/layer-name-pattern.js";
|
|
2
|
+
import noLiteralValues from "./plugins/no-literal-values.js";
|
|
3
|
+
import selectorScope from "./plugins/selector-scope.js";
|
|
4
|
+
|
|
5
|
+
/** A lowercase, hyphen-separated name, such as `pdf-thumb`. */
|
|
6
|
+
const NAME = "[a-z0-9]+(?:-[a-z0-9]+)*";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Stylelint preset for the SDK family. It enforces the family's own rules:
|
|
10
|
+
* the `hm` namespace (ADR 0003), scoped selectors (ADR 0005) and tokens for
|
|
11
|
+
* every design value (ADR 0006). General CSS correctness and formatting are
|
|
12
|
+
* Biome's job, so this preset doesn't extend a general-purpose config.
|
|
13
|
+
*
|
|
14
|
+
* @type {import("stylelint").Config}
|
|
15
|
+
*/
|
|
16
|
+
const config = {
|
|
17
|
+
plugins: [layerNamePattern, noLiteralValues, selectorScope],
|
|
18
|
+
reportDescriptionlessDisables: true,
|
|
19
|
+
reportInvalidScopeDisables: true,
|
|
20
|
+
reportNeedlessDisables: true,
|
|
21
|
+
rules: {
|
|
22
|
+
"custom-property-pattern": [
|
|
23
|
+
`^hm-${NAME}$`,
|
|
24
|
+
{
|
|
25
|
+
message: /** @param {string} name */ (name) =>
|
|
26
|
+
`Expected custom property "--${name}" to start with "--hm-"`,
|
|
27
|
+
},
|
|
28
|
+
],
|
|
29
|
+
"keyframes-name-pattern": [
|
|
30
|
+
`^hm-${NAME}$`,
|
|
31
|
+
{
|
|
32
|
+
message: /** @param {string} name */ (name) =>
|
|
33
|
+
`Expected keyframes "${name}" to start with "hm-"`,
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
"selector-attribute-name-disallowed-list": [
|
|
37
|
+
["/^data-(?!hm-)/"],
|
|
38
|
+
{
|
|
39
|
+
message: /** @param {string} name */ (name) =>
|
|
40
|
+
`Expected data attribute "${name}" to start with "data-hm-"`,
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
"selector-class-pattern": [
|
|
44
|
+
`^hm-${NAME}(?:--${NAME})?$`,
|
|
45
|
+
{
|
|
46
|
+
message: /** @param {string} name */ (name) =>
|
|
47
|
+
`Expected class "${name}" to match ".hm-<part>" or ".hm-<part>--<variant>". Use ARIA or data-hm-* attributes for states`,
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
"selector-id-pattern": [
|
|
51
|
+
`^hm-${NAME}$`,
|
|
52
|
+
{
|
|
53
|
+
message: /** @param {string} name */ (name) => `Expected id "${name}" to start with "hm-"`,
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
"hm/layer-name-pattern": true,
|
|
57
|
+
"hm/no-literal-values": true,
|
|
58
|
+
"hm/selector-scope": true,
|
|
59
|
+
},
|
|
60
|
+
overrides: [
|
|
61
|
+
{
|
|
62
|
+
// Token definitions are where literal values belong.
|
|
63
|
+
files: ["**/*.tokens.css", "**/tokens/**/*.css"],
|
|
64
|
+
rules: { "hm/no-literal-values": null },
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export default config;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
const ADR_BASE_URL = "https://github.com/hussmartinez/sdk-core/blob/main/docs/adr/";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Builds the documentation link shown next to a rule's warnings.
|
|
5
|
+
*
|
|
6
|
+
* @param {string} file ADR file name, such as `0003-hm-namespace.md`.
|
|
7
|
+
* @returns {string}
|
|
8
|
+
*/
|
|
9
|
+
export function adrUrl(file) {
|
|
10
|
+
return `${ADR_BASE_URL}${file}`;
|
|
11
|
+
}
|