@clearlydev/nx-archweave 0.1.1 → 0.2.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 +1 -1
- package/dist/eslint/fsd-boundaries.d.ts +25 -8
- package/dist/eslint/fsd-policy.d.ts +38 -0
- package/dist/eslint/index.d.ts +10 -9
- package/dist/eslint/index.js +227 -34
- package/dist/eslint/slice-groups.d.ts +12 -0
- package/dist/generators/add-agent-rules/generator.js +1175 -23
- package/dist/generators/add-backend/generator.js +1127 -75
- package/dist/generators/add-fsd-lite/generator.js +1274 -96
- package/dist/generators/add-review/files/dependabot.yml +7 -0
- package/dist/generators/add-review/generator.js +1100 -46
- package/dist/generators/doctor/generator.js +780 -96
- package/dist/generators/entity/generator.js +251 -32
- package/dist/generators/feature/generator.js +251 -32
- package/dist/generators/init/generator.js +1955 -549
- package/dist/generators/page/generator.js +251 -32
- package/dist/generators/shared/generator.js +251 -32
- package/dist/generators/widget/generator.js +251 -32
- package/package.json +4 -1
- package/src/generators/add-agent-rules/schema.json +5 -0
- package/src/generators/add-fsd-lite/schema.json +2 -2
- package/src/generators/entity/schema.json +11 -1
- package/src/generators/feature/schema.json +11 -1
- package/src/generators/init/schema.json +13 -2
- package/src/generators/page/schema.json +15 -3
- package/src/generators/shared/schema.json +7 -1
- package/src/generators/widget/schema.json +11 -1
- package/dist/eslint/fsd-boundaries.js +0 -144
- package/dist/eslint/tsconfig-aliases.js +0 -114
- package/dist/generators/add-agent-rules/render.js +0 -46
- package/dist/generators/add-backend/render.js +0 -102
- package/dist/generators/add-fsd-lite/render.js +0 -72
- package/dist/generators/add-github-templates/render.js +0 -26
- package/dist/generators/add-integration/render.js +0 -34
- package/dist/generators/add-review/render.js +0 -96
- package/dist/generators/allow-builds/render.js +0 -82
- package/dist/generators/doctor/integration-report.js +0 -70
- package/dist/generators/doctor/render.js +0 -10
- package/dist/generators/init/render.js +0 -125
- package/dist/generators/init/validation.js +0 -74
- package/dist/generators/install-integration/render.js +0 -128
- package/dist/generators/record-integration/render.js +0 -31
- package/dist/generators/shared/dry-run.js +0 -12
- package/dist/generators/shared/frontend-app.js +0 -57
- package/dist/generators/shared/project-type.js +0 -39
- package/dist/generators/shared/record-pipeline.js +0 -34
- package/dist/generators/shared/run-nx.js +0 -24
- package/dist/generators/shared/template-files.js +0 -38
- package/dist/generators/shared/toolkit-config-file.js +0 -110
- package/dist/generators/slice/core.js +0 -185
- package/dist/generators/slice/render.js +0 -70
- package/dist/lib/nx-plugin.js +0 -7
- package/dist/runtime/integrations-composition-root.js +0 -20
- package/dist/runtime/node-command-runner.js +0 -81
package/README.md
CHANGED
|
@@ -49,7 +49,7 @@ pnpm nx g @clearlydev/nx-archweave:init --preset=team --frontend=next
|
|
|
49
49
|
```
|
|
50
50
|
|
|
51
51
|
- `init` runs the content generators below according to the preset (`quick-start` | `solo` | `team`).
|
|
52
|
-
- `init` requires a clean git working tree
|
|
52
|
+
- `init` requires a clean git working tree, so a partial run can be undone exactly: the report lists the files it modified (`git restore -- …`) and the files it created.
|
|
53
53
|
- `--allowDirty` opts out of the clean-tree check. The report then warns you that the rollback scope is wider.
|
|
54
54
|
- `--locale=ko|en` sets the language of generated content. Reports are always in English.
|
|
55
55
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type FsdPolicy } from './fsd-policy.ts';
|
|
1
2
|
/**
|
|
2
3
|
* `archweave/fsd-boundaries` — canonical FSD import rules inside one frontend app.
|
|
3
4
|
* Three findings:
|
|
@@ -6,33 +7,41 @@
|
|
|
6
7
|
* - cross-slice: two slices on the same sliced layer import each other — unless through the
|
|
7
8
|
* target slice's `@x/<importer-slice>` public API (FSD's cross-import notation)
|
|
8
9
|
* - sidestep: a slice's (or a shared segment's) internal file is imported past its index
|
|
10
|
+
* (or a folder that only groups slices is imported as if it were one)
|
|
9
11
|
*
|
|
10
12
|
* Only relative specifiers and the configured aliases are judged; package specifiers
|
|
11
13
|
* (`react`, `firebase/app`) are never touched — string-matching layer names in them was the
|
|
12
|
-
* measured source of false positives. Locations
|
|
13
|
-
*
|
|
14
|
+
* measured source of false positives. Locations come from path strings, plus one look at the
|
|
15
|
+
* file system for slice groups (`slice-groups.ts`); everything else follows the policy
|
|
16
|
+
* (`fsd-policy.ts`).
|
|
14
17
|
*
|
|
15
18
|
* Self-contained on purpose: no `eslint` import, so the plugin's only runtime dependency stays
|
|
16
19
|
* @nx/devkit. The handful of ESLint shapes it touches are typed structurally below.
|
|
17
20
|
*/
|
|
18
21
|
export type FsdEnforcementSeverity = 'error' | 'warn';
|
|
19
22
|
export interface FsdBoundariesOptions {
|
|
20
|
-
/** The app's source root, relative to ESLint's cwd
|
|
23
|
+
/** The app's source root: absolute (what `fsdBoundaries` passes), or relative to ESLint's cwd. */
|
|
21
24
|
root: string;
|
|
22
|
-
/** Import-prefix aliases → directories relative to cwd, e.g. `{ '@/': 'src' }`. */
|
|
25
|
+
/** Import-prefix aliases → directories, absolute or relative to ESLint's cwd, e.g. `{ '@/': 'src' }`. */
|
|
23
26
|
aliases: Record<string, string>;
|
|
27
|
+
/** How the tree is read; omitted keys take the `basic` preset's values. */
|
|
28
|
+
policy?: Partial<FsdPolicy>;
|
|
24
29
|
}
|
|
25
30
|
declare const LAYER_ORDER: readonly ["app", "pages", "widgets", "features", "entities", "shared"];
|
|
26
31
|
type FsdLayer = (typeof LAYER_ORDER)[number];
|
|
27
|
-
/** Where a file sits inside the FSD tree. `unit` is the slice on a sliced layer
|
|
28
|
-
* on `shared` (its segments carry a public API too),
|
|
29
|
-
* the unit, with a trailing `index[.ext]` dropped.
|
|
32
|
+
/** Where a file sits inside the FSD tree. `unit` is the slice on a sliced layer (`<group>/<slice>`
|
|
33
|
+
* inside a slice group), the public-API unit on `shared` (its segments carry a public API too),
|
|
34
|
+
* null on `app`. `rest` is the path below the unit, with a trailing `index[.ext]` dropped.
|
|
35
|
+
* `group` marks a path that stops at a slice-group folder. */
|
|
30
36
|
interface Location {
|
|
31
37
|
layer: FsdLayer;
|
|
32
38
|
unit: string | null;
|
|
33
39
|
rest: string[];
|
|
40
|
+
group?: true;
|
|
34
41
|
}
|
|
35
|
-
export declare function
|
|
42
|
+
export declare function indexFilePattern(platformSuffixes: string[]): RegExp;
|
|
43
|
+
/** `isGroup(layerDir, name)` says whether a folder on a sliced layer only groups slices. */
|
|
44
|
+
export declare function locate(rootRelativePath: string, policy?: FsdPolicy, isGroup?: (layerDir: string, name: string) => boolean): Location | null;
|
|
36
45
|
export type FsdViolation = {
|
|
37
46
|
kind: 'upward';
|
|
38
47
|
from: Location;
|
|
@@ -45,6 +54,10 @@ export type FsdViolation = {
|
|
|
45
54
|
kind: 'sidestep';
|
|
46
55
|
from: Location;
|
|
47
56
|
to: Location;
|
|
57
|
+
} | {
|
|
58
|
+
kind: 'group';
|
|
59
|
+
from: Location;
|
|
60
|
+
to: Location;
|
|
48
61
|
};
|
|
49
62
|
/** Pure core: the violation (if any) of `from` importing `to`. */
|
|
50
63
|
export declare function judge(from: Location, to: Location): FsdViolation | null;
|
|
@@ -94,6 +107,9 @@ export declare const fsdBoundariesRule: {
|
|
|
94
107
|
type: string;
|
|
95
108
|
};
|
|
96
109
|
};
|
|
110
|
+
policy: {
|
|
111
|
+
type: string;
|
|
112
|
+
};
|
|
97
113
|
};
|
|
98
114
|
additionalProperties: boolean;
|
|
99
115
|
}[];
|
|
@@ -101,6 +117,7 @@ export declare const fsdBoundariesRule: {
|
|
|
101
117
|
upward: string;
|
|
102
118
|
crossSlice: string;
|
|
103
119
|
sidestep: string;
|
|
120
|
+
group: string;
|
|
104
121
|
};
|
|
105
122
|
};
|
|
106
123
|
create(context: RuleContext): {
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How strictly `archweave/fsd-boundaries` reads an FSD tree. `basic` is canonical FSD; `practical`
|
|
3
|
+
* accepts the variants common in existing apps. A preset is only a set of values for these
|
|
4
|
+
* options — each can be set on its own, and an explicit value always wins over the preset.
|
|
5
|
+
*/
|
|
6
|
+
export interface FsdPolicy {
|
|
7
|
+
/** Folders on a sliced layer that only group slices (`features/mypage/{order,claims}`):
|
|
8
|
+
* detected (`auto`), forced (`include`) or ruled out (`exclude`), as `<layer dir>/<name>`. */
|
|
9
|
+
sliceGroups: false | {
|
|
10
|
+
auto: boolean;
|
|
11
|
+
include: string[];
|
|
12
|
+
exclude: string[];
|
|
13
|
+
};
|
|
14
|
+
/** The public-API unit of `shared`: a segment (`shared/ui`), a component inside a segment
|
|
15
|
+
* (`shared/ui/button`), or any file (no sidestep check inside shared). */
|
|
16
|
+
sharedPublicApi: 'segment' | 'component' | 'file';
|
|
17
|
+
/** Globs (relative to the source root) of test files, which may import another slice's
|
|
18
|
+
* internals; `false` treats them like any other file. Upward imports are reported either way. */
|
|
19
|
+
testFiles: false | string[];
|
|
20
|
+
/** Extra folder names that are segments, never slices, for group detection. */
|
|
21
|
+
segments: string[];
|
|
22
|
+
/** Platform suffixes whose `index.<suffix>.<ext>` is a public API too (React Native). */
|
|
23
|
+
platformSuffixes: string[];
|
|
24
|
+
/** Folders a framework's router owns (Next.js's Pages Router `pages/`): read as the
|
|
25
|
+
* composition root (the app layer), so they may re-export any layer below. */
|
|
26
|
+
routerDirs: string[];
|
|
27
|
+
}
|
|
28
|
+
export type FsdPreset = 'basic' | 'practical';
|
|
29
|
+
/** The policy from a recorded `fsd` object (its preset, then its own keys), then `overrides`.
|
|
30
|
+
* Unknown or malformed values fall back to the preset's. */
|
|
31
|
+
export declare function resolveFsdPolicy(recorded: unknown, overrides?: Partial<FsdPolicy>): FsdPolicy;
|
|
32
|
+
/** The `fsd` object of the nearest toolkit.config.json at or above `from`; undefined when there
|
|
33
|
+
* is none or it cannot be read. */
|
|
34
|
+
export declare function readRecordedFsd(from: string): unknown;
|
|
35
|
+
/** A glob (`**`, `*`, `?`, `{a,b}`) as a regular expression over `/`-separated paths. A brace
|
|
36
|
+
* without its closing `}` is a literal character. */
|
|
37
|
+
export declare function globToRegExp(glob: string): RegExp;
|
|
38
|
+
//# sourceMappingURL=fsd-policy.d.ts.map
|
package/dist/eslint/index.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { type FsdEnforcementSeverity } from './fsd-boundaries.ts';
|
|
2
|
+
import { type FsdPolicy } from './fsd-policy.ts';
|
|
2
3
|
export { fsdBoundariesRule, type FsdBoundariesOptions, type FsdEnforcementSeverity } from './fsd-boundaries.ts';
|
|
3
4
|
export { aliasesFromTsconfig } from './tsconfig-aliases.ts';
|
|
5
|
+
export { resolveFsdPolicy, type FsdPolicy, type FsdPreset } from './fsd-policy.ts';
|
|
4
6
|
/** The ESLint plugin object — `plugins: { archweave: archweavePlugin }`. */
|
|
5
7
|
export declare const archweavePlugin: {
|
|
6
8
|
meta: {
|
|
@@ -27,6 +29,9 @@ export declare const archweavePlugin: {
|
|
|
27
29
|
type: string;
|
|
28
30
|
};
|
|
29
31
|
};
|
|
32
|
+
policy: {
|
|
33
|
+
type: string;
|
|
34
|
+
};
|
|
30
35
|
};
|
|
31
36
|
additionalProperties: boolean;
|
|
32
37
|
}[];
|
|
@@ -34,6 +39,7 @@ export declare const archweavePlugin: {
|
|
|
34
39
|
upward: string;
|
|
35
40
|
crossSlice: string;
|
|
36
41
|
sidestep: string;
|
|
42
|
+
group: string;
|
|
37
43
|
};
|
|
38
44
|
};
|
|
39
45
|
create(context: import("./fsd-boundaries.ts").RuleContext): {
|
|
@@ -50,7 +56,7 @@ export declare const archweavePlugin: {
|
|
|
50
56
|
};
|
|
51
57
|
};
|
|
52
58
|
};
|
|
53
|
-
export interface FsdBoundariesConfigOptions {
|
|
59
|
+
export interface FsdBoundariesConfigOptions extends Partial<FsdPolicy> {
|
|
54
60
|
/** `error` (Team preset) or `warn` (Solo / Quick Start). Default `error`. */
|
|
55
61
|
severity?: FsdEnforcementSeverity;
|
|
56
62
|
/** The app's source root, relative to the config file's directory. Default `src`. */
|
|
@@ -62,17 +68,12 @@ export interface FsdBoundariesConfigOptions {
|
|
|
62
68
|
/** The tsconfig whose `compilerOptions.paths` (and its `extends` chain) add to the default
|
|
63
69
|
* aliases, relative to `cwd`. Default `tsconfig.json`; `false` turns the lookup off. */
|
|
64
70
|
tsconfig?: string | false;
|
|
65
|
-
/** The directory
|
|
66
|
-
* `
|
|
71
|
+
/** The directory `root`, `aliases` and `tsconfig` are relative to: the config file's own
|
|
72
|
+
* directory (`import.meta.dirname`), so the rule checks the same files whether ESLint runs
|
|
73
|
+
* from the project (`nx lint`), the workspace root or an editor. Default `process.cwd()`. */
|
|
67
74
|
cwd?: string;
|
|
68
75
|
/** Override the files the rule applies to. Default: everything under `root`. */
|
|
69
76
|
files?: string[];
|
|
70
77
|
}
|
|
71
|
-
/**
|
|
72
|
-
* The one flat-config entry add-fsd-lite adds to a frontend app's eslint.config.mjs:
|
|
73
|
-
*
|
|
74
|
-
* import { fsdBoundaries } from '@clearlydev/nx-archweave/eslint';
|
|
75
|
-
* export default [...baseConfig, ...fsdBoundaries({ severity: 'error' })];
|
|
76
|
-
*/
|
|
77
78
|
export declare function fsdBoundaries(options?: FsdBoundariesConfigOptions): object[];
|
|
78
79
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/eslint/index.js
CHANGED
|
@@ -1,10 +1,164 @@
|
|
|
1
1
|
// src/eslint/index.ts
|
|
2
|
-
import { existsSync as
|
|
3
|
-
import { dirname as
|
|
2
|
+
import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
|
|
3
|
+
import { dirname as dirname3, join as join3, posix as posix2, resolve as resolve3, sep as sep3 } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
|
|
6
6
|
// src/eslint/fsd-boundaries.ts
|
|
7
7
|
import { posix, relative, resolve, sep } from "node:path";
|
|
8
|
+
|
|
9
|
+
// src/eslint/fsd-policy.ts
|
|
10
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
var PRESETS = {
|
|
13
|
+
basic: {
|
|
14
|
+
sliceGroups: { auto: true, include: [], exclude: [] },
|
|
15
|
+
sharedPublicApi: "segment",
|
|
16
|
+
testFiles: false,
|
|
17
|
+
segments: [],
|
|
18
|
+
platformSuffixes: [],
|
|
19
|
+
routerDirs: []
|
|
20
|
+
},
|
|
21
|
+
practical: {
|
|
22
|
+
sliceGroups: { auto: true, include: [], exclude: [] },
|
|
23
|
+
sharedPublicApi: "file",
|
|
24
|
+
testFiles: ["**/*.{spec,test}.*", "**/__tests__/**"],
|
|
25
|
+
segments: [],
|
|
26
|
+
platformSuffixes: [],
|
|
27
|
+
routerDirs: []
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
var isStringArray = (value) => Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
31
|
+
function resolveFsdPolicy(recorded, overrides = {}) {
|
|
32
|
+
const fsd = typeof recorded === "object" && recorded !== null ? recorded : {};
|
|
33
|
+
const preset = fsd["preset"] === "practical" ? "practical" : "basic";
|
|
34
|
+
const policy = { ...PRESETS[preset] };
|
|
35
|
+
const groups = fsd["sliceGroups"];
|
|
36
|
+
if (groups === false) policy.sliceGroups = false;
|
|
37
|
+
else if (groups === "auto") policy.sliceGroups = { auto: true, include: [], exclude: [] };
|
|
38
|
+
else if (typeof groups === "object" && groups !== null) {
|
|
39
|
+
const value = groups;
|
|
40
|
+
policy.sliceGroups = {
|
|
41
|
+
auto: value["auto"] !== false,
|
|
42
|
+
include: isStringArray(value["include"]) ? value["include"] : [],
|
|
43
|
+
exclude: isStringArray(value["exclude"]) ? value["exclude"] : []
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
const shared = fsd["sharedPublicApi"];
|
|
47
|
+
if (shared === "segment" || shared === "component" || shared === "file") policy.sharedPublicApi = shared;
|
|
48
|
+
const tests = fsd["testFiles"];
|
|
49
|
+
if (tests === false || isStringArray(tests)) policy.testFiles = tests;
|
|
50
|
+
if (isStringArray(fsd["segments"])) policy.segments = fsd["segments"];
|
|
51
|
+
if (isStringArray(fsd["platformSuffixes"])) policy.platformSuffixes = fsd["platformSuffixes"];
|
|
52
|
+
if (isStringArray(fsd["routerDirs"])) policy.routerDirs = fsd["routerDirs"];
|
|
53
|
+
for (const [key, value] of Object.entries(overrides)) {
|
|
54
|
+
if (value !== void 0) policy[key] = value;
|
|
55
|
+
}
|
|
56
|
+
return policy;
|
|
57
|
+
}
|
|
58
|
+
function readRecordedFsd(from) {
|
|
59
|
+
for (let dir = from; ; dir = dirname(dir)) {
|
|
60
|
+
const file = join(dir, "toolkit.config.json");
|
|
61
|
+
if (existsSync(file)) {
|
|
62
|
+
try {
|
|
63
|
+
return JSON.parse(readFileSync(file, "utf-8"))["fsd"];
|
|
64
|
+
} catch {
|
|
65
|
+
return void 0;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (dirname(dir) === dir) return void 0;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
var escapeLiteral = (text) => text.replace(/[.+^$()|[\]\\{}]/g, "\\$&");
|
|
72
|
+
function wildcardsToRegExp(glob) {
|
|
73
|
+
let source = "";
|
|
74
|
+
for (let i = 0; i < glob.length; i++) {
|
|
75
|
+
const ch = glob[i];
|
|
76
|
+
if (ch === "*" && glob[i + 1] === "*") {
|
|
77
|
+
const slash = glob[i + 2] === "/";
|
|
78
|
+
source += slash ? "(?:.*/)?" : ".*";
|
|
79
|
+
i += slash ? 2 : 1;
|
|
80
|
+
} else if (ch === "*") source += "[^/]*";
|
|
81
|
+
else if (ch === "?") source += "[^/]";
|
|
82
|
+
else source += escapeLiteral(ch);
|
|
83
|
+
}
|
|
84
|
+
return source;
|
|
85
|
+
}
|
|
86
|
+
function globToRegExp(glob) {
|
|
87
|
+
let source = "";
|
|
88
|
+
let rest = glob;
|
|
89
|
+
for (; ; ) {
|
|
90
|
+
const open = rest.indexOf("{");
|
|
91
|
+
const close = open === -1 ? -1 : rest.indexOf("}", open);
|
|
92
|
+
if (open === -1 || close === -1) {
|
|
93
|
+
source += wildcardsToRegExp(rest);
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
source += wildcardsToRegExp(rest.slice(0, open));
|
|
97
|
+
source += `(?:${rest.slice(open + 1, close).split(",").map(wildcardsToRegExp).join("|")})`;
|
|
98
|
+
rest = rest.slice(close + 1);
|
|
99
|
+
}
|
|
100
|
+
return new RegExp(`^${source}$`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// src/eslint/slice-groups.ts
|
|
104
|
+
import { readdirSync, statSync } from "node:fs";
|
|
105
|
+
import { join as join2 } from "node:path";
|
|
106
|
+
var SEGMENTS = [
|
|
107
|
+
"ui",
|
|
108
|
+
"model",
|
|
109
|
+
"api",
|
|
110
|
+
"lib",
|
|
111
|
+
"config",
|
|
112
|
+
"components",
|
|
113
|
+
"hooks",
|
|
114
|
+
"types",
|
|
115
|
+
"utils",
|
|
116
|
+
"helpers",
|
|
117
|
+
"constants",
|
|
118
|
+
"consts",
|
|
119
|
+
"store",
|
|
120
|
+
"stores",
|
|
121
|
+
"styles",
|
|
122
|
+
"assets",
|
|
123
|
+
"services",
|
|
124
|
+
"context"
|
|
125
|
+
];
|
|
126
|
+
var IGNORED = /^(\.|readme(\.|$))/i;
|
|
127
|
+
var listings = /* @__PURE__ */ new Map();
|
|
128
|
+
function list(dir) {
|
|
129
|
+
let mtimeMs;
|
|
130
|
+
try {
|
|
131
|
+
mtimeMs = statSync(dir).mtimeMs;
|
|
132
|
+
} catch {
|
|
133
|
+
listings.delete(dir);
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
const cached = listings.get(dir);
|
|
137
|
+
if (cached !== void 0 && cached.mtimeMs === mtimeMs) return cached.entries;
|
|
138
|
+
try {
|
|
139
|
+
const entries = readdirSync(dir, { withFileTypes: true }).map((entry) => ({ name: entry.name, directory: entry.isDirectory() }));
|
|
140
|
+
listings.set(dir, { mtimeMs, entries });
|
|
141
|
+
return entries;
|
|
142
|
+
} catch {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
function createGroupDetector(rootAbsolute, policy, isIndexFile, listDirectory = list) {
|
|
147
|
+
const groups = policy.sliceGroups;
|
|
148
|
+
if (groups === false) return () => false;
|
|
149
|
+
const segments = /* @__PURE__ */ new Set([...SEGMENTS, ...policy.segments]);
|
|
150
|
+
return (layerDir, name) => {
|
|
151
|
+
const key = `${layerDir}/${name}`;
|
|
152
|
+
if (groups.include.includes(key)) return true;
|
|
153
|
+
if (groups.exclude.includes(key) || !groups.auto) return false;
|
|
154
|
+
const entries = listDirectory(join2(rootAbsolute, layerDir, name))?.filter((entry) => !IGNORED.test(entry.name)) ?? null;
|
|
155
|
+
if (entries === null || entries.length === 0) return false;
|
|
156
|
+
if (entries.some((entry) => !entry.directory || segments.has(entry.name))) return false;
|
|
157
|
+
return entries.some((entry) => listDirectory(join2(rootAbsolute, layerDir, name, entry.name))?.some((child) => !child.directory && isIndexFile(child.name)) ?? false);
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// src/eslint/fsd-boundaries.ts
|
|
8
162
|
var LAYER_ORDER = ["app", "pages", "widgets", "features", "entities", "shared"];
|
|
9
163
|
var LAYER_BY_DIR = {
|
|
10
164
|
app: "app",
|
|
@@ -17,24 +171,45 @@ var LAYER_BY_DIR = {
|
|
|
17
171
|
shared: "shared"
|
|
18
172
|
};
|
|
19
173
|
var SLICED_LAYERS = /* @__PURE__ */ new Set(["pages", "widgets", "features", "entities"]);
|
|
20
|
-
|
|
21
|
-
|
|
174
|
+
function indexFilePattern(platformSuffixes) {
|
|
175
|
+
const platforms = platformSuffixes.length === 0 ? "" : `(\\.(${platformSuffixes.map((suffix) => suffix.replace(/[^a-z0-9-]/gi, "")).join("|")}))?`;
|
|
176
|
+
return new RegExp(`^index${platforms}(\\.[cm]?[jt]sx?)?$`);
|
|
177
|
+
}
|
|
178
|
+
var DEFAULT_POLICY = resolveFsdPolicy(void 0);
|
|
179
|
+
var NO_GROUPS = () => false;
|
|
180
|
+
function locate(rootRelativePath, policy = DEFAULT_POLICY, isGroup = NO_GROUPS) {
|
|
22
181
|
const segments = rootRelativePath.split("/").filter((s) => s.length > 0 && s !== ".");
|
|
23
182
|
if (segments.length === 0) return null;
|
|
24
|
-
const layer = LAYER_BY_DIR[segments[0]];
|
|
183
|
+
const layer = policy.routerDirs.includes(segments[0]) ? "app" : LAYER_BY_DIR[segments[0]];
|
|
25
184
|
if (layer === void 0) return null;
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
185
|
+
let unit;
|
|
186
|
+
let rest;
|
|
187
|
+
if (layer === "app") {
|
|
188
|
+
unit = null;
|
|
189
|
+
rest = segments.slice(1);
|
|
190
|
+
} else if (layer === "shared") {
|
|
191
|
+
const component = policy.sharedPublicApi === "component" && segments.length > 2;
|
|
192
|
+
unit = segments[1] === void 0 ? null : component ? `${segments[1]}/${segments[2]}` : segments[1];
|
|
193
|
+
rest = policy.sharedPublicApi === "file" ? [] : segments.slice(component ? 3 : 2);
|
|
194
|
+
} else if (segments[1] !== void 0 && isGroup(segments[0], segments[1])) {
|
|
195
|
+
if (segments[2] === void 0) return { layer, unit: segments[1], rest: [], group: true };
|
|
196
|
+
unit = `${segments[1]}/${segments[2]}`;
|
|
197
|
+
rest = segments.slice(3);
|
|
198
|
+
} else {
|
|
199
|
+
unit = segments[1] ?? null;
|
|
200
|
+
rest = segments.slice(2);
|
|
201
|
+
}
|
|
202
|
+
if (rest.length > 0 && indexFilePattern(policy.platformSuffixes).test(rest[rest.length - 1])) rest.pop();
|
|
30
203
|
return { layer, unit, rest };
|
|
31
204
|
}
|
|
32
205
|
function judge(from, to) {
|
|
33
206
|
if (LAYER_ORDER.indexOf(to.layer) < LAYER_ORDER.indexOf(from.layer)) return { kind: "upward", from, to };
|
|
34
207
|
if (to.unit === null) return null;
|
|
208
|
+
if (to.group) return { kind: "group", from, to };
|
|
35
209
|
const sameUnit = from.layer === to.layer && from.unit === to.unit;
|
|
36
210
|
if (sameUnit) return null;
|
|
37
|
-
const
|
|
211
|
+
const importerSlice = from.unit?.split("/").pop();
|
|
212
|
+
const viaCrossImportApi = to.rest[0] === "@x" && to.rest[1] === importerSlice && from.layer === to.layer;
|
|
38
213
|
if (viaCrossImportApi) return null;
|
|
39
214
|
if (from.layer === to.layer && SLICED_LAYERS.has(to.layer)) return { kind: "cross-slice", from, to };
|
|
40
215
|
if (to.rest.length > 0) return { kind: "sidestep", from, to };
|
|
@@ -50,9 +225,8 @@ function resolveSpecifier(specifier, importerRootRelative, options) {
|
|
|
50
225
|
} else {
|
|
51
226
|
for (const [prefix, target] of Object.entries(options.aliases)) {
|
|
52
227
|
if (!specifier.startsWith(prefix)) continue;
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
rootRelative = posix.normalize(posix.join(targetRootRelative === "" ? "." : targetRootRelative, specifier.slice(prefix.length)));
|
|
228
|
+
const absolute = resolve(resolve(options.cwd, target), specifier.slice(prefix.length));
|
|
229
|
+
rootRelative = toPosix(relative(options.rootAbsolute, absolute)) || ".";
|
|
56
230
|
break;
|
|
57
231
|
}
|
|
58
232
|
}
|
|
@@ -75,7 +249,8 @@ var fsdBoundariesRule = {
|
|
|
75
249
|
type: "object",
|
|
76
250
|
properties: {
|
|
77
251
|
root: { type: "string" },
|
|
78
|
-
aliases: { type: "object", additionalProperties: { type: "string" } }
|
|
252
|
+
aliases: { type: "object", additionalProperties: { type: "string" } },
|
|
253
|
+
policy: { type: "object" }
|
|
79
254
|
},
|
|
80
255
|
additionalProperties: false
|
|
81
256
|
}
|
|
@@ -83,7 +258,8 @@ var fsdBoundariesRule = {
|
|
|
83
258
|
messages: {
|
|
84
259
|
upward: 'FSD: "{{from}}" may not import from "{{to}}" (a higher layer).',
|
|
85
260
|
crossSlice: 'FSD: "{{from}}" may not import a sibling slice "{{to}}" \u2014 move the shared part down a layer, or expose it through "{{to}}/@x/{{fromUnit}}".',
|
|
86
|
-
sidestep: 'FSD: import "{{to}}" through its public API (index.ts), not its internal file "{{path}}".'
|
|
261
|
+
sidestep: 'FSD: import "{{to}}" through its public API (index.ts), not its internal file "{{path}}".',
|
|
262
|
+
group: 'FSD: "{{to}}" is a group of slices, not a slice \u2014 import one of its slices through its index.ts.'
|
|
87
263
|
}
|
|
88
264
|
},
|
|
89
265
|
create(context) {
|
|
@@ -94,20 +270,24 @@ var fsdBoundariesRule = {
|
|
|
94
270
|
const rootAbsolute = resolve(cwd, root);
|
|
95
271
|
const importerRootRelative = toPosix(relative(rootAbsolute, context.filename));
|
|
96
272
|
if (importerRootRelative === "" || importerRootRelative.startsWith("..")) return {};
|
|
97
|
-
const
|
|
273
|
+
const policy = resolveFsdPolicy(void 0, options.policy ?? {});
|
|
274
|
+
const isIndexFile = (name) => indexFilePattern(policy.platformSuffixes).test(name);
|
|
275
|
+
const isGroup = createGroupDetector(rootAbsolute, policy, isIndexFile);
|
|
276
|
+
const from = locate(importerRootRelative, policy, isGroup);
|
|
98
277
|
if (from === null) return {};
|
|
278
|
+
const isTestFile = policy.testFiles !== false && policy.testFiles.some((glob) => globToRegExp(glob).test(importerRootRelative));
|
|
99
279
|
const check = (node, specifier) => {
|
|
100
280
|
if (typeof specifier !== "string") return;
|
|
101
281
|
const target = resolveSpecifier(specifier, importerRootRelative, { rootAbsolute, cwd, aliases });
|
|
102
282
|
if (target === null) return;
|
|
103
|
-
const to = locate(target);
|
|
283
|
+
const to = locate(target, policy, isGroup);
|
|
104
284
|
if (to === null) return;
|
|
105
285
|
const violation = judge(from, to);
|
|
106
|
-
if (violation === null) return;
|
|
107
|
-
const data = { from: describeLocation(from), to: describeLocation(to), fromUnit: from.unit ?? "", path: target };
|
|
286
|
+
if (violation === null || isTestFile && violation.kind !== "upward") return;
|
|
287
|
+
const data = { from: describeLocation(from), to: describeLocation(to), fromUnit: from.unit?.split("/").pop() ?? "", path: target };
|
|
108
288
|
context.report({
|
|
109
289
|
node,
|
|
110
|
-
messageId: violation.kind === "
|
|
290
|
+
messageId: violation.kind === "cross-slice" ? "crossSlice" : violation.kind,
|
|
111
291
|
data
|
|
112
292
|
});
|
|
113
293
|
};
|
|
@@ -125,8 +305,8 @@ var fsdBoundariesRule = {
|
|
|
125
305
|
};
|
|
126
306
|
|
|
127
307
|
// src/eslint/tsconfig-aliases.ts
|
|
128
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
129
|
-
import { dirname, isAbsolute, relative as relative2, resolve as resolve2, sep as sep2 } from "node:path";
|
|
308
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
|
|
309
|
+
import { dirname as dirname2, isAbsolute, relative as relative2, resolve as resolve2, sep as sep2 } from "node:path";
|
|
130
310
|
function aliasesFromTsconfig({ cwd, tsconfig = "tsconfig.json", maxDepth = 8 }) {
|
|
131
311
|
const aliases = {};
|
|
132
312
|
let current = resolve2(cwd, tsconfig);
|
|
@@ -138,7 +318,7 @@ function aliasesFromTsconfig({ cwd, tsconfig = "tsconfig.json", maxDepth = 8 })
|
|
|
138
318
|
const compilerOptions = asRecord(config["compilerOptions"]);
|
|
139
319
|
const paths = asRecord(compilerOptions?.["paths"]);
|
|
140
320
|
if (paths) {
|
|
141
|
-
const baseUrl = typeof compilerOptions?.["baseUrl"] === "string" ? resolve2(
|
|
321
|
+
const baseUrl = typeof compilerOptions?.["baseUrl"] === "string" ? resolve2(dirname2(current), compilerOptions["baseUrl"]) : dirname2(current);
|
|
142
322
|
for (const [pattern, targets] of Object.entries(paths)) {
|
|
143
323
|
if (!pattern.endsWith("*")) continue;
|
|
144
324
|
const prefix = pattern.slice(0, -1);
|
|
@@ -151,13 +331,13 @@ function aliasesFromTsconfig({ cwd, tsconfig = "tsconfig.json", maxDepth = 8 })
|
|
|
151
331
|
}
|
|
152
332
|
const parent = config["extends"];
|
|
153
333
|
const parentPath = typeof parent === "string" ? parent : Array.isArray(parent) ? parent.find((p) => typeof p === "string") : void 0;
|
|
154
|
-
current = parentPath !== void 0 && (parentPath.startsWith(".") || isAbsolute(parentPath)) ? resolveExtends(
|
|
334
|
+
current = parentPath !== void 0 && (parentPath.startsWith(".") || isAbsolute(parentPath)) ? resolveExtends(dirname2(current), parentPath) : null;
|
|
155
335
|
}
|
|
156
336
|
return aliases;
|
|
157
337
|
}
|
|
158
338
|
function resolveExtends(fromDir, target) {
|
|
159
339
|
const candidates = [resolve2(fromDir, target), resolve2(fromDir, `${target}.json`)];
|
|
160
|
-
return candidates.find((candidate) =>
|
|
340
|
+
return candidates.find((candidate) => existsSync2(candidate)) ?? null;
|
|
161
341
|
}
|
|
162
342
|
function asRecord(value) {
|
|
163
343
|
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
|
|
@@ -168,7 +348,7 @@ function toPosix2(path) {
|
|
|
168
348
|
function readJsonc(path) {
|
|
169
349
|
let text;
|
|
170
350
|
try {
|
|
171
|
-
text =
|
|
351
|
+
text = readFileSync2(path, "utf-8");
|
|
172
352
|
} catch {
|
|
173
353
|
return null;
|
|
174
354
|
}
|
|
@@ -218,14 +398,14 @@ function readJsonc(path) {
|
|
|
218
398
|
|
|
219
399
|
// src/eslint/index.ts
|
|
220
400
|
function ownVersion() {
|
|
221
|
-
let dir =
|
|
401
|
+
let dir = dirname3(fileURLToPath(import.meta.url));
|
|
222
402
|
for (; ; ) {
|
|
223
|
-
const manifest =
|
|
224
|
-
if (
|
|
225
|
-
const pkg = JSON.parse(
|
|
403
|
+
const manifest = join3(dir, "package.json");
|
|
404
|
+
if (existsSync3(manifest)) {
|
|
405
|
+
const pkg = JSON.parse(readFileSync3(manifest, "utf-8"));
|
|
226
406
|
if (pkg.name === "@clearlydev/nx-archweave" && pkg.version) return pkg.version;
|
|
227
407
|
}
|
|
228
|
-
const parent =
|
|
408
|
+
const parent = dirname3(dir);
|
|
229
409
|
if (parent === dir) throw new Error("@clearlydev/nx-archweave: its own package.json was not found");
|
|
230
410
|
dir = parent;
|
|
231
411
|
}
|
|
@@ -234,15 +414,27 @@ var archweavePlugin = {
|
|
|
234
414
|
meta: { name: "@clearlydev/nx-archweave", version: ownVersion() },
|
|
235
415
|
rules: { "fsd-boundaries": fsdBoundariesRule }
|
|
236
416
|
};
|
|
417
|
+
var OUTPUT_DIRS = [".next/**", ".expo/**", "dist/**", "out/**", "build/**", "coverage/**"];
|
|
237
418
|
function fsdBoundaries(options = {}) {
|
|
238
419
|
const root = options.root ?? "src";
|
|
239
420
|
const cwd = options.cwd ?? process.cwd();
|
|
240
421
|
const fromTsconfig = options.aliases === void 0 && options.tsconfig !== false ? aliasesFromTsconfig({ cwd, tsconfig: options.tsconfig }) : {};
|
|
241
|
-
const
|
|
422
|
+
const aliases = options.aliases ?? { "@/": root, ...fromTsconfig };
|
|
423
|
+
const anchor = (dir) => options.cwd === void 0 ? dir : resolve3(options.cwd, dir);
|
|
424
|
+
const { sliceGroups, sharedPublicApi, testFiles, segments, platformSuffixes, routerDirs } = options;
|
|
425
|
+
const policy = resolveFsdPolicy(readRecordedFsd(cwd), { sliceGroups, sharedPublicApi, testFiles, segments, platformSuffixes, routerDirs });
|
|
426
|
+
const ruleOptions = {
|
|
427
|
+
root: anchor(root),
|
|
428
|
+
aliases: Object.fromEntries(Object.entries(aliases).map(([prefix, dir]) => [prefix, anchor(dir)])),
|
|
429
|
+
policy
|
|
430
|
+
};
|
|
431
|
+
const rootGlob = posix2.normalize(root.split(sep3).join("/"));
|
|
432
|
+
const atAppRoot = rootGlob === "." || rootGlob === "./";
|
|
242
433
|
return [
|
|
243
434
|
{
|
|
244
435
|
name: "nx-archweave/fsd-boundaries",
|
|
245
|
-
files: options.files ?? [`${
|
|
436
|
+
files: options.files ?? [`${atAppRoot ? "" : `${rootGlob.replace(/\/$/, "")}/`}**/*.{ts,tsx,js,jsx,mts,cts,mjs,cjs}`],
|
|
437
|
+
...atAppRoot ? { ignores: OUTPUT_DIRS } : {},
|
|
246
438
|
plugins: { archweave: archweavePlugin },
|
|
247
439
|
rules: { "archweave/fsd-boundaries": [options.severity ?? "error", ruleOptions] }
|
|
248
440
|
}
|
|
@@ -252,5 +444,6 @@ export {
|
|
|
252
444
|
aliasesFromTsconfig,
|
|
253
445
|
archweavePlugin,
|
|
254
446
|
fsdBoundaries,
|
|
255
|
-
fsdBoundariesRule
|
|
447
|
+
fsdBoundariesRule,
|
|
448
|
+
resolveFsdPolicy
|
|
256
449
|
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { FsdPolicy } from './fsd-policy.ts';
|
|
2
|
+
export type DirectoryLister = (dir: string) => {
|
|
3
|
+
name: string;
|
|
4
|
+
directory: boolean;
|
|
5
|
+
}[] | null;
|
|
6
|
+
/**
|
|
7
|
+
* Whether `<rootAbsolute>/<layerDir>/<name>` groups slices instead of being one. A group has no
|
|
8
|
+
* files of its own (so no index), none of its folders is a segment, and at least one of its
|
|
9
|
+
* folders has an index. `include` / `exclude` (`<layerDir>/<name>`) decide before detection.
|
|
10
|
+
*/
|
|
11
|
+
export declare function createGroupDetector(rootAbsolute: string, policy: FsdPolicy, isIndexFile: (name: string) => boolean, listDirectory?: DirectoryLister): (layerDir: string, name: string) => boolean;
|
|
12
|
+
//# sourceMappingURL=slice-groups.d.ts.map
|