@clearlydev/nx-archweave 0.1.0 → 0.2.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 (59) hide show
  1. package/README.md +2 -2
  2. package/dist/eslint/fsd-boundaries.d.ts +27 -10
  3. package/dist/eslint/fsd-policy.d.ts +38 -0
  4. package/dist/eslint/index.d.ts +10 -9
  5. package/dist/eslint/index.js +227 -34
  6. package/dist/eslint/slice-groups.d.ts +12 -0
  7. package/dist/generators/add-agent-rules/generator.js +1175 -23
  8. package/dist/generators/add-backend/generator.js +1127 -75
  9. package/dist/generators/add-fsd-lite/generator.js +1274 -96
  10. package/dist/generators/add-integration/generator.js +4 -35
  11. package/dist/generators/add-review/files/dependabot.yml +7 -0
  12. package/dist/generators/add-review/generator.js +1100 -46
  13. package/dist/generators/doctor/generator.js +784 -131
  14. package/dist/generators/entity/generator.js +251 -32
  15. package/dist/generators/feature/generator.js +251 -32
  16. package/dist/generators/init/generator.js +1955 -549
  17. package/dist/generators/install-integration/generator.js +5 -37
  18. package/dist/generators/page/generator.js +251 -32
  19. package/dist/generators/record-integration/generator.js +5 -36
  20. package/dist/generators/shared/generator.js +251 -32
  21. package/dist/generators/widget/generator.js +251 -32
  22. package/dist/index.js +4 -35
  23. package/package.json +5 -3
  24. package/src/generators/add-agent-rules/schema.json +5 -0
  25. package/src/generators/add-fsd-lite/schema.json +2 -2
  26. package/src/generators/add-integration/schema.json +1 -1
  27. package/src/generators/entity/schema.json +11 -1
  28. package/src/generators/feature/schema.json +11 -1
  29. package/src/generators/init/schema.json +13 -2
  30. package/src/generators/page/schema.json +15 -3
  31. package/src/generators/shared/schema.json +7 -1
  32. package/src/generators/widget/schema.json +11 -1
  33. package/dist/eslint/fsd-boundaries.js +0 -144
  34. package/dist/eslint/tsconfig-aliases.js +0 -114
  35. package/dist/generators/add-agent-rules/render.js +0 -46
  36. package/dist/generators/add-backend/render.js +0 -102
  37. package/dist/generators/add-fsd-lite/render.js +0 -72
  38. package/dist/generators/add-github-templates/render.js +0 -26
  39. package/dist/generators/add-integration/render.js +0 -34
  40. package/dist/generators/add-review/render.js +0 -96
  41. package/dist/generators/allow-builds/render.js +0 -82
  42. package/dist/generators/doctor/integration-report.js +0 -70
  43. package/dist/generators/doctor/render.js +0 -10
  44. package/dist/generators/init/render.js +0 -125
  45. package/dist/generators/init/validation.js +0 -74
  46. package/dist/generators/install-integration/render.js +0 -128
  47. package/dist/generators/record-integration/render.js +0 -31
  48. package/dist/generators/shared/dry-run.js +0 -12
  49. package/dist/generators/shared/frontend-app.js +0 -57
  50. package/dist/generators/shared/project-type.js +0 -39
  51. package/dist/generators/shared/record-pipeline.js +0 -34
  52. package/dist/generators/shared/run-nx.js +0 -24
  53. package/dist/generators/shared/template-files.js +0 -38
  54. package/dist/generators/shared/toolkit-config-file.js +0 -110
  55. package/dist/generators/slice/core.js +0 -185
  56. package/dist/generators/slice/render.js +0 -70
  57. package/dist/lib/nx-plugin.js +0 -7
  58. package/dist/runtime/integrations-composition-root.js +0 -20
  59. package/dist/runtime/node-command-runner.js +0 -81
package/README.md CHANGED
@@ -32,7 +32,7 @@ bun nx g @clearlydev/nx-archweave:doctor # bun
32
32
  - **Requirements:** Node >= 22.18, plus one of npm, pnpm >= 10, yarn (classic or berry) or bun. All four package managers are first-class.
33
33
  - Generators detect the workspace's real package manager from its lockfiles.
34
34
  - The examples below use `pnpm nx g`. With another package manager, run the same generator the way the `doctor` commands above show.
35
- - The package ships as a self-contained bundle. Its only runtime dependencies are `@nx/devkit` and `tslib`.
35
+ - The package ships as a self-contained bundle. Its only runtime dependency is `@nx/devkit`.
36
36
 
37
37
  ## Quick start
38
38
 
@@ -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. That way you can always undo a partial run with a single `git restore .`.
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 are computed from path strings alone: the
13
- * rule never reads the file system.
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
- * Self-contained on purpose: no `eslint` import, so the plugin's runtime dependencies stay
16
- * @nx/devkit + tslib. The handful of ESLint shapes it touches are typed structurally below.
18
+ * Self-contained on purpose: no `eslint` import, so the plugin's only runtime dependency stays
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 (Nx lints from the project root). */
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, the segment
28
- * on `shared` (its segments carry a public API too), null on `app`. `rest` is the path below
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 locate(rootRelativePath: string): Location | null;
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
@@ -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 the config is evaluated from — Nx lints from the project root. Default
66
- * `process.cwd()`. */
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
@@ -1,10 +1,164 @@
1
1
  // src/eslint/index.ts
2
- import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
3
- import { dirname as dirname2, join } from "node:path";
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
- var INDEX_FILE = /^index(\.[cm]?[jt]sx?)?$/;
21
- function locate(rootRelativePath) {
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
- const hasUnit = layer !== "app";
27
- const unit = hasUnit ? segments[1] ?? null : null;
28
- const rest = segments.slice(hasUnit ? 2 : 1);
29
- if (rest.length > 0 && INDEX_FILE.test(rest[rest.length - 1])) rest.pop();
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 viaCrossImportApi = to.rest[0] === "@x" && to.rest[1] === from.unit && from.layer === to.layer;
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 targetAbsolute = resolve(options.cwd, target);
54
- const targetRootRelative = toPosix(relative(options.rootAbsolute, targetAbsolute));
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 from = locate(importerRootRelative);
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 === "upward" ? "upward" : violation.kind === "cross-slice" ? "crossSlice" : "sidestep",
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(dirname(current), compilerOptions["baseUrl"]) : dirname(current);
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(dirname(current), parentPath) : null;
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) => existsSync(candidate)) ?? null;
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 = readFileSync(path, "utf-8");
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 = dirname2(fileURLToPath(import.meta.url));
401
+ let dir = dirname3(fileURLToPath(import.meta.url));
222
402
  for (; ; ) {
223
- const manifest = join(dir, "package.json");
224
- if (existsSync2(manifest)) {
225
- const pkg = JSON.parse(readFileSync2(manifest, "utf-8"));
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 = dirname2(dir);
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 ruleOptions = { root, aliases: options.aliases ?? { "@/": root, ...fromTsconfig } };
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 ?? [`${root}/**/*.{ts,tsx,js,jsx,mts,cts,mjs,cjs}`],
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