pasika 0.2.0 → 0.3.1
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/README.md +93 -57
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +132 -0
- package/dist/enforcement/coverage.d.ts +51 -0
- package/dist/enforcement/coverage.js +210 -0
- package/dist/enforcement/docs-check.d.ts +17 -0
- package/dist/enforcement/docs-check.js +162 -0
- package/dist/enforcement/normalize.d.ts +11 -0
- package/dist/enforcement/normalize.js +21 -0
- package/dist/enforcement/parse-docs.d.ts +58 -0
- package/dist/enforcement/parse-docs.js +94 -0
- package/dist/enforcement/types.d.ts +57 -0
- package/dist/enforcement/types.js +59 -0
- package/dist/eslint/pasika/index.d.ts +9 -15
- package/dist/eslint/pasika/index.js +18 -20
- package/dist/eslint/pasika/project/ccf.d.ts +48 -0
- package/dist/eslint/pasika/project/ccf.js +119 -0
- package/dist/eslint/pasika/project/index.d.ts +21 -0
- package/dist/eslint/pasika/project/index.js +139 -0
- package/dist/eslint/pasika/project/parse-module.d.ts +27 -0
- package/dist/eslint/pasika/project/parse-module.js +128 -0
- package/dist/eslint/pasika/rules/component-placement.d.ts +11 -0
- package/dist/eslint/pasika/rules/component-placement.js +75 -0
- package/dist/eslint/pasika/rules/enforce-cva-variant-props.js +6 -1
- package/dist/eslint/pasika/rules/import-boundaries.js +53 -20
- package/dist/eslint/pasika/rules/no-arbitrary-tailwind.js +55 -65
- package/dist/eslint/pasika/rules/support-file-placement.d.ts +15 -0
- package/dist/eslint/pasika/rules/support-file-placement.js +70 -0
- package/enforcement/registry.json +1139 -0
- package/package.json +22 -4
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
export const SUPPORT_FOLDERS = new Set(["hooks", "types", "schemas", "constants", "utils"]);
|
|
3
|
+
/** Path of a file relative to the source root, as segments. */
|
|
4
|
+
export function segmentsOf(file, sourceRoot) {
|
|
5
|
+
const relative = path.relative(sourceRoot, file);
|
|
6
|
+
return relative.startsWith("..") ? [] : relative.split(path.sep);
|
|
7
|
+
}
|
|
8
|
+
/** Folder of a file relative to the source root, as segments. */
|
|
9
|
+
export function folderSegmentsOf(file, sourceRoot) {
|
|
10
|
+
return segmentsOf(file, sourceRoot).slice(0, -1);
|
|
11
|
+
}
|
|
12
|
+
export const isUnderApp = (segments) => segments[0] === "app";
|
|
13
|
+
export const isConfigModule = (segments) => segments[0] === "config";
|
|
14
|
+
export const isUnderCompositions = (segments) => segments[0] === "compositions";
|
|
15
|
+
/** The longest folder prefix every path shares. */
|
|
16
|
+
function commonPrefix(folders) {
|
|
17
|
+
if (folders.length === 0)
|
|
18
|
+
return [];
|
|
19
|
+
const [first = []] = folders;
|
|
20
|
+
const shared = [];
|
|
21
|
+
for (const [depth, segment] of first.entries()) {
|
|
22
|
+
if (!folders.every((folder) => folder[depth] === segment))
|
|
23
|
+
break;
|
|
24
|
+
shared.push(segment);
|
|
25
|
+
}
|
|
26
|
+
return shared;
|
|
27
|
+
}
|
|
28
|
+
/** Walks out of any trailing support folders, since a component never lives in one. */
|
|
29
|
+
function outOfSupportFolders(folder) {
|
|
30
|
+
const result = [...folder];
|
|
31
|
+
while (result.length > 0 && SUPPORT_FOLDERS.has(result[result.length - 1] ?? ""))
|
|
32
|
+
result.pop();
|
|
33
|
+
return result;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Resolves where a component belongs from the files that import it.
|
|
37
|
+
*
|
|
38
|
+
* Imports from `src/app/` and from configuration modules drop out, consumers
|
|
39
|
+
* under `src/compositions/` count only when every consumer is there, and a
|
|
40
|
+
* result of `src/features/` becomes `src/shared/` because no feature may import
|
|
41
|
+
* from another. Returns undefined when no consumer counts, which is the case the
|
|
42
|
+
* "lives in the feature it represents" requirement covers instead.
|
|
43
|
+
*/
|
|
44
|
+
export function resolveComponentPlacement(componentFile, index) {
|
|
45
|
+
const consumers = [...(index.consumers.get(componentFile) ?? [])];
|
|
46
|
+
const relevant = consumers.filter((consumer) => {
|
|
47
|
+
const segments = segmentsOf(consumer, index.sourceRoot);
|
|
48
|
+
return segments.length > 0 && !isUnderApp(segments) && !isConfigModule(segments);
|
|
49
|
+
});
|
|
50
|
+
if (relevant.length === 0)
|
|
51
|
+
return undefined;
|
|
52
|
+
const outsideCompositions = relevant.filter((consumer) => !isUnderCompositions(segmentsOf(consumer, index.sourceRoot)));
|
|
53
|
+
const counted = outsideCompositions.length > 0 ? outsideCompositions : relevant;
|
|
54
|
+
const shared = outOfSupportFolders(commonPrefix(counted.map((consumer) => folderSegmentsOf(consumer, index.sourceRoot))));
|
|
55
|
+
// A shared folder of `features` or of the source root itself means no single
|
|
56
|
+
// feature owns the component, so it belongs to the shared layer.
|
|
57
|
+
if (shared.length === 1 && shared[0] === "features") {
|
|
58
|
+
return { countedConsumers: counted, expectedFolder: ["shared"], reason: "across-features" };
|
|
59
|
+
}
|
|
60
|
+
if (shared.length === 0) {
|
|
61
|
+
return { countedConsumers: counted, expectedFolder: ["shared"], reason: "across-layers" };
|
|
62
|
+
}
|
|
63
|
+
return { countedConsumers: counted, expectedFolder: shared, reason: "ccf" };
|
|
64
|
+
}
|
|
65
|
+
export const formatFolder = (folder) => `src/${folder.join("/")}/`;
|
|
66
|
+
/** The folder that owns a consumer: its own folder, stepped out of any support folder. */
|
|
67
|
+
function owningFolderOf(consumer, sourceRoot) {
|
|
68
|
+
return outOfSupportFolders(folderSegmentsOf(consumer, sourceRoot));
|
|
69
|
+
}
|
|
70
|
+
/** The configuration module a file belongs to. `config/<name>/...` only: `config/<file>.ts` is not a module. */
|
|
71
|
+
const configModuleOf = (segments) => isConfigModule(segments) && segments.length >= 3 ? segments[1] : undefined;
|
|
72
|
+
/**
|
|
73
|
+
* Resolves where a support file belongs from the files that import it.
|
|
74
|
+
*
|
|
75
|
+
* A consumer inside a support folder is owned by that folder's parent, so the
|
|
76
|
+
* calculation lands on the scope that uses the file rather than on a sibling
|
|
77
|
+
* support folder. A consumer under `src/app/` forces the root support folder, a
|
|
78
|
+
* set of consumers inside one configuration module keeps the file in that module,
|
|
79
|
+
* and consumers spanning features land in the root support folder.
|
|
80
|
+
*/
|
|
81
|
+
export function resolveSupportPlacement(supportFile, supportFolder, index) {
|
|
82
|
+
const consumers = [...(index.consumers.get(supportFile) ?? [])].filter((consumer) => segmentsOf(consumer, index.sourceRoot).length > 0);
|
|
83
|
+
if (consumers.length === 0)
|
|
84
|
+
return undefined;
|
|
85
|
+
const consumerSegments = consumers.map((consumer) => segmentsOf(consumer, index.sourceRoot));
|
|
86
|
+
if (consumerSegments.some((segments) => isUnderApp(segments))) {
|
|
87
|
+
return { countedConsumers: consumers, expectedFolder: [supportFolder], reason: "app-consumer" };
|
|
88
|
+
}
|
|
89
|
+
const configModules = new Set(consumerSegments.map((segments) => configModuleOf(segments)));
|
|
90
|
+
const [onlyConfigModule] = [...configModules];
|
|
91
|
+
if (configModules.size === 1 && onlyConfigModule !== undefined) {
|
|
92
|
+
return {
|
|
93
|
+
countedConsumers: consumers,
|
|
94
|
+
expectedFolder: ["config", onlyConfigModule, supportFolder],
|
|
95
|
+
reason: "config-module",
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
const shared = commonPrefix(consumers.map((consumer) => owningFolderOf(consumer, index.sourceRoot)));
|
|
99
|
+
if (shared.length === 1 && shared[0] === "features") {
|
|
100
|
+
return { countedConsumers: consumers, expectedFolder: [supportFolder], reason: "across-features" };
|
|
101
|
+
}
|
|
102
|
+
if (shared.length === 0) {
|
|
103
|
+
return { countedConsumers: consumers, expectedFolder: [supportFolder], reason: "across-layers" };
|
|
104
|
+
}
|
|
105
|
+
return { countedConsumers: consumers, expectedFolder: [...shared, supportFolder], reason: "ccf" };
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Consumers for a message. A widely used file can have dozens, so the list is
|
|
109
|
+
* capped: the point is to show where the requirement comes from, not to enumerate.
|
|
110
|
+
*/
|
|
111
|
+
export function describeConsumers(consumers, sourceRoot) {
|
|
112
|
+
const shown = 3;
|
|
113
|
+
const names = consumers
|
|
114
|
+
.map((consumer) => path.relative(path.dirname(sourceRoot), consumer).split(path.sep).join("/"))
|
|
115
|
+
.sort((left, right) => left.localeCompare(right));
|
|
116
|
+
if (names.length <= shown)
|
|
117
|
+
return names.join(", ");
|
|
118
|
+
return `${names.slice(0, shown).join(", ")} and ${String(names.length - shown)} more`;
|
|
119
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type ParsedModule } from "./parse-module.js";
|
|
2
|
+
export interface ProjectIndex {
|
|
3
|
+
sourceRoot: string;
|
|
4
|
+
/** Absolute file path to its parsed module. */
|
|
5
|
+
modules: Map<string, ParsedModule>;
|
|
6
|
+
/** Absolute file path to the files that import it. */
|
|
7
|
+
consumers: Map<string, Set<string>>;
|
|
8
|
+
/** `file` and name joined by a NUL, to the files that import that name from it. */
|
|
9
|
+
symbolConsumers: Map<string, Set<string>>;
|
|
10
|
+
}
|
|
11
|
+
export declare const symbolKey: (file: string, name: string) => string;
|
|
12
|
+
/** Resolves an import specifier to a file inside the source tree, if it points at one. */
|
|
13
|
+
export declare function resolveSpecifier(fromFile: string, specifier: string, sourceRoot: string): string | undefined;
|
|
14
|
+
/**
|
|
15
|
+
* The project index for a source tree, rebuilt only when the tree changed.
|
|
16
|
+
* Returns undefined when the tree does not exist, which is how a repository
|
|
17
|
+
* without a `src/` folder opts out of every cross-file rule.
|
|
18
|
+
*/
|
|
19
|
+
export declare function getProjectIndex(sourceRoot: string): ProjectIndex | undefined;
|
|
20
|
+
/** Drops the memoized index. Used by tests that write a fresh tree per case. */
|
|
21
|
+
export declare function clearProjectIndex(): void;
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { readdirSync, statSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { parseModule } from "./parse-module.js";
|
|
4
|
+
const MODULE_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"];
|
|
5
|
+
const INDEX_BASENAMES = ["index.ts", "index.tsx", "index.mts", "index.cts", "index.js", "index.jsx"];
|
|
6
|
+
/**
|
|
7
|
+
* How long a built index is trusted before its inputs are re-checked. A long
|
|
8
|
+
* lived ESLint server keeps this module in memory across edits, so the index has
|
|
9
|
+
* to notice a changed tree without re-reading it on every single file.
|
|
10
|
+
*/
|
|
11
|
+
const REVALIDATE_AFTER_MS = 2000;
|
|
12
|
+
export const symbolKey = (file, name) => `${file}\u0000${name}`;
|
|
13
|
+
function listSourceFiles(dir) {
|
|
14
|
+
let entries;
|
|
15
|
+
try {
|
|
16
|
+
entries = readdirSync(dir);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return [];
|
|
20
|
+
}
|
|
21
|
+
return entries.flatMap((entry) => {
|
|
22
|
+
if (entry.startsWith(".") || entry === "node_modules")
|
|
23
|
+
return [];
|
|
24
|
+
const entryPath = path.join(dir, entry);
|
|
25
|
+
let stats;
|
|
26
|
+
try {
|
|
27
|
+
stats = statSync(entryPath);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
if (stats.isDirectory())
|
|
33
|
+
return listSourceFiles(entryPath);
|
|
34
|
+
return MODULE_EXTENSIONS.includes(path.extname(entry)) ? [entryPath] : [];
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
/** Cheap fingerprint of the tree, so an unchanged tree is never re-parsed. */
|
|
38
|
+
function fingerprint(files) {
|
|
39
|
+
let total = 0;
|
|
40
|
+
for (const file of files) {
|
|
41
|
+
try {
|
|
42
|
+
total += statSync(file).mtimeMs;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
/* A file removed between listing and stat just drops out of the fingerprint. */
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return `${String(files.length)}:${String(total)}`;
|
|
49
|
+
}
|
|
50
|
+
/** Resolves an import specifier to a file inside the source tree, if it points at one. */
|
|
51
|
+
export function resolveSpecifier(fromFile, specifier, sourceRoot) {
|
|
52
|
+
let base;
|
|
53
|
+
if (specifier.startsWith("@/")) {
|
|
54
|
+
base = path.resolve(sourceRoot, specifier.slice(2));
|
|
55
|
+
}
|
|
56
|
+
else if (specifier.startsWith(".")) {
|
|
57
|
+
base = path.resolve(path.dirname(fromFile), specifier);
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
const candidates = [
|
|
63
|
+
base,
|
|
64
|
+
...MODULE_EXTENSIONS.map((extension) => `${base}${extension}`),
|
|
65
|
+
...INDEX_BASENAMES.map((name) => path.join(base, name)),
|
|
66
|
+
];
|
|
67
|
+
for (const candidate of candidates) {
|
|
68
|
+
try {
|
|
69
|
+
if (statSync(candidate).isFile())
|
|
70
|
+
return candidate;
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
/* Try the next candidate. */
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
function build(sourceRoot, files) {
|
|
79
|
+
const modules = new Map();
|
|
80
|
+
const consumers = new Map();
|
|
81
|
+
const symbolConsumers = new Map();
|
|
82
|
+
for (const file of files) {
|
|
83
|
+
try {
|
|
84
|
+
modules.set(file, parseModule(file));
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
/* A file that cannot be read or parsed contributes nothing to the graph. */
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
for (const [file, module] of modules) {
|
|
91
|
+
for (const moduleImport of module.imports) {
|
|
92
|
+
const target = resolveSpecifier(file, moduleImport.specifier, sourceRoot);
|
|
93
|
+
if (!target || !modules.has(target))
|
|
94
|
+
continue;
|
|
95
|
+
const fileConsumers = consumers.get(target) ?? new Set();
|
|
96
|
+
fileConsumers.add(file);
|
|
97
|
+
consumers.set(target, fileConsumers);
|
|
98
|
+
for (const name of moduleImport.names) {
|
|
99
|
+
const key = symbolKey(target, name);
|
|
100
|
+
const nameConsumers = symbolConsumers.get(key) ?? new Set();
|
|
101
|
+
nameConsumers.add(file);
|
|
102
|
+
symbolConsumers.set(key, nameConsumers);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return { sourceRoot, modules, consumers, symbolConsumers };
|
|
107
|
+
}
|
|
108
|
+
let cache;
|
|
109
|
+
/**
|
|
110
|
+
* The project index for a source tree, rebuilt only when the tree changed.
|
|
111
|
+
* Returns undefined when the tree does not exist, which is how a repository
|
|
112
|
+
* without a `src/` folder opts out of every cross-file rule.
|
|
113
|
+
*/
|
|
114
|
+
export function getProjectIndex(sourceRoot) {
|
|
115
|
+
const now = Date.now();
|
|
116
|
+
if (cache?.index.sourceRoot === sourceRoot && now - cache.checkedAt < REVALIDATE_AFTER_MS) {
|
|
117
|
+
return cache.index;
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
if (!statSync(sourceRoot).isDirectory())
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
const files = listSourceFiles(sourceRoot).sort((left, right) => left.localeCompare(right));
|
|
127
|
+
const currentFingerprint = fingerprint(files);
|
|
128
|
+
if (cache?.index.sourceRoot === sourceRoot && cache.fingerprint === currentFingerprint) {
|
|
129
|
+
cache.checkedAt = now;
|
|
130
|
+
return cache.index;
|
|
131
|
+
}
|
|
132
|
+
const index = build(sourceRoot, files);
|
|
133
|
+
cache = { index, checkedAt: now, fingerprint: currentFingerprint };
|
|
134
|
+
return index;
|
|
135
|
+
}
|
|
136
|
+
/** Drops the memoized index. Used by tests that write a fresh tree per case. */
|
|
137
|
+
export function clearProjectIndex() {
|
|
138
|
+
cache = undefined;
|
|
139
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** What an exported name is, as far as the placement rules need to care. */
|
|
2
|
+
export type ExportKind = "component" | "hook" | "type" | "schema" | "constant" | "function" | "other";
|
|
3
|
+
export interface ModuleExport {
|
|
4
|
+
name: string;
|
|
5
|
+
kind: ExportKind;
|
|
6
|
+
line: number;
|
|
7
|
+
}
|
|
8
|
+
export interface ModuleImport {
|
|
9
|
+
/** The specifier exactly as written. */
|
|
10
|
+
specifier: string;
|
|
11
|
+
/** Names taken from the module; empty for a side-effect or namespace import. */
|
|
12
|
+
names: string[];
|
|
13
|
+
line: number;
|
|
14
|
+
}
|
|
15
|
+
export interface ParsedModule {
|
|
16
|
+
file: string;
|
|
17
|
+
imports: ModuleImport[];
|
|
18
|
+
exports: ModuleExport[];
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Parses one module for the names it exports and the modules it imports.
|
|
22
|
+
*
|
|
23
|
+
* Uses the TypeScript parser without a program or typechecker: the placement
|
|
24
|
+
* rules only need the shape of the import and export statements, and parsing a
|
|
25
|
+
* file in isolation keeps this fast enough to run over a whole tree.
|
|
26
|
+
*/
|
|
27
|
+
export declare function parseModule(file: string): ParsedModule;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import ts from "typescript";
|
|
4
|
+
const isPascalCase = (name) => /^[A-Z][A-Za-z0-9]*$/.test(name);
|
|
5
|
+
const isHookName = (name) => /^use[A-Z]/.test(name);
|
|
6
|
+
const isSchemaName = (name) => /[Ss]chema$/.test(name);
|
|
7
|
+
function lineOf(sourceFile, node) {
|
|
8
|
+
return sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
|
|
9
|
+
}
|
|
10
|
+
function returnsJsx(node) {
|
|
11
|
+
let found = false;
|
|
12
|
+
const visit = (child) => {
|
|
13
|
+
if (found)
|
|
14
|
+
return;
|
|
15
|
+
if (ts.isJsxElement(child) ||
|
|
16
|
+
ts.isJsxSelfClosingElement(child) ||
|
|
17
|
+
ts.isJsxFragment(child) ||
|
|
18
|
+
ts.isJsxOpeningFragment(child)) {
|
|
19
|
+
found = true;
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
ts.forEachChild(child, visit);
|
|
23
|
+
};
|
|
24
|
+
ts.forEachChild(node, visit);
|
|
25
|
+
return found;
|
|
26
|
+
}
|
|
27
|
+
function classifyFunction(name, isTsx, hasJsx) {
|
|
28
|
+
if (isHookName(name))
|
|
29
|
+
return "hook";
|
|
30
|
+
if (isTsx && isPascalCase(name) && hasJsx)
|
|
31
|
+
return "component";
|
|
32
|
+
return "function";
|
|
33
|
+
}
|
|
34
|
+
function classifyValue(name, initializer, isTsx) {
|
|
35
|
+
const isFunctionLike = initializer !== undefined &&
|
|
36
|
+
(ts.isArrowFunction(initializer) || ts.isFunctionExpression(initializer) || ts.isFunctionDeclaration(initializer));
|
|
37
|
+
if (isHookName(name))
|
|
38
|
+
return "hook";
|
|
39
|
+
if (isSchemaName(name))
|
|
40
|
+
return "schema";
|
|
41
|
+
if (isTsx && isPascalCase(name) && (initializer === undefined || returnsJsx(initializer) || isFunctionLike)) {
|
|
42
|
+
return "component";
|
|
43
|
+
}
|
|
44
|
+
if (isFunctionLike)
|
|
45
|
+
return "function";
|
|
46
|
+
return "constant";
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Parses one module for the names it exports and the modules it imports.
|
|
50
|
+
*
|
|
51
|
+
* Uses the TypeScript parser without a program or typechecker: the placement
|
|
52
|
+
* rules only need the shape of the import and export statements, and parsing a
|
|
53
|
+
* file in isolation keeps this fast enough to run over a whole tree.
|
|
54
|
+
*/
|
|
55
|
+
export function parseModule(file) {
|
|
56
|
+
const text = readFileSync(file, "utf8");
|
|
57
|
+
const isTsx = file.endsWith(".tsx") || file.endsWith(".jsx");
|
|
58
|
+
const sourceFile = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
59
|
+
const imports = [];
|
|
60
|
+
const exports = [];
|
|
61
|
+
const addImport = (specifierNode, names, node) => {
|
|
62
|
+
if (!ts.isStringLiteral(specifierNode))
|
|
63
|
+
return;
|
|
64
|
+
imports.push({ specifier: specifierNode.text, names, line: lineOf(sourceFile, node) });
|
|
65
|
+
};
|
|
66
|
+
for (const statement of sourceFile.statements) {
|
|
67
|
+
if (ts.isImportDeclaration(statement)) {
|
|
68
|
+
const names = [];
|
|
69
|
+
const bindings = statement.importClause?.namedBindings;
|
|
70
|
+
if (statement.importClause?.name)
|
|
71
|
+
names.push(statement.importClause.name.text);
|
|
72
|
+
if (bindings && ts.isNamedImports(bindings)) {
|
|
73
|
+
for (const element of bindings.elements)
|
|
74
|
+
names.push(element.propertyName?.text ?? element.name.text);
|
|
75
|
+
}
|
|
76
|
+
addImport(statement.moduleSpecifier, names, statement);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (ts.isExportDeclaration(statement)) {
|
|
80
|
+
// `export { x } from "./y"` both imports and re-exports.
|
|
81
|
+
if (statement.moduleSpecifier) {
|
|
82
|
+
const names = [];
|
|
83
|
+
if (statement.exportClause && ts.isNamedExports(statement.exportClause)) {
|
|
84
|
+
for (const element of statement.exportClause.elements) {
|
|
85
|
+
names.push(element.propertyName?.text ?? element.name.text);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
addImport(statement.moduleSpecifier, names, statement);
|
|
89
|
+
}
|
|
90
|
+
if (statement.exportClause && ts.isNamedExports(statement.exportClause)) {
|
|
91
|
+
for (const element of statement.exportClause.elements) {
|
|
92
|
+
exports.push({ name: element.name.text, kind: "other", line: lineOf(sourceFile, element) });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const isExported = ts.canHaveModifiers(statement)
|
|
98
|
+
? (ts.getModifiers(statement) ?? []).some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword)
|
|
99
|
+
: false;
|
|
100
|
+
if (!isExported)
|
|
101
|
+
continue;
|
|
102
|
+
if (ts.isFunctionDeclaration(statement) && statement.name) {
|
|
103
|
+
const name = statement.name.text;
|
|
104
|
+
exports.push({
|
|
105
|
+
name,
|
|
106
|
+
kind: classifyFunction(name, isTsx, returnsJsx(statement)),
|
|
107
|
+
line: lineOf(sourceFile, statement),
|
|
108
|
+
});
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (ts.isVariableStatement(statement)) {
|
|
112
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
113
|
+
if (!ts.isIdentifier(declaration.name))
|
|
114
|
+
continue;
|
|
115
|
+
exports.push({
|
|
116
|
+
name: declaration.name.text,
|
|
117
|
+
kind: classifyValue(declaration.name.text, declaration.initializer, isTsx),
|
|
118
|
+
line: lineOf(sourceFile, declaration),
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (ts.isTypeAliasDeclaration(statement) || ts.isInterfaceDeclaration(statement)) {
|
|
124
|
+
exports.push({ name: statement.name.text, kind: "type", line: lineOf(sourceFile, statement) });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return { file: path.resolve(file), imports, exports };
|
|
128
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: pasika/component-placement
|
|
3
|
+
*
|
|
4
|
+
* Enforces the placement requirements from the "Component Placement Rule" by
|
|
5
|
+
* reading the whole source tree, because where a component belongs depends on
|
|
6
|
+
* which files import it — something a single-file pass cannot see.
|
|
7
|
+
*
|
|
8
|
+
* @see docs/code-organization-guide/rules/component-placement-rule.md
|
|
9
|
+
*/
|
|
10
|
+
import type { Rule } from "eslint";
|
|
11
|
+
export declare const componentPlacementRule: Rule.RuleModule;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: pasika/component-placement
|
|
3
|
+
*
|
|
4
|
+
* Enforces the placement requirements from the "Component Placement Rule" by
|
|
5
|
+
* reading the whole source tree, because where a component belongs depends on
|
|
6
|
+
* which files import it — something a single-file pass cannot see.
|
|
7
|
+
*
|
|
8
|
+
* @see docs/code-organization-guide/rules/component-placement-rule.md
|
|
9
|
+
*/
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { getProjectIndex } from "../project/index.js";
|
|
12
|
+
import { describeConsumers, folderSegmentsOf, formatFolder, isConfigModule, isUnderApp, resolveComponentPlacement, segmentsOf, } from "../project/ccf.js";
|
|
13
|
+
const REASON_TEXT = {
|
|
14
|
+
ccf: "that is the closest folder its consumers share",
|
|
15
|
+
"across-features": "its consumers span more than one feature, and no feature may import from another",
|
|
16
|
+
"across-layers": "its consumers span more than one layer, so no feature can own it",
|
|
17
|
+
};
|
|
18
|
+
const sameFolder = (left, right) => left.length === right.length && left.every((segment, depth) => segment === right[depth]);
|
|
19
|
+
export const componentPlacementRule = {
|
|
20
|
+
meta: {
|
|
21
|
+
schema: [],
|
|
22
|
+
type: "problem",
|
|
23
|
+
docs: {
|
|
24
|
+
description: "Enforce that a component lives in the folder its consumers imply.",
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
create(context) {
|
|
28
|
+
const filename = context.filename;
|
|
29
|
+
if (!filename.endsWith(".tsx") && !filename.endsWith(".jsx"))
|
|
30
|
+
return {};
|
|
31
|
+
const sourceRoot = path.resolve("src");
|
|
32
|
+
const index = getProjectIndex(sourceRoot);
|
|
33
|
+
if (!index)
|
|
34
|
+
return {};
|
|
35
|
+
const componentFile = path.resolve(filename);
|
|
36
|
+
const segments = segmentsOf(componentFile, sourceRoot);
|
|
37
|
+
if (segments.length === 0)
|
|
38
|
+
return {};
|
|
39
|
+
// Routing files and configuration modules are not placed by their consumers.
|
|
40
|
+
if (isUnderApp(segments) || isConfigModule(segments))
|
|
41
|
+
return {};
|
|
42
|
+
const module = index.modules.get(componentFile);
|
|
43
|
+
if (!module?.exports.some((moduleExport) => moduleExport.kind === "component"))
|
|
44
|
+
return {};
|
|
45
|
+
const currentFolder = folderSegmentsOf(componentFile, sourceRoot);
|
|
46
|
+
const placement = resolveComponentPlacement(componentFile, index);
|
|
47
|
+
return {
|
|
48
|
+
Program(node) {
|
|
49
|
+
if (!placement) {
|
|
50
|
+
// No consumer counts, so the component belongs to the feature it represents.
|
|
51
|
+
if (segments[0] !== "features" || segments.length < 3) {
|
|
52
|
+
context.report({
|
|
53
|
+
node,
|
|
54
|
+
loc: { line: 1, column: 0 },
|
|
55
|
+
message: `This component has no consumer outside src/app/ or a configuration module, ` +
|
|
56
|
+
`so it belongs in the feature folder it represents, not in ${formatFolder(currentFolder)}. ` +
|
|
57
|
+
"See docs/code-organization-guide/rules/component-placement-rule.md",
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (sameFolder(currentFolder, placement.expectedFolder))
|
|
63
|
+
return;
|
|
64
|
+
const explanation = REASON_TEXT[placement.reason] ?? "that is where its consumers place it";
|
|
65
|
+
context.report({
|
|
66
|
+
node,
|
|
67
|
+
loc: { line: 1, column: 0 },
|
|
68
|
+
message: `Move this component to ${formatFolder(placement.expectedFolder)} — ${explanation}. ` +
|
|
69
|
+
`Imported by ${describeConsumers(placement.countedConsumers, sourceRoot)}. ` +
|
|
70
|
+
"See docs/code-organization-guide/rules/component-placement-rule.md",
|
|
71
|
+
});
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
},
|
|
75
|
+
};
|
|
@@ -53,7 +53,12 @@ export const enforceCvaVariantPropsRule = {
|
|
|
53
53
|
member.typeAnnotation?.typeAnnotation?.type === "TSUnionType") {
|
|
54
54
|
const keyName = String(member.key.name);
|
|
55
55
|
const typeAnn = member.typeAnnotation.typeAnnotation;
|
|
56
|
-
|
|
56
|
+
// typescript-eslint emits ESTree `Literal` nodes; other TypeScript
|
|
57
|
+
// parsers emit `StringLiteral`. Accept both so the rule works
|
|
58
|
+
// whichever parser the consuming config installs.
|
|
59
|
+
const allStringLiterals = typeAnn.types.every((t) => t.type === "TSLiteralType" &&
|
|
60
|
+
(t.literal?.type === "StringLiteral" ||
|
|
61
|
+
(t.literal?.type === "Literal" && typeof t.literal.value === "string")));
|
|
57
62
|
if (allStringLiterals && typeAnn.types.length >= 2) {
|
|
58
63
|
for (const [, variantNames] of cvaDefinitions) {
|
|
59
64
|
if (variantNames.includes(keyName)) {
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* ESLint rule: pasika/import-boundaries
|
|
3
3
|
*
|
|
4
4
|
* Enforces the import conventions from the "Exports and Imports Rule":
|
|
5
|
-
* -
|
|
6
|
-
*
|
|
5
|
+
* - Whichever of the relative path and the @/* alias has fewer segments, with
|
|
6
|
+
* a tie going to the relative path.
|
|
7
7
|
* - Layer boundary enforcement (app → compositions → features → shared → root).
|
|
8
8
|
*
|
|
9
9
|
* @see docs/code-organization-guide/rules/exports-and-imports-rule.md
|
|
@@ -29,12 +29,33 @@ function sourceSegments(absolutePath) {
|
|
|
29
29
|
}
|
|
30
30
|
return relativePath.split(path.sep);
|
|
31
31
|
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
32
|
+
/** The relative form of an import, always prefixed so it reads as a path. */
|
|
33
|
+
function relativeSpecifier(filename, resolvedPath) {
|
|
34
|
+
const relativePath = path.relative(path.dirname(filename), resolvedPath).split(path.sep).join("/");
|
|
35
|
+
return relativePath.startsWith(".") ? relativePath : `./${relativePath}`;
|
|
36
|
+
}
|
|
37
|
+
/** The `@/*` form of an import. */
|
|
38
|
+
function aliasSpecifier(resolvedPath) {
|
|
39
|
+
return `@/${(sourceSegments(resolvedPath) ?? []).join("/")}`;
|
|
40
|
+
}
|
|
41
|
+
/** Segments in a specifier: one per `../` step and one per name, ignoring a leading `./`. */
|
|
42
|
+
function segmentCount(specifier) {
|
|
43
|
+
return specifier
|
|
44
|
+
.replace(/^@\//, "")
|
|
45
|
+
.split("/")
|
|
46
|
+
.filter((segment) => segment !== "." && segment !== "").length;
|
|
47
|
+
}
|
|
48
|
+
function describeSegments(count) {
|
|
49
|
+
return `${String(count)} segment${count === 1 ? "" : "s"}`;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Whether the relative form is the one to use. Shorter wins, and a tie goes to
|
|
53
|
+
* the relative form. Because crossing a layer always costs at least one `../`
|
|
54
|
+
* while the alias spells the same tail, the alias always wins for a
|
|
55
|
+
* cross-layer import without this needing to know what a layer is.
|
|
56
|
+
*/
|
|
57
|
+
function prefersRelative(filename, resolvedPath) {
|
|
58
|
+
return segmentCount(relativeSpecifier(filename, resolvedPath)) <= segmentCount(aliasSpecifier(resolvedPath));
|
|
38
59
|
}
|
|
39
60
|
export const importBoundariesRule = {
|
|
40
61
|
meta: {
|
|
@@ -60,18 +81,6 @@ export const importBoundariesRule = {
|
|
|
60
81
|
if (!importer || !imported || importer.length === 0 || imported.length === 0) {
|
|
61
82
|
return;
|
|
62
83
|
}
|
|
63
|
-
if (isNearbyImport(filename, resolvedPath) && importPath.startsWith("@/")) {
|
|
64
|
-
context.report({
|
|
65
|
-
node: source,
|
|
66
|
-
message: "Use a relative path for imports in the same folder, a descendant, or one folder up.",
|
|
67
|
-
});
|
|
68
|
-
}
|
|
69
|
-
if (!isNearbyImport(filename, resolvedPath) && importPath.startsWith(".")) {
|
|
70
|
-
context.report({
|
|
71
|
-
node: source,
|
|
72
|
-
message: "Use the @/* alias for imports beyond one folder up.",
|
|
73
|
-
});
|
|
74
|
-
}
|
|
75
84
|
const [importerLayer = "", importerFeature] = importer;
|
|
76
85
|
const [importedLayer = "", importedFeature] = imported;
|
|
77
86
|
const extension = path.extname(importPath);
|
|
@@ -107,6 +116,30 @@ export const importBoundariesRule = {
|
|
|
107
116
|
node: source,
|
|
108
117
|
message: "This import violates the src layer boundary.",
|
|
109
118
|
});
|
|
119
|
+
// The fix is to move the file, so how the specifier is spelled does not matter yet.
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const relativeForm = relativeSpecifier(filename, resolvedPath);
|
|
123
|
+
const aliasForm = aliasSpecifier(resolvedPath);
|
|
124
|
+
const relativeSegments = segmentCount(relativeForm);
|
|
125
|
+
const aliasSegments = segmentCount(aliasForm);
|
|
126
|
+
function describeChoice(preferred, preferredSegments, other, otherSegments) {
|
|
127
|
+
const tie = preferredSegments === otherSegments ? ", and a tie goes to the relative path" : "";
|
|
128
|
+
return (`Use "${preferred}" (${describeSegments(preferredSegments)}) ` +
|
|
129
|
+
`instead of "${other}" (${describeSegments(otherSegments)})${tie}.`);
|
|
130
|
+
}
|
|
131
|
+
if (prefersRelative(filename, resolvedPath) && importPath.startsWith("@/")) {
|
|
132
|
+
context.report({
|
|
133
|
+
node: source,
|
|
134
|
+
message: describeChoice(relativeForm, relativeSegments, aliasForm, aliasSegments),
|
|
135
|
+
});
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (!prefersRelative(filename, resolvedPath) && importPath.startsWith(".")) {
|
|
139
|
+
context.report({
|
|
140
|
+
node: source,
|
|
141
|
+
message: describeChoice(aliasForm, aliasSegments, relativeForm, relativeSegments),
|
|
142
|
+
});
|
|
110
143
|
}
|
|
111
144
|
}
|
|
112
145
|
return {
|