@trackunit/eslint-plugin-trackunit 0.6.49 → 0.6.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,9 @@
1
+ ## 0.6.50 (2026-06-30)
2
+
3
+ ### 🧱 Updated Dependencies
4
+
5
+ - Updated shared-utils to 1.15.50
6
+
1
7
  ## 0.6.49 (2026-06-30)
2
8
 
3
9
  ### 🧱 Updated Dependencies
package/README.md CHANGED
@@ -83,6 +83,12 @@ All custom rules are enabled automatically through the presets under the `@track
83
83
  | `@trackunit/no-internal-graphql-when-tagged-with-gql-public` | error | — | Prevents internal GraphQL usage in projects tagged with `gql-public`. Applied to `project.json` files. |
84
84
 
85
85
 
86
+ ### Storybook
87
+
88
+ | Rule | Severity | Auto-fix | Description |
89
+ | ----------------------------------------------------- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------- |
90
+ | `@trackunit/require-storybook-folder-landing-page` | — | no | Requires a `*.docs.mdx` landing page (`<Meta title>`) for every ancestor folder implied by a story's title. Report-only; not yet enabled in a preset. |
91
+
86
92
  ## Utilities
87
93
 
88
94
  ### `createSkipWhen`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trackunit/eslint-plugin-trackunit",
3
- "version": "0.6.49",
3
+ "version": "0.6.50",
4
4
  "license": "SEE LICENSE IN LICENSE.txt",
5
5
  "repository": "https://github.com/Trackunit/manager",
6
6
  "engines": {
@@ -86,6 +86,9 @@ export declare const configs: {
86
86
  }], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
87
87
  name: string;
88
88
  };
89
+ "require-storybook-folder-landing-page": import("@typescript-eslint/utils/ts-eslint").RuleModule<"missingLandingPage", [], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
90
+ name: string;
91
+ };
89
92
  };
90
93
  };
91
94
  "@typescript-eslint": {
@@ -272,6 +275,9 @@ export declare const configs: {
272
275
  }], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
273
276
  name: string;
274
277
  };
278
+ "require-storybook-folder-landing-page": import("@typescript-eslint/utils/ts-eslint").RuleModule<"missingLandingPage", [], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
279
+ name: string;
280
+ };
275
281
  };
276
282
  };
277
283
  "@nx"?: undefined;
@@ -390,6 +396,9 @@ export declare const configs: {
390
396
  }], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
391
397
  name: string;
392
398
  };
399
+ "require-storybook-folder-landing-page": import("@typescript-eslint/utils/ts-eslint").RuleModule<"missingLandingPage", [], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
400
+ name: string;
401
+ };
393
402
  };
394
403
  };
395
404
  "@typescript-eslint": {
@@ -104,6 +104,9 @@ export declare const localRulesPlugin: {
104
104
  }], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
105
105
  name: string;
106
106
  };
107
+ "require-storybook-folder-landing-page": import("@typescript-eslint/utils/ts-eslint").RuleModule<"missingLandingPage", [], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
108
+ name: string;
109
+ };
107
110
  };
108
111
  };
109
112
  export { eslintConfigPrettier, globals, importPlugin, jest, jsdoc, jsoncParser, noNull, nx, react, reactHooks, testingLibrary, tsParser, typescriptEslint, };
@@ -86,6 +86,9 @@ export declare const base: (import("eslint").Linter.FlatConfig<import("eslint").
86
86
  }], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
87
87
  name: string;
88
88
  };
89
+ "require-storybook-folder-landing-page": import("@typescript-eslint/utils/ts-eslint").RuleModule<"missingLandingPage", [], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
90
+ name: string;
91
+ };
89
92
  };
90
93
  };
91
94
  "@typescript-eslint": {
@@ -272,6 +275,9 @@ export declare const base: (import("eslint").Linter.FlatConfig<import("eslint").
272
275
  }], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
273
276
  name: string;
274
277
  };
278
+ "require-storybook-folder-landing-page": import("@typescript-eslint/utils/ts-eslint").RuleModule<"missingLandingPage", [], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
279
+ name: string;
280
+ };
275
281
  };
276
282
  };
277
283
  "@nx"?: undefined;
@@ -85,6 +85,9 @@ export declare const reactPreset: (import("eslint").Linter.Config<import("eslint
85
85
  }], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
86
86
  name: string;
87
87
  };
88
+ "require-storybook-folder-landing-page": import("@typescript-eslint/utils/ts-eslint").RuleModule<"missingLandingPage", [], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
89
+ name: string;
90
+ };
88
91
  };
89
92
  };
90
93
  "@typescript-eslint": {
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Require a Storybook landing page (`*.docs.mdx` with `<Meta title="...">`) for every ancestor
3
+ * FOLDER implied by a story's title.
4
+ *
5
+ * Architecture: per-story-file rule. Each `*.stories.@(js|jsx|ts|tsx)` file declares a Storybook
6
+ * `title` (e.g. "Hooks/react-core-hooks/useDebounce"). The folders implied by that title are the
7
+ * accumulated path prefixes EXCLUDING the story leaf (here: "Hooks" and "Hooks/react-core-hooks").
8
+ * Every such folder must have a landing page: a `*.docs.mdx` somewhere in the workspace whose
9
+ * `<Meta title="FOLDER" />` EXACTLY equals the folder path, with non-empty body and no TODO marker.
10
+ *
11
+ * The rule is REPORT-ONLY: it does not scaffold files. Creating a separate `*.docs.mdx` cannot be
12
+ * expressed as a pure ESLint fix, and ESLint evaluates fix/suggestion callbacks on every lint pass
13
+ * (not only under `--fix`), so a file-writing fix would write stubs during ordinary editor/CI lint.
14
+ * Stub scaffolding is therefore delivered as a separate, explicit command (tracked as a follow-up).
15
+ *
16
+ * See README.md for incorrect/correct examples.
17
+ */
18
+ import { ESLintUtils } from "@typescript-eslint/utils";
19
+ /** Canonical TODO marker. A landing page still containing this marker does NOT satisfy the rule. */
20
+ export declare const STORYBOOK_LANDING_PAGE_TODO_MARKER = "{/* TODO: storybook-folder-landing-page */}";
21
+ /**
22
+ * Canonical permalink to the how-to-author doc. Single source of truth, embedded in the rule's
23
+ * error message so developers tripping the rule get a direct link. The `master` permalink stays
24
+ * stable across file moves within the (internal) repo.
25
+ */
26
+ export declare const STORYBOOK_LANDING_PAGE_DOCS_URL = "https://github.com/Trackunit/manager/blob/master/docs/STORYBOOK-LANDING-PAGES.md";
27
+ /** Clear the module-level scan cache (primarily for tests). */
28
+ export declare const clearLandingPageCache: () => void;
29
+ export declare const requireStorybookFolderLandingPage: ESLintUtils.RuleModule<"missingLandingPage", [], unknown, ESLintUtils.RuleListener> & {
30
+ name: string;
31
+ };
@@ -0,0 +1,238 @@
1
+ "use strict";
2
+ /**
3
+ * Require a Storybook landing page (`*.docs.mdx` with `<Meta title="...">`) for every ancestor
4
+ * FOLDER implied by a story's title.
5
+ *
6
+ * Architecture: per-story-file rule. Each `*.stories.@(js|jsx|ts|tsx)` file declares a Storybook
7
+ * `title` (e.g. "Hooks/react-core-hooks/useDebounce"). The folders implied by that title are the
8
+ * accumulated path prefixes EXCLUDING the story leaf (here: "Hooks" and "Hooks/react-core-hooks").
9
+ * Every such folder must have a landing page: a `*.docs.mdx` somewhere in the workspace whose
10
+ * `<Meta title="FOLDER" />` EXACTLY equals the folder path, with non-empty body and no TODO marker.
11
+ *
12
+ * The rule is REPORT-ONLY: it does not scaffold files. Creating a separate `*.docs.mdx` cannot be
13
+ * expressed as a pure ESLint fix, and ESLint evaluates fix/suggestion callbacks on every lint pass
14
+ * (not only under `--fix`), so a file-writing fix would write stubs during ordinary editor/CI lint.
15
+ * Stub scaffolding is therefore delivered as a separate, explicit command (tracked as a follow-up).
16
+ *
17
+ * See README.md for incorrect/correct examples.
18
+ */
19
+ Object.defineProperty(exports, "__esModule", { value: true });
20
+ exports.requireStorybookFolderLandingPage = exports.clearLandingPageCache = exports.STORYBOOK_LANDING_PAGE_DOCS_URL = exports.STORYBOOK_LANDING_PAGE_TODO_MARKER = void 0;
21
+ const tslib_1 = require("tslib");
22
+ const utils_1 = require("@typescript-eslint/utils");
23
+ const crypto = tslib_1.__importStar(require("crypto"));
24
+ const fs = tslib_1.__importStar(require("fs"));
25
+ const path = tslib_1.__importStar(require("path"));
26
+ const file_utils_1 = require("../../utils/file-utils");
27
+ const createRule = utils_1.ESLintUtils.RuleCreator(name => `https://github.com/trackunit/manager/blob/master/libs/eslint/plugin-trackunit/src/lib/rules/${name}/${name}.ts`);
28
+ /** Canonical TODO marker. A landing page still containing this marker does NOT satisfy the rule. */
29
+ exports.STORYBOOK_LANDING_PAGE_TODO_MARKER = "{/* TODO: storybook-folder-landing-page */}";
30
+ /**
31
+ * Canonical permalink to the how-to-author doc. Single source of truth, embedded in the rule's
32
+ * error message so developers tripping the rule get a direct link. The `master` permalink stays
33
+ * stable across file moves within the (internal) repo.
34
+ */
35
+ exports.STORYBOOK_LANDING_PAGE_DOCS_URL = "https://github.com/Trackunit/manager/blob/master/docs/STORYBOOK-LANDING-PAGES.md";
36
+ /**
37
+ * Cache of landing-page scans, keyed by workspace root. The cached value is reused only while the
38
+ * current docs-file fingerprint matches, so long-lived ESLint processes pick up newly added or
39
+ * edited landing pages without requiring an ESLint server restart.
40
+ */
41
+ const landingPageCache = new Map();
42
+ /** Clear the module-level scan cache (primarily for tests). */
43
+ const clearLandingPageCache = () => {
44
+ landingPageCache.clear();
45
+ };
46
+ exports.clearLandingPageCache = clearLandingPageCache;
47
+ const SKIP_DIRECTORIES = new Set(["node_modules", "dist", ".git", ".nx"]);
48
+ // Match `title="..."` anywhere within an opening `<Meta ...>` tag (attribute order independent).
49
+ const META_TITLE_REGEX = /<Meta\b[^>]*?\btitle\s*=\s*("|')(.*?)\1/;
50
+ const STORY_FILE_REGEX = /\.stories\.(js|jsx|ts|tsx)$/;
51
+ const DOCS_MDX_REGEX = /\.docs\.mdx$/;
52
+ /** Recursively collect absolute paths of every `*.docs.mdx` file under `dir`. */
53
+ const collectDocsMdxFiles = (dir) => {
54
+ const result = [];
55
+ let entries;
56
+ try {
57
+ entries = fs.readdirSync(dir, { withFileTypes: true });
58
+ }
59
+ catch {
60
+ return result;
61
+ }
62
+ for (const entry of entries) {
63
+ const fullPath = path.join(dir, entry.name);
64
+ if (entry.isDirectory()) {
65
+ if (!SKIP_DIRECTORIES.has(entry.name)) {
66
+ result.push(...collectDocsMdxFiles(fullPath));
67
+ }
68
+ }
69
+ else if (DOCS_MDX_REGEX.test(entry.name)) {
70
+ result.push(fullPath);
71
+ }
72
+ }
73
+ return result;
74
+ };
75
+ /** Strip import lines and the `<Meta .../>` tag, leaving the documentation body. */
76
+ const extractBody = (contents) => {
77
+ return contents
78
+ .split("\n")
79
+ .filter(line => !/^\s*import\s/.test(line))
80
+ .join("\n")
81
+ .replace(/<Meta\b[^>]*\/>/, "")
82
+ .trim();
83
+ };
84
+ /** Scan the workspace for `*.docs.mdx` landing pages, building a title -> entry map. */
85
+ const scanLandingPages = (workspaceRoot) => {
86
+ const landingPages = new Map();
87
+ const hash = crypto.createHash("sha256");
88
+ for (const filePath of collectDocsMdxFiles(workspaceRoot).toSorted()) {
89
+ hash.update(filePath);
90
+ hash.update("\0");
91
+ let contents;
92
+ try {
93
+ contents = fs.readFileSync(filePath, "utf-8");
94
+ }
95
+ catch {
96
+ hash.update("unreadable");
97
+ hash.update("\0");
98
+ continue;
99
+ }
100
+ hash.update(contents);
101
+ hash.update("\0");
102
+ const match = META_TITLE_REGEX.exec(contents);
103
+ if (match && typeof match[2] === "string") {
104
+ landingPages.set(match[2], { filePath, body: extractBody(contents) });
105
+ }
106
+ }
107
+ return { fingerprint: hash.digest("hex"), landingPages };
108
+ };
109
+ /** Get landing pages from cache when the current docs-file fingerprint is unchanged. */
110
+ const getLandingPages = (workspaceRoot) => {
111
+ const scan = scanLandingPages(workspaceRoot);
112
+ const cached = landingPageCache.get(workspaceRoot);
113
+ if (cached?.fingerprint === scan.fingerprint) {
114
+ return cached.landingPages;
115
+ }
116
+ landingPageCache.set(workspaceRoot, scan);
117
+ return scan.landingPages;
118
+ };
119
+ /** Derive ancestor folder paths from a title, dropping the story leaf. */
120
+ const deriveFolders = (title) => {
121
+ const segments = title.split("/");
122
+ const folders = [];
123
+ // Drop the last segment (story leaf); accumulate prefixes for the rest.
124
+ for (let i = 1; i < segments.length; i++) {
125
+ folders.push(segments.slice(0, i).join("/"));
126
+ }
127
+ return folders;
128
+ };
129
+ /** A folder is satisfied by a landing page entry with non-empty body and no TODO marker. */
130
+ const isSatisfied = (entry) => {
131
+ if (!entry) {
132
+ return false;
133
+ }
134
+ if (entry.body.length === 0) {
135
+ return false;
136
+ }
137
+ return !entry.body.includes(exports.STORYBOOK_LANDING_PAGE_TODO_MARKER);
138
+ };
139
+ /** Unwrap satisfies/as wrappers around an expression. */
140
+ const unwrapExpression = (node) => {
141
+ let current = node;
142
+ while (current.type === utils_1.AST_NODE_TYPES.TSSatisfiesExpression || current.type === utils_1.AST_NODE_TYPES.TSAsExpression) {
143
+ current = current.expression;
144
+ }
145
+ return current;
146
+ };
147
+ /** Extract a string-literal `title` property from an object expression, if present. */
148
+ const getTitleFromObject = (object) => {
149
+ for (const property of object.properties) {
150
+ if (property.type === utils_1.AST_NODE_TYPES.Property &&
151
+ property.key.type === utils_1.AST_NODE_TYPES.Identifier &&
152
+ property.key.name === "title" &&
153
+ property.value.type === utils_1.AST_NODE_TYPES.Literal &&
154
+ typeof property.value.value === "string") {
155
+ return property.value;
156
+ }
157
+ }
158
+ return null;
159
+ };
160
+ exports.requireStorybookFolderLandingPage = createRule({
161
+ name: "require-storybook-folder-landing-page",
162
+ meta: {
163
+ type: "problem",
164
+ docs: {
165
+ description: "Require a Storybook landing page (*.docs.mdx) for every ancestor folder implied by a story's title",
166
+ },
167
+ schema: [],
168
+ messages: {
169
+ // URL embedded from the single-source-of-truth const (see STORYBOOK_LANDING_PAGE_DOCS_URL).
170
+ missingLandingPage: `[require-storybook-folder-landing-page] Missing Storybook landing page for folder "{{folder}}". The <Meta title> must equal "{{folder}}" exactly (no extra leaf). Add a *.docs.mdx with <Meta title="{{folder}}" /> and real content. See ${exports.STORYBOOK_LANDING_PAGE_DOCS_URL} for how to author one.`,
171
+ },
172
+ },
173
+ defaultOptions: [],
174
+ create(context) {
175
+ const currentFile = context.filename;
176
+ // Only analyze story files (never .docs.* or .mdx).
177
+ if (!STORY_FILE_REGEX.test(currentFile)) {
178
+ return {};
179
+ }
180
+ const nxJsonPath = (0, file_utils_1.findNearestFile)(currentFile, "nx.json");
181
+ if (nxJsonPath === null) {
182
+ return {};
183
+ }
184
+ const workspaceRoot = path.dirname(nxJsonPath);
185
+ const reportForObject = (titleNode, reportNode) => {
186
+ const title = titleNode.value;
187
+ if (typeof title !== "string") {
188
+ return;
189
+ }
190
+ const folders = deriveFolders(title);
191
+ if (folders.length === 0) {
192
+ return;
193
+ }
194
+ const landingPages = getLandingPages(workspaceRoot);
195
+ for (const folder of folders) {
196
+ const entry = landingPages.get(folder);
197
+ if (isSatisfied(entry)) {
198
+ continue;
199
+ }
200
+ context.report({
201
+ node: reportNode,
202
+ messageId: "missingLandingPage",
203
+ data: { folder },
204
+ });
205
+ }
206
+ };
207
+ return {
208
+ ExportDefaultDeclaration(node) {
209
+ const declaration = node.declaration;
210
+ // Shape 1: export default { title: "..." }
211
+ if (declaration.type === utils_1.AST_NODE_TYPES.ObjectExpression) {
212
+ const titleNode = getTitleFromObject(declaration);
213
+ if (titleNode) {
214
+ reportForObject(titleNode, node);
215
+ }
216
+ return;
217
+ }
218
+ // Shape 2: const meta = { title: "..." } satisfies Meta; export default meta;
219
+ if (declaration.type === utils_1.AST_NODE_TYPES.Identifier) {
220
+ const variable = context.sourceCode
221
+ .getScope(node)
222
+ .references.find(ref => ref.identifier === declaration)?.resolved;
223
+ const definition = variable?.defs[0];
224
+ if (definition && definition.node.type === utils_1.AST_NODE_TYPES.VariableDeclarator && definition.node.init) {
225
+ const initializer = unwrapExpression(definition.node.init);
226
+ if (initializer.type === utils_1.AST_NODE_TYPES.ObjectExpression) {
227
+ const titleNode = getTitleFromObject(initializer);
228
+ if (titleNode) {
229
+ reportForObject(titleNode, node);
230
+ }
231
+ }
232
+ }
233
+ }
234
+ },
235
+ };
236
+ },
237
+ });
238
+ //# sourceMappingURL=require-storybook-folder-landing-page.js.map
@@ -82,4 +82,7 @@ export declare const rulesMap: {
82
82
  }], unknown, ESLintUtils.RuleListener> & {
83
83
  name: string;
84
84
  };
