@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.
@@ -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
+ }