pasika 0.1.6 → 0.3.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.
Files changed (91) hide show
  1. package/README.md +99 -78
  2. package/dist/cli/index.d.ts +2 -0
  3. package/dist/cli/index.js +132 -0
  4. package/dist/enforcement/coverage.d.ts +51 -0
  5. package/dist/enforcement/coverage.js +210 -0
  6. package/dist/enforcement/docs-check.d.ts +17 -0
  7. package/dist/enforcement/docs-check.js +159 -0
  8. package/dist/enforcement/normalize.d.ts +11 -0
  9. package/dist/enforcement/normalize.js +21 -0
  10. package/dist/enforcement/parse-docs.d.ts +58 -0
  11. package/dist/enforcement/parse-docs.js +94 -0
  12. package/dist/enforcement/types.d.ts +57 -0
  13. package/dist/enforcement/types.js +59 -0
  14. package/dist/eslint/pasika/index.d.ts +13 -1
  15. package/dist/eslint/pasika/index.js +24 -11
  16. package/dist/eslint/pasika/project/ccf.d.ts +48 -0
  17. package/dist/eslint/pasika/project/ccf.js +119 -0
  18. package/dist/eslint/pasika/project/index.d.ts +21 -0
  19. package/dist/eslint/pasika/project/index.js +139 -0
  20. package/dist/eslint/pasika/project/parse-module.d.ts +27 -0
  21. package/dist/eslint/pasika/project/parse-module.js +128 -0
  22. package/dist/eslint/pasika/rules/component-placement.d.ts +11 -0
  23. package/dist/eslint/pasika/rules/component-placement.js +75 -0
  24. package/dist/eslint/pasika/rules/enforce-barrel-exports.d.ts +9 -0
  25. package/dist/eslint/pasika/rules/enforce-barrel-exports.js +78 -0
  26. package/dist/eslint/pasika/rules/enforce-cn-merge.d.ts +9 -0
  27. package/dist/eslint/pasika/rules/enforce-cn-merge.js +84 -0
  28. package/dist/eslint/pasika/rules/enforce-cva-variant-props.d.ts +9 -0
  29. package/dist/eslint/pasika/rules/enforce-cva-variant-props.js +81 -0
  30. package/dist/eslint/pasika/rules/filename-case.js +10 -0
  31. package/dist/eslint/pasika/rules/import-boundaries.d.ts +2 -0
  32. package/dist/eslint/pasika/rules/{organization-imports.js → import-boundaries.js} +62 -19
  33. package/dist/eslint/pasika/rules/no-arbitrary-tailwind.d.ts +9 -0
  34. package/dist/eslint/pasika/rules/no-arbitrary-tailwind.js +97 -0
  35. package/dist/eslint/pasika/rules/no-mixed-concerns.d.ts +9 -0
  36. package/dist/eslint/pasika/rules/no-mixed-concerns.js +84 -0
  37. package/dist/eslint/pasika/rules/support-file-placement.d.ts +15 -0
  38. package/dist/eslint/pasika/rules/support-file-placement.js +70 -0
  39. package/enforcement/registry.json +1139 -0
  40. package/package.json +21 -15
  41. package/claude/hooks/.vulyk +0 -3
  42. package/claude/hooks/AGENTS.md +0 -3
  43. package/claude/hooks/CLAUDE.md +0 -1
  44. package/claude/hooks/claude-hooks.md +0 -30
  45. package/claude/hooks/notification.sh +0 -38
  46. package/claude/hooks/protect-files.sh +0 -21
  47. package/claude/hooks/status-line/index.js +0 -57
  48. package/claude/scripts/render-settings.ts +0 -223
  49. package/claude/settings.base.json +0 -38
  50. package/dist/claude/scripts/render-settings.js +0 -145
  51. package/dist/eslint/pasika/rules/organization-imports.d.ts +0 -2
  52. package/dist/eslint.config.js +0 -7
  53. package/dist/scripts/pasika.js +0 -59
  54. package/docs/agent-conventions.md +0 -27
  55. package/docs/claude/hooks.md +0 -30
  56. package/docs/code-organization-guide/code-organization-guide.md +0 -69
  57. package/docs/code-organization-guide/references/application-architecture-reference.md +0 -149
  58. package/docs/code-organization-guide/rules/component-placement-rule.md +0 -119
  59. package/docs/code-organization-guide/rules/configuration-rule.md +0 -42
  60. package/docs/code-organization-guide/rules/constants-rule.md +0 -74
  61. package/docs/code-organization-guide/rules/exports-and-imports-rule.md +0 -84
  62. package/docs/code-organization-guide/rules/folder-nesting-rule.md +0 -82
  63. package/docs/code-organization-guide/rules/hook-extraction-rule.md +0 -141
  64. package/docs/code-organization-guide/rules/interactive-component-rule.md +0 -97
  65. package/docs/code-organization-guide/rules/jsx-hygiene-rule.md +0 -67
  66. package/docs/code-organization-guide/rules/locales-rule.md +0 -53
  67. package/docs/code-organization-guide/rules/nameable-visual-concept-rule.md +0 -66
  68. package/docs/code-organization-guide/rules/no-mixed-concerns-rule.md +0 -63
  69. package/docs/code-organization-guide/rules/repeated-structure-rule.md +0 -93
  70. package/docs/code-organization-guide/rules/smart-vs-dumb-component-rule.md +0 -112
  71. package/docs/code-organization-guide/rules/sole-state-owner-rule.md +0 -101
  72. package/docs/code-organization-guide/rules/types-and-schemas-rule.md +0 -139
  73. package/docs/code-organization-guide/rules/utilities-rule.md +0 -86
  74. package/docs/documentation-guide/_templates/grouped-reference.md +0 -11
  75. package/docs/documentation-guide/_templates/guide.md +0 -19
  76. package/docs/documentation-guide/_templates/rule.md +0 -21
  77. package/docs/documentation-guide/_templates/single-lookup-reference.md +0 -5
  78. package/docs/documentation-guide/documentation-guide.md +0 -13
  79. package/docs/documentation-guide/references/documentation-types-reference.md +0 -9
  80. package/docs/documentation-guide/rules/guide-creation-rule.md +0 -113
  81. package/docs/documentation-guide/rules/reference-creation-rule.md +0 -132
  82. package/docs/documentation-guide/rules/rule-creation-rule.md +0 -81
  83. package/docs/documentation-guide/rules/template-usage-rule.md +0 -49
  84. package/docs/shadcn-theme.md +0 -121
  85. package/docs/styling-guide/rules/arbitrary-value-rule.md +0 -31
  86. package/docs/styling-guide/rules/class-composition-rule.md +0 -52
  87. package/docs/styling-guide/rules/component-ui-state-rule.md +0 -53
  88. package/docs/styling-guide/rules/component-variant-rule.md +0 -125
  89. package/docs/styling-guide/rules/global-stylesheet-rule.md +0 -67
  90. package/docs/styling-guide/rules/theme-and-utility-definition-rule.md +0 -86
  91. package/docs/styling-guide/styling-guide.md +0 -14