85
+ "require-storybook-folder-landing-page": ESLintUtils.RuleModule<"missingLandingPage", [], unknown, ESLintUtils.RuleListener> & {
86
+ name: string;
87
+ };
85
88
  };
@@ -18,6 +18,7 @@ const require_classname_alternatives_1 = require("./rules/require-classname-alte
18
18
  const require_component_prop_contracts_1 = require("./rules/require-component-prop-contracts/require-component-prop-contracts");
19
19
  const require_list_item_virtualization_props_1 = require("./rules/require-list-item-virtualization-props/require-list-item-virtualization-props");
20
20
  const require_optional_prop_initialization_1 = require("./rules/require-optional-prop-initialization/require-optional-prop-initialization");
21
+ const require_storybook_folder_landing_page_1 = require("./rules/require-storybook-folder-landing-page/require-storybook-folder-landing-page");
21
22
  exports.rulesMap = {
22
23
  "component-name-matches-filename": component_name_matches_filename_1.componentNameMatchesFilename,
23
24
  "one-component-per-file": one_component_per_file_1.oneComponentPerFile,
@@ -36,5 +37,6 @@ exports.rulesMap = {
36
37
  "require-component-prop-contracts": require_component_prop_contracts_1.requireComponentPropContracts,
37
38
  "prefer-field-components": prefer_field_components_1.preferFieldComponents,
38
39
  "require-list-item-virtualization-props": require_list_item_virtualization_props_1.requireListItemVirtualizationProps,
40
+ "require-storybook-folder-landing-page": require_storybook_folder_landing_page_1.requireStorybookFolderLandingPage,
39
41
  };
40
42
  //# sourceMappingURL=rules-map.js.map