@noctcore/eslint-plugin-architecture 0.2.0 → 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.
- package/README.md +1 -0
- package/dist/index.cjs +581 -3
- package/dist/index.d.cts +35 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.js +581 -3
- package/docs/rules/single-semantic-module.md +180 -0
- package/package.json +2 -2
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
import * as _typescript_eslint_utils_ts_eslint from '@typescript-eslint/utils/ts-eslint';
|
|
2
2
|
|
|
3
|
+
declare const SEMANTIC_CATEGORIES: readonly ["type", "constant", "function", "class", "react-component", "hook", "schema", "enum"];
|
|
4
|
+
type SemanticCategory = (typeof SEMANTIC_CATEGORIES)[number];
|
|
5
|
+
type EnumCategory = Extract<SemanticCategory, 'enum' | 'type'>;
|
|
6
|
+
type SchemaLibrary = 'zod' | 'yup' | 'valibot';
|
|
7
|
+
interface SingleSemanticModuleOptions {
|
|
8
|
+
/** Category sets a module may mix, e.g. `[['constant', 'type', 'enum']]`. */
|
|
9
|
+
readonly allow?: readonly (readonly SemanticCategory[])[];
|
|
10
|
+
/** Whether a TS `enum` is its own category or counts as `type`. */
|
|
11
|
+
readonly enumCategory?: EnumCategory;
|
|
12
|
+
/** List every classified declaration and why in the report. */
|
|
13
|
+
readonly debug?: boolean;
|
|
14
|
+
/** Skip `declare ...` and `declare global` blocks entirely. */
|
|
15
|
+
readonly ignoreAmbientDeclarations?: boolean;
|
|
16
|
+
/**
|
|
17
|
+
* Only the exported surface defines a module's semantics: a non-exported
|
|
18
|
+
* render helper, config object or class serves that surface and is not
|
|
19
|
+
* classified. Default true.
|
|
20
|
+
*/
|
|
21
|
+
readonly ignorePrivateDeclarations?: boolean;
|
|
22
|
+
readonly schemaLibraries?: readonly SchemaLibrary[];
|
|
23
|
+
readonly reactComponentDetection?: {
|
|
24
|
+
readonly enabled?: boolean;
|
|
25
|
+
};
|
|
26
|
+
readonly hookDetection?: {
|
|
27
|
+
readonly enabled?: boolean;
|
|
28
|
+
readonly namePattern?: string;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
3
32
|
interface NoCrossFeatureImportsOptions {
|
|
4
33
|
readonly featureRoot?: string;
|
|
5
34
|
readonly alias?: string;
|
|
@@ -58,6 +87,9 @@ declare const rules: {
|
|
|
58
87
|
'no-cross-feature-imports': _typescript_eslint_utils_ts_eslint.RuleModule<"crossFeatureImport", [NoCrossFeatureImportsOptions], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
|
|
59
88
|
name: string;
|
|
60
89
|
};
|
|
90
|
+
'single-semantic-module': _typescript_eslint_utils_ts_eslint.RuleModule<"mixedSemanticCategories", [SingleSemanticModuleOptions], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
|
|
91
|
+
name: string;
|
|
92
|
+
};
|
|
61
93
|
};
|
|
62
94
|
|
|
63
95
|
declare const plugin: {
|
|
@@ -87,6 +119,9 @@ declare const plugin: {
|
|
|
87
119
|
'no-cross-feature-imports': _typescript_eslint_utils_ts_eslint.RuleModule<"crossFeatureImport", [NoCrossFeatureImportsOptions], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
|
|
88
120
|
name: string;
|
|
89
121
|
};
|
|
122
|
+
'single-semantic-module': _typescript_eslint_utils_ts_eslint.RuleModule<"mixedSemanticCategories", [SingleSemanticModuleOptions], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
|
|
123
|
+
name: string;
|
|
124
|
+
};
|
|
90
125
|
};
|
|
91
126
|
configs: Record<string, unknown>;
|
|
92
127
|
};
|
package/dist/index.js
CHANGED
|
@@ -10,7 +10,12 @@ var recommended = {
|
|
|
10
10
|
"noctcore-architecture/filename-matches-export": "error",
|
|
11
11
|
"noctcore-architecture/index-must-reexport-default": "error",
|
|
12
12
|
"noctcore-architecture/max-import-depth": "error",
|
|
13
|
-
"noctcore-architecture/no-cross-feature-imports": "error"
|
|
13
|
+
"noctcore-architecture/no-cross-feature-imports": "error",
|
|
14
|
+
// Ships OFF: which files it governs and which category mixes they may keep
|
|
15
|
+
// (a NestJS `.constants.ts` legitimately holds constants, types and enums) is
|
|
16
|
+
// a per-codebase decision best made from measured counts. Enable it with
|
|
17
|
+
// 'noctcore-architecture/single-semantic-module': ['error', { allow: [['constant', 'type', 'enum']] }]
|
|
18
|
+
"noctcore-architecture/single-semantic-module": "off"
|
|
14
19
|
};
|
|
15
20
|
|
|
16
21
|
// src/rules/barrel-purity.ts
|
|
@@ -756,6 +761,578 @@ var noCrossFeatureImportsRule = createRule({
|
|
|
756
761
|
}
|
|
757
762
|
});
|
|
758
763
|
|
|
764
|
+
// src/semantic-module/classify.ts
|
|
765
|
+
import { AST_NODE_TYPES as AST_NODE_TYPES8 } from "@typescript-eslint/utils";
|
|
766
|
+
|
|
767
|
+
// src/semantic-module/ast.ts
|
|
768
|
+
import { AST_NODE_TYPES as AST_NODE_TYPES6 } from "@typescript-eslint/utils";
|
|
769
|
+
function getDeclarationName(node) {
|
|
770
|
+
if ("id" in node) {
|
|
771
|
+
const id = node.id;
|
|
772
|
+
if (isIdentifier(id)) {
|
|
773
|
+
return id.name;
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
return void 0;
|
|
777
|
+
}
|
|
778
|
+
function getVariableDeclaratorName(declarator) {
|
|
779
|
+
return declarator.id.type === AST_NODE_TYPES6.Identifier ? declarator.id.name : void 0;
|
|
780
|
+
}
|
|
781
|
+
function isWrapperExpression(expression) {
|
|
782
|
+
return expression.type === AST_NODE_TYPES6.TSAsExpression || expression.type === AST_NODE_TYPES6.TSTypeAssertion || expression.type === AST_NODE_TYPES6.TSNonNullExpression || expression.type === AST_NODE_TYPES6.TSSatisfiesExpression || expression.type === AST_NODE_TYPES6.TSInstantiationExpression;
|
|
783
|
+
}
|
|
784
|
+
function unwrapExpression(expression) {
|
|
785
|
+
let current = expression;
|
|
786
|
+
while (isWrapperExpression(current)) {
|
|
787
|
+
current = current.expression;
|
|
788
|
+
}
|
|
789
|
+
return current;
|
|
790
|
+
}
|
|
791
|
+
function isAmbientDeclaration(node) {
|
|
792
|
+
if ("declare" in node && node.declare === true) {
|
|
793
|
+
return true;
|
|
794
|
+
}
|
|
795
|
+
return node.type === AST_NODE_TYPES6.TSModuleDeclaration && node.kind === "global";
|
|
796
|
+
}
|
|
797
|
+
function functionReturnsJsx(node) {
|
|
798
|
+
if (node.type === AST_NODE_TYPES6.ArrowFunctionExpression) {
|
|
799
|
+
if (!node.expression && node.body.type === AST_NODE_TYPES6.BlockStatement) {
|
|
800
|
+
return blockReturnsJsx(node.body);
|
|
801
|
+
}
|
|
802
|
+
return containsJsx(node.body);
|
|
803
|
+
}
|
|
804
|
+
return blockReturnsJsx(node.body);
|
|
805
|
+
}
|
|
806
|
+
function blockReturnsJsx(block) {
|
|
807
|
+
return containsNode(block, (node) => {
|
|
808
|
+
if (node.type !== AST_NODE_TYPES6.ReturnStatement || !node.argument) {
|
|
809
|
+
return false;
|
|
810
|
+
}
|
|
811
|
+
return containsJsx(node.argument);
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
function containsJsx(node) {
|
|
815
|
+
return containsNode(
|
|
816
|
+
node,
|
|
817
|
+
(candidate) => candidate.type === AST_NODE_TYPES6.JSXElement || candidate.type === AST_NODE_TYPES6.JSXFragment
|
|
818
|
+
);
|
|
819
|
+
}
|
|
820
|
+
var SKIPPED_KEYS = /* @__PURE__ */ new Set(["parent", "loc", "range", "tokens", "comments"]);
|
|
821
|
+
function containsNode(root, predicate) {
|
|
822
|
+
const stack = [root];
|
|
823
|
+
while (stack.length > 0) {
|
|
824
|
+
const current = stack.pop();
|
|
825
|
+
if (!current) {
|
|
826
|
+
continue;
|
|
827
|
+
}
|
|
828
|
+
if (predicate(current)) {
|
|
829
|
+
return true;
|
|
830
|
+
}
|
|
831
|
+
for (const [key, value] of Object.entries(current)) {
|
|
832
|
+
if (SKIPPED_KEYS.has(key)) {
|
|
833
|
+
continue;
|
|
834
|
+
}
|
|
835
|
+
if (Array.isArray(value)) {
|
|
836
|
+
for (const item of value) {
|
|
837
|
+
if (isNodeLike(item)) {
|
|
838
|
+
stack.push(item);
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
continue;
|
|
842
|
+
}
|
|
843
|
+
if (isNodeLike(value)) {
|
|
844
|
+
stack.push(value);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
return false;
|
|
849
|
+
}
|
|
850
|
+
function isNodeLike(value) {
|
|
851
|
+
return typeof value === "object" && value !== null && "type" in value && typeof value.type === "string";
|
|
852
|
+
}
|
|
853
|
+
function isIdentifier(value) {
|
|
854
|
+
return isNodeLike(value) && value.type === AST_NODE_TYPES6.Identifier;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// src/semantic-module/classifiers.ts
|
|
858
|
+
import { AST_NODE_TYPES as AST_NODE_TYPES7 } from "@typescript-eslint/utils";
|
|
859
|
+
function isHookName(name, options) {
|
|
860
|
+
if (!options.hookDetection.enabled || !name) {
|
|
861
|
+
return false;
|
|
862
|
+
}
|
|
863
|
+
return options.hookDetection.namePattern.test(name);
|
|
864
|
+
}
|
|
865
|
+
function isReactComponentName(name) {
|
|
866
|
+
return Boolean(name && /^[A-Z][A-Za-z0-9]*$/u.test(name));
|
|
867
|
+
}
|
|
868
|
+
function isReactComponentFunction(node, name, options, isDefaultExport = false) {
|
|
869
|
+
if (!options.reactComponentDetection.enabled) {
|
|
870
|
+
return false;
|
|
871
|
+
}
|
|
872
|
+
if (!isReactComponentName(name) && !isDefaultExport) {
|
|
873
|
+
return false;
|
|
874
|
+
}
|
|
875
|
+
if (node.returnType && typeReferencesJsxValue(node.returnType.typeAnnotation)) {
|
|
876
|
+
return true;
|
|
877
|
+
}
|
|
878
|
+
return functionReturnsJsx(node);
|
|
879
|
+
}
|
|
880
|
+
function isReactComponentVariable(declarator, options) {
|
|
881
|
+
if (!options.reactComponentDetection.enabled) {
|
|
882
|
+
return false;
|
|
883
|
+
}
|
|
884
|
+
const name = getVariableDeclaratorName(declarator);
|
|
885
|
+
if (!isReactComponentName(name)) {
|
|
886
|
+
return false;
|
|
887
|
+
}
|
|
888
|
+
if (declarator.id.type === AST_NODE_TYPES7.Identifier && declarator.id.typeAnnotation && typeReferencesReactComponent(declarator.id.typeAnnotation.typeAnnotation)) {
|
|
889
|
+
return true;
|
|
890
|
+
}
|
|
891
|
+
if (!declarator.init) {
|
|
892
|
+
return false;
|
|
893
|
+
}
|
|
894
|
+
if (declarator.init.type === AST_NODE_TYPES7.ArrowFunctionExpression || declarator.init.type === AST_NODE_TYPES7.FunctionExpression) {
|
|
895
|
+
return isReactComponentFunction(declarator.init, name, options);
|
|
896
|
+
}
|
|
897
|
+
return containsJsx(declarator.init);
|
|
898
|
+
}
|
|
899
|
+
var REACT_COMPONENT_TYPES = /* @__PURE__ */ new Set([
|
|
900
|
+
"FC",
|
|
901
|
+
"FunctionComponent",
|
|
902
|
+
"React.FC",
|
|
903
|
+
"React.FunctionComponent"
|
|
904
|
+
]);
|
|
905
|
+
var JSX_VALUE_TYPES = /* @__PURE__ */ new Set([
|
|
906
|
+
"JSX.Element",
|
|
907
|
+
"React.ReactElement",
|
|
908
|
+
"React.ReactNode"
|
|
909
|
+
]);
|
|
910
|
+
function typeReferencesReactComponent(node) {
|
|
911
|
+
return containsNode(
|
|
912
|
+
node,
|
|
913
|
+
(candidate) => candidate.type === AST_NODE_TYPES7.TSTypeReference && REACT_COMPONENT_TYPES.has(entityNameToString(candidate.typeName))
|
|
914
|
+
);
|
|
915
|
+
}
|
|
916
|
+
function typeReferencesJsxValue(node) {
|
|
917
|
+
return containsNode(
|
|
918
|
+
node,
|
|
919
|
+
(candidate) => candidate.type === AST_NODE_TYPES7.TSTypeReference && JSX_VALUE_TYPES.has(entityNameToString(candidate.typeName))
|
|
920
|
+
);
|
|
921
|
+
}
|
|
922
|
+
function entityNameToString(entityName) {
|
|
923
|
+
if (entityName.type === AST_NODE_TYPES7.Identifier) {
|
|
924
|
+
return entityName.name;
|
|
925
|
+
}
|
|
926
|
+
if (entityName.type === AST_NODE_TYPES7.TSQualifiedName) {
|
|
927
|
+
return `${entityNameToString(entityName.left)}.${entityName.right.name}`;
|
|
928
|
+
}
|
|
929
|
+
return "this";
|
|
930
|
+
}
|
|
931
|
+
var SCHEMA_LIBRARY_MODULES = {
|
|
932
|
+
zod: ["zod"],
|
|
933
|
+
yup: ["yup"],
|
|
934
|
+
valibot: ["valibot"]
|
|
935
|
+
};
|
|
936
|
+
var SCHEMA_BUILDER_NAMES = /* @__PURE__ */ new Set([
|
|
937
|
+
"array",
|
|
938
|
+
"boolean",
|
|
939
|
+
"date",
|
|
940
|
+
"enum",
|
|
941
|
+
"literal",
|
|
942
|
+
"number",
|
|
943
|
+
"object",
|
|
944
|
+
"record",
|
|
945
|
+
"string",
|
|
946
|
+
"tuple",
|
|
947
|
+
"union"
|
|
948
|
+
]);
|
|
949
|
+
function collectSchemaImportContext(program, options) {
|
|
950
|
+
const namespaceIdentifiers = /* @__PURE__ */ new Set();
|
|
951
|
+
const builderIdentifiers = /* @__PURE__ */ new Set();
|
|
952
|
+
const enabledModules = new Set(
|
|
953
|
+
options.schemaLibraries.flatMap((library) => SCHEMA_LIBRARY_MODULES[library])
|
|
954
|
+
);
|
|
955
|
+
for (const statement of program.body) {
|
|
956
|
+
if (statement.type !== AST_NODE_TYPES7.ImportDeclaration || statement.importKind === "type" || !enabledModules.has(String(statement.source.value))) {
|
|
957
|
+
continue;
|
|
958
|
+
}
|
|
959
|
+
for (const specifier of statement.specifiers) {
|
|
960
|
+
if (specifier.type === AST_NODE_TYPES7.ImportNamespaceSpecifier || specifier.type === AST_NODE_TYPES7.ImportDefaultSpecifier) {
|
|
961
|
+
namespaceIdentifiers.add(specifier.local.name);
|
|
962
|
+
continue;
|
|
963
|
+
}
|
|
964
|
+
if (specifier.importKind === "type") {
|
|
965
|
+
continue;
|
|
966
|
+
}
|
|
967
|
+
const importedName = specifier.imported.type === AST_NODE_TYPES7.Identifier ? specifier.imported.name : String(specifier.imported.value);
|
|
968
|
+
if (importedName === "z") {
|
|
969
|
+
namespaceIdentifiers.add(specifier.local.name);
|
|
970
|
+
}
|
|
971
|
+
if (SCHEMA_BUILDER_NAMES.has(importedName)) {
|
|
972
|
+
builderIdentifiers.add(specifier.local.name);
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
return { namespaceIdentifiers, builderIdentifiers };
|
|
977
|
+
}
|
|
978
|
+
function isSchemaExpression(expression, context) {
|
|
979
|
+
const unwrapped = unwrapExpression(expression);
|
|
980
|
+
if (unwrapped.type !== AST_NODE_TYPES7.CallExpression) {
|
|
981
|
+
return false;
|
|
982
|
+
}
|
|
983
|
+
const rootName = expressionRootIdentifier(unwrapped.callee);
|
|
984
|
+
if (!rootName) {
|
|
985
|
+
return false;
|
|
986
|
+
}
|
|
987
|
+
return context.namespaceIdentifiers.has(rootName) || context.builderIdentifiers.has(rootName);
|
|
988
|
+
}
|
|
989
|
+
function expressionRootIdentifier(node) {
|
|
990
|
+
switch (node.type) {
|
|
991
|
+
case AST_NODE_TYPES7.Identifier:
|
|
992
|
+
return node.name;
|
|
993
|
+
case AST_NODE_TYPES7.MemberExpression:
|
|
994
|
+
return expressionRootIdentifier(node.object);
|
|
995
|
+
case AST_NODE_TYPES7.CallExpression:
|
|
996
|
+
return expressionRootIdentifier(node.callee);
|
|
997
|
+
case AST_NODE_TYPES7.ChainExpression:
|
|
998
|
+
return expressionRootIdentifier(node.expression);
|
|
999
|
+
default:
|
|
1000
|
+
return null;
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
function getConstantReason(expression) {
|
|
1004
|
+
if (!expression) {
|
|
1005
|
+
return "top-level variable declaration without initializer";
|
|
1006
|
+
}
|
|
1007
|
+
switch (unwrapExpression(expression).type) {
|
|
1008
|
+
case AST_NODE_TYPES7.Literal:
|
|
1009
|
+
return "literal runtime value";
|
|
1010
|
+
case AST_NODE_TYPES7.ObjectExpression:
|
|
1011
|
+
return "object literal runtime value";
|
|
1012
|
+
case AST_NODE_TYPES7.ArrayExpression:
|
|
1013
|
+
return "array literal runtime value";
|
|
1014
|
+
case AST_NODE_TYPES7.TemplateLiteral:
|
|
1015
|
+
return "template literal runtime value";
|
|
1016
|
+
case AST_NODE_TYPES7.CallExpression:
|
|
1017
|
+
return "computed top-level runtime value";
|
|
1018
|
+
default:
|
|
1019
|
+
return "top-level runtime value";
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
// src/semantic-module/options.ts
|
|
1024
|
+
var SEMANTIC_CATEGORIES = [
|
|
1025
|
+
"type",
|
|
1026
|
+
"constant",
|
|
1027
|
+
"function",
|
|
1028
|
+
"class",
|
|
1029
|
+
"react-component",
|
|
1030
|
+
"hook",
|
|
1031
|
+
"schema",
|
|
1032
|
+
"enum"
|
|
1033
|
+
];
|
|
1034
|
+
var SCHEMA_LIBRARIES = ["zod", "yup", "valibot"];
|
|
1035
|
+
function sortCategories(categories) {
|
|
1036
|
+
const categorySet = new Set(categories);
|
|
1037
|
+
return SEMANTIC_CATEGORIES.filter((category) => categorySet.has(category));
|
|
1038
|
+
}
|
|
1039
|
+
var DEFAULT_HOOK_NAME_PATTERN = "^use[A-Z0-9].*";
|
|
1040
|
+
var DEFAULT_OPTIONS = {
|
|
1041
|
+
allow: [],
|
|
1042
|
+
enumCategory: "enum",
|
|
1043
|
+
debug: false,
|
|
1044
|
+
ignoreAmbientDeclarations: false,
|
|
1045
|
+
ignorePrivateDeclarations: true,
|
|
1046
|
+
schemaLibraries: SCHEMA_LIBRARIES,
|
|
1047
|
+
reactComponentDetection: { enabled: true },
|
|
1048
|
+
hookDetection: { enabled: true, namePattern: DEFAULT_HOOK_NAME_PATTERN }
|
|
1049
|
+
};
|
|
1050
|
+
function normalizeOptions(options) {
|
|
1051
|
+
return {
|
|
1052
|
+
allow: options.allow ?? DEFAULT_OPTIONS.allow,
|
|
1053
|
+
enumCategory: options.enumCategory ?? DEFAULT_OPTIONS.enumCategory,
|
|
1054
|
+
debug: options.debug ?? DEFAULT_OPTIONS.debug,
|
|
1055
|
+
ignoreAmbientDeclarations: options.ignoreAmbientDeclarations ?? DEFAULT_OPTIONS.ignoreAmbientDeclarations,
|
|
1056
|
+
ignorePrivateDeclarations: options.ignorePrivateDeclarations ?? DEFAULT_OPTIONS.ignorePrivateDeclarations,
|
|
1057
|
+
schemaLibraries: options.schemaLibraries ?? DEFAULT_OPTIONS.schemaLibraries,
|
|
1058
|
+
reactComponentDetection: { enabled: options.reactComponentDetection?.enabled ?? true },
|
|
1059
|
+
hookDetection: {
|
|
1060
|
+
enabled: options.hookDetection?.enabled ?? true,
|
|
1061
|
+
namePattern: compilePattern(options.hookDetection?.namePattern ?? DEFAULT_HOOK_NAME_PATTERN)
|
|
1062
|
+
}
|
|
1063
|
+
};
|
|
1064
|
+
}
|
|
1065
|
+
function compilePattern(pattern) {
|
|
1066
|
+
try {
|
|
1067
|
+
return new RegExp(pattern);
|
|
1068
|
+
} catch (error) {
|
|
1069
|
+
throw new Error(
|
|
1070
|
+
`single-semantic-module: hookDetection.namePattern ${JSON.stringify(pattern)} is not a valid regular expression (${String(error)}).`
|
|
1071
|
+
);
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
function isCategorySetAllowed(categories, allow) {
|
|
1075
|
+
if (categories.size <= 1) {
|
|
1076
|
+
return true;
|
|
1077
|
+
}
|
|
1078
|
+
const detected = [...categories];
|
|
1079
|
+
return allow.some((group) => {
|
|
1080
|
+
const allowed = new Set(group);
|
|
1081
|
+
return detected.every((category) => allowed.has(category));
|
|
1082
|
+
});
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
// src/semantic-module/classify.ts
|
|
1086
|
+
function analyzeSemanticModule(program, rawOptions) {
|
|
1087
|
+
const options = normalizeOptions(rawOptions);
|
|
1088
|
+
const context = {
|
|
1089
|
+
options,
|
|
1090
|
+
schemaImports: collectSchemaImportContext(program, options),
|
|
1091
|
+
exportedNames: collectLocallyExportedNames(program)
|
|
1092
|
+
};
|
|
1093
|
+
const classifications = program.body.flatMap(
|
|
1094
|
+
(statement) => classifyTopLevelStatement(statement, context)
|
|
1095
|
+
);
|
|
1096
|
+
return {
|
|
1097
|
+
categories: new Set(classifications.map((classification2) => classification2.category)),
|
|
1098
|
+
classifications,
|
|
1099
|
+
options
|
|
1100
|
+
};
|
|
1101
|
+
}
|
|
1102
|
+
function collectLocallyExportedNames(program) {
|
|
1103
|
+
const names = /* @__PURE__ */ new Set();
|
|
1104
|
+
for (const statement of program.body) {
|
|
1105
|
+
if (statement.type === AST_NODE_TYPES8.ExportNamedDeclaration && statement.source === null && statement.declaration === null) {
|
|
1106
|
+
for (const specifier of statement.specifiers) {
|
|
1107
|
+
if (specifier.local.type === AST_NODE_TYPES8.Identifier) {
|
|
1108
|
+
names.add(specifier.local.name);
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
} else if (statement.type === AST_NODE_TYPES8.ExportDefaultDeclaration && statement.declaration.type === AST_NODE_TYPES8.Identifier) {
|
|
1112
|
+
names.add(statement.declaration.name);
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
return names;
|
|
1116
|
+
}
|
|
1117
|
+
function classifyTopLevelStatement(statement, context) {
|
|
1118
|
+
switch (statement.type) {
|
|
1119
|
+
case AST_NODE_TYPES8.ImportDeclaration:
|
|
1120
|
+
case AST_NODE_TYPES8.EmptyStatement:
|
|
1121
|
+
case AST_NODE_TYPES8.ExportAllDeclaration:
|
|
1122
|
+
return [];
|
|
1123
|
+
case AST_NODE_TYPES8.ExportNamedDeclaration:
|
|
1124
|
+
return statement.declaration ? classifyDeclarationLike(statement.declaration, context) : [];
|
|
1125
|
+
case AST_NODE_TYPES8.ExportDefaultDeclaration:
|
|
1126
|
+
return classifyDeclarationLike(statement.declaration, { ...context, isDefaultExport: true });
|
|
1127
|
+
default:
|
|
1128
|
+
if (!context.options.ignorePrivateDeclarations) {
|
|
1129
|
+
return classifyDeclarationLike(statement, context);
|
|
1130
|
+
}
|
|
1131
|
+
return classifyExportedByName(statement, context);
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
function classifyExportedByName(statement, context) {
|
|
1135
|
+
if (context.exportedNames.size === 0) {
|
|
1136
|
+
return [];
|
|
1137
|
+
}
|
|
1138
|
+
if (statement.type === AST_NODE_TYPES8.VariableDeclaration) {
|
|
1139
|
+
const exported = statement.declarations.filter((declarator) => {
|
|
1140
|
+
const name2 = getVariableDeclaratorName(declarator);
|
|
1141
|
+
return name2 !== void 0 && context.exportedNames.has(name2);
|
|
1142
|
+
});
|
|
1143
|
+
return exported.map((declarator) => classifyVariableDeclarator(declarator, context));
|
|
1144
|
+
}
|
|
1145
|
+
const name = getDeclarationName(statement);
|
|
1146
|
+
return name !== void 0 && context.exportedNames.has(name) ? classifyDeclarationLike(statement, context) : [];
|
|
1147
|
+
}
|
|
1148
|
+
function classifyDeclarationLike(node, context) {
|
|
1149
|
+
if (isAmbientDeclaration(node)) {
|
|
1150
|
+
return context.options.ignoreAmbientDeclarations ? [] : [classification("type", node, getDeclarationName(node), "ambient declaration")];
|
|
1151
|
+
}
|
|
1152
|
+
switch (node.type) {
|
|
1153
|
+
case AST_NODE_TYPES8.TSInterfaceDeclaration:
|
|
1154
|
+
case AST_NODE_TYPES8.TSTypeAliasDeclaration:
|
|
1155
|
+
case AST_NODE_TYPES8.TSModuleDeclaration:
|
|
1156
|
+
return [
|
|
1157
|
+
classification("type", node, getDeclarationName(node), "TypeScript type-space declaration")
|
|
1158
|
+
];
|
|
1159
|
+
case AST_NODE_TYPES8.TSEnumDeclaration:
|
|
1160
|
+
return [
|
|
1161
|
+
classification(
|
|
1162
|
+
context.options.enumCategory,
|
|
1163
|
+
node,
|
|
1164
|
+
getDeclarationName(node),
|
|
1165
|
+
context.options.enumCategory === "type" ? "enum configured as type" : "enum declaration"
|
|
1166
|
+
)
|
|
1167
|
+
];
|
|
1168
|
+
case AST_NODE_TYPES8.ClassDeclaration:
|
|
1169
|
+
return [classification("class", node, getDeclarationName(node), "class declaration")];
|
|
1170
|
+
case AST_NODE_TYPES8.FunctionDeclaration:
|
|
1171
|
+
return [classifyFunction(node, getDeclarationName(node), context, "function declaration")];
|
|
1172
|
+
case AST_NODE_TYPES8.VariableDeclaration:
|
|
1173
|
+
return node.declarations.map((declarator) => classifyVariableDeclarator(declarator, context));
|
|
1174
|
+
case AST_NODE_TYPES8.ArrowFunctionExpression:
|
|
1175
|
+
case AST_NODE_TYPES8.FunctionExpression:
|
|
1176
|
+
return [classifyFunction(node, void 0, context, "function expression")];
|
|
1177
|
+
case AST_NODE_TYPES8.ClassExpression:
|
|
1178
|
+
return [classification("class", node, getDeclarationName(node), "class expression")];
|
|
1179
|
+
case AST_NODE_TYPES8.CallExpression:
|
|
1180
|
+
case AST_NODE_TYPES8.ArrayExpression:
|
|
1181
|
+
case AST_NODE_TYPES8.ObjectExpression:
|
|
1182
|
+
case AST_NODE_TYPES8.Literal:
|
|
1183
|
+
case AST_NODE_TYPES8.TemplateLiteral:
|
|
1184
|
+
return [classifyDefaultExpression(node, context)];
|
|
1185
|
+
case AST_NODE_TYPES8.TSDeclareFunction:
|
|
1186
|
+
return [
|
|
1187
|
+
classification("function", node, getDeclarationName(node), "function overload signature")
|
|
1188
|
+
];
|
|
1189
|
+
default:
|
|
1190
|
+
return [];
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
function classifyFunction(node, name, context, reason) {
|
|
1194
|
+
if (isHookName(name, context.options)) {
|
|
1195
|
+
return classification("hook", node, name, "function name matches hook pattern");
|
|
1196
|
+
}
|
|
1197
|
+
if (isReactComponentFunction(node, name, context.options, context.isDefaultExport === true)) {
|
|
1198
|
+
return classification(
|
|
1199
|
+
"react-component",
|
|
1200
|
+
node,
|
|
1201
|
+
name,
|
|
1202
|
+
`${reason === "function declaration" ? "function component" : "function expression"} returns JSX or React element`
|
|
1203
|
+
);
|
|
1204
|
+
}
|
|
1205
|
+
return classification("function", node, name, reason);
|
|
1206
|
+
}
|
|
1207
|
+
function classifyVariableDeclarator(declarator, context) {
|
|
1208
|
+
const name = getVariableDeclaratorName(declarator);
|
|
1209
|
+
const init = declarator.init ? unwrapExpression(declarator.init) : null;
|
|
1210
|
+
if (init && isSchemaExpression(init, context.schemaImports)) {
|
|
1211
|
+
return classification("schema", declarator, name, "schema builder expression");
|
|
1212
|
+
}
|
|
1213
|
+
if (isReactComponentVariable(declarator, context.options)) {
|
|
1214
|
+
return classification("react-component", declarator, name, "React component variable");
|
|
1215
|
+
}
|
|
1216
|
+
if (isHookName(name, context.options)) {
|
|
1217
|
+
return classification("hook", declarator, name, "variable name matches hook pattern");
|
|
1218
|
+
}
|
|
1219
|
+
if (init?.type === AST_NODE_TYPES8.ArrowFunctionExpression || init?.type === AST_NODE_TYPES8.FunctionExpression) {
|
|
1220
|
+
return classifyFunction(init, name, context, "function expression");
|
|
1221
|
+
}
|
|
1222
|
+
if (init?.type === AST_NODE_TYPES8.ClassExpression) {
|
|
1223
|
+
return classification("class", declarator, name, "class expression");
|
|
1224
|
+
}
|
|
1225
|
+
return classification("constant", declarator, name, getConstantReason(init));
|
|
1226
|
+
}
|
|
1227
|
+
function classifyDefaultExpression(expression, context) {
|
|
1228
|
+
const unwrapped = unwrapExpression(expression);
|
|
1229
|
+
if (isSchemaExpression(unwrapped, context.schemaImports)) {
|
|
1230
|
+
return classification("schema", expression, void 0, "default schema expression");
|
|
1231
|
+
}
|
|
1232
|
+
if (unwrapped.type === AST_NODE_TYPES8.ArrowFunctionExpression || unwrapped.type === AST_NODE_TYPES8.FunctionExpression) {
|
|
1233
|
+
return classifyFunction(unwrapped, void 0, context, "function expression");
|
|
1234
|
+
}
|
|
1235
|
+
if (unwrapped.type === AST_NODE_TYPES8.ClassExpression) {
|
|
1236
|
+
return classification("class", expression, void 0, "default class expression");
|
|
1237
|
+
}
|
|
1238
|
+
return classification("constant", expression, void 0, getConstantReason(unwrapped));
|
|
1239
|
+
}
|
|
1240
|
+
function classification(category, node, declarationName, reason) {
|
|
1241
|
+
return declarationName ? { category, node, reason, declarationName } : { category, node, reason };
|
|
1242
|
+
}
|
|
1243
|
+
function buildMixedCategoriesMessage(classifications, debug) {
|
|
1244
|
+
const categories = sortCategories(classifications.map((entry) => entry.category));
|
|
1245
|
+
const lines = [
|
|
1246
|
+
"Mixed semantic categories detected in module:",
|
|
1247
|
+
...categories.map((category) => `- ${category}`)
|
|
1248
|
+
];
|
|
1249
|
+
if (debug) {
|
|
1250
|
+
lines.push("", "Detected declarations:");
|
|
1251
|
+
for (const entry of classifications) {
|
|
1252
|
+
lines.push(`- ${entry.category}: ${entry.declarationName ?? "<anonymous>"} (${entry.reason})`);
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
lines.push(
|
|
1256
|
+
"",
|
|
1257
|
+
"A module must contain only one semantic concern.",
|
|
1258
|
+
"Move declarations into separate files/modules."
|
|
1259
|
+
);
|
|
1260
|
+
return lines.join("\n");
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
// src/rules/single-semantic-module.ts
|
|
1264
|
+
var RULE_NAME8 = "single-semantic-module";
|
|
1265
|
+
var optionSchema8 = {
|
|
1266
|
+
type: "object",
|
|
1267
|
+
additionalProperties: false,
|
|
1268
|
+
properties: {
|
|
1269
|
+
allow: {
|
|
1270
|
+
type: "array",
|
|
1271
|
+
items: {
|
|
1272
|
+
type: "array",
|
|
1273
|
+
minItems: 2,
|
|
1274
|
+
uniqueItems: true,
|
|
1275
|
+
items: { type: "string", enum: [...SEMANTIC_CATEGORIES] }
|
|
1276
|
+
}
|
|
1277
|
+
},
|
|
1278
|
+
enumCategory: { type: "string", enum: ["enum", "type"] },
|
|
1279
|
+
debug: { type: "boolean" },
|
|
1280
|
+
ignoreAmbientDeclarations: { type: "boolean" },
|
|
1281
|
+
ignorePrivateDeclarations: { type: "boolean" },
|
|
1282
|
+
schemaLibraries: {
|
|
1283
|
+
type: "array",
|
|
1284
|
+
uniqueItems: true,
|
|
1285
|
+
items: { type: "string", enum: [...SCHEMA_LIBRARIES] }
|
|
1286
|
+
},
|
|
1287
|
+
reactComponentDetection: {
|
|
1288
|
+
type: "object",
|
|
1289
|
+
additionalProperties: false,
|
|
1290
|
+
properties: { enabled: { type: "boolean" } }
|
|
1291
|
+
},
|
|
1292
|
+
hookDetection: {
|
|
1293
|
+
type: "object",
|
|
1294
|
+
additionalProperties: false,
|
|
1295
|
+
properties: {
|
|
1296
|
+
enabled: { type: "boolean" },
|
|
1297
|
+
namePattern: { type: "string" }
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
};
|
|
1302
|
+
var singleSemanticModuleRule = createRule({
|
|
1303
|
+
name: RULE_NAME8,
|
|
1304
|
+
meta: {
|
|
1305
|
+
type: "suggestion",
|
|
1306
|
+
docs: {
|
|
1307
|
+
description: "Require each module to export only one semantic concern (types, constants, functions, classes, components, hooks, schemas or enums)."
|
|
1308
|
+
},
|
|
1309
|
+
schema: [optionSchema8],
|
|
1310
|
+
messages: {
|
|
1311
|
+
mixedSemanticCategories: "{{message}}"
|
|
1312
|
+
}
|
|
1313
|
+
},
|
|
1314
|
+
defaultOptions: [DEFAULT_OPTIONS],
|
|
1315
|
+
create(context, [options]) {
|
|
1316
|
+
return {
|
|
1317
|
+
Program(program) {
|
|
1318
|
+
const analysis = analyzeSemanticModule(program, options);
|
|
1319
|
+
if (isCategorySetAllowed(analysis.categories, analysis.options.allow)) {
|
|
1320
|
+
return;
|
|
1321
|
+
}
|
|
1322
|
+
const [first] = analysis.classifications;
|
|
1323
|
+
const reportNode = analysis.classifications.find((entry) => entry.category !== first?.category)?.node ?? program;
|
|
1324
|
+
context.report({
|
|
1325
|
+
node: reportNode,
|
|
1326
|
+
messageId: "mixedSemanticCategories",
|
|
1327
|
+
data: {
|
|
1328
|
+
message: buildMixedCategoriesMessage(analysis.classifications, analysis.options.debug)
|
|
1329
|
+
}
|
|
1330
|
+
});
|
|
1331
|
+
}
|
|
1332
|
+
};
|
|
1333
|
+
}
|
|
1334
|
+
});
|
|
1335
|
+
|
|
759
1336
|
// src/rules/index.ts
|
|
760
1337
|
var rules = {
|
|
761
1338
|
"barrel-purity": barrelPurityRule,
|
|
@@ -764,12 +1341,13 @@ var rules = {
|
|
|
764
1341
|
"filename-matches-export": filenameMatchesExportRule,
|
|
765
1342
|
"index-must-reexport-default": indexMustReexportDefaultRule,
|
|
766
1343
|
"max-import-depth": maxImportDepthRule,
|
|
767
|
-
"no-cross-feature-imports": noCrossFeatureImportsRule
|
|
1344
|
+
"no-cross-feature-imports": noCrossFeatureImportsRule,
|
|
1345
|
+
"single-semantic-module": singleSemanticModuleRule
|
|
768
1346
|
};
|
|
769
1347
|
|
|
770
1348
|
// src/index.ts
|
|
771
1349
|
var NAMESPACE = "noctcore-architecture";
|
|
772
|
-
var VERSION = "0.
|
|
1350
|
+
var VERSION = "0.3.0";
|
|
773
1351
|
var plugin = {
|
|
774
1352
|
meta: { name: "@noctcore/eslint-plugin-architecture", version: VERSION },
|
|
775
1353
|
rules,
|