@@ -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
+ };
@@ -0,0 +1,9 @@
1
+ /**
2
+ * ESLint rule: pasika/enforce-barrel-exports
3
+ *
4
+ * Enforces the "Folder Nesting Rule" — index.ts only re-exports parent component.
5
+ *
6
+ * @see docs/code-organization-guide/rules/folder-nesting-rule.md
7
+ */
8
+ import type { Rule } from "eslint";
9
+ export declare const enforceBarrelExportsRule: Rule.RuleModule;
@@ -0,0 +1,78 @@
1
+ /**
2
+ * ESLint rule: pasika/enforce-barrel-exports
3
+ *
4
+ * Enforces the "Folder Nesting Rule" — index.ts only re-exports parent component.
5
+ *
6
+ * @see docs/code-organization-guide/rules/folder-nesting-rule.md
7
+ */
8
+ import path from "node:path";
9
+ import fs from "node:fs";
10
+ function isPascalCase(str) {
11
+ return /^[A-Z][A-Za-z0-9]*$/.test(str);
12
+ }
13
+ function isKebabCase(str) {
14
+ return /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(str);
15
+ }
16
+ const SUPPORT_FOLDERS = new Set(["types", "schemas", "hooks", "constants", "utils", "config", "locales"]);
17
+ export const enforceBarrelExportsRule = {
18
+ meta: {
19
+ schema: [],
20
+ type: "problem",
21
+ docs: {
22
+ description: "Enforce index.ts barrels only re-export the parent component.",
23
+ },
24
+ },
25
+ create(context) {
26
+ const filename = context.filename;
27
+ if (!filename)
28
+ return {};
29
+ const baseName = path.basename(filename);
30
+ if (baseName !== "index.ts" && baseName !== "index.cts" && baseName !== "index.mts")
31
+ return {};
32
+ const dirPath = path.dirname(filename);
33
+ const folderName = path.basename(dirPath);
34
+ const parentFolderName = path.basename(path.dirname(dirPath));
35
+ if (SUPPORT_FOLDERS.has(folderName))
36
+ return {};
37
+ if (!isPascalCase(folderName) && !isKebabCase(folderName))
38
+ return {};
39
+ const matchingTsx = fs.existsSync(path.join(dirPath, `${folderName}.tsx`)) ? folderName : null;
40
+ if (!matchingTsx)
41
+ return {};
42
+ if (!isPascalCase(parentFolderName) && !isKebabCase(parentFolderName))
43
+ return {};
44
+ const reExportedNames = new Set();
45
+ return {
46
+ ExportNamedDeclaration(node) {
47
+ if (!node.source)
48
+ return;
49
+ for (const spec of node.specifiers) {
50
+ if (spec.exported.type === "Identifier") {
51
+ reExportedNames.add(spec.exported.name);
52
+ }
53
+ }
54
+ },
55
+ "Program:exit"() {
56
+ if (reExportedNames.size === 0)
57
+ return;
58
+ if (!reExportedNames.has(matchingTsx)) {
59
+ context.report({
60
+ loc: { line: 1, column: 0 },
61
+ message: `index.ts in "${folderName}/" must re-export "${matchingTsx}". ` +
62
+ "See docs/code-organization-guide/rules/folder-nesting-rule.md",
63
+ });
64
+ return;
65
+ }
66
+ const nonParentExports = [...reExportedNames].filter((n) => n !== matchingTsx);
67
+ if (nonParentExports.length > 0) {
68
+ context.report({
69
+ loc: { line: 1, column: 0 },
70
+ message: `index.ts must not re-export exclusive children: ${nonParentExports.join(", ")}. ` +
71
+ `Only "${matchingTsx}" may be re-exported. ` +
72
+ "See docs/code-organization-guide/rules/folder-nesting-rule.md",
73
+ });
74
+ }
75
+ },
76
+ };
77
+ },
78
+ };
@@ -0,0 +1,9 @@
1
+ /**
2
+ * ESLint rule: pasika/enforce-cn-merge
3
+ *
4
+ * Enforces the "Class Composition Rule".
5
+ *
6
+ * @see docs/styling-guide/rules/class-composition-rule.md
7
+ */
8
+ import type { Rule } from "eslint";
9
+ export declare const enforceCnMergeRule: Rule.RuleModule;