oxlint-plugin-arch 0.12.0 → 0.12.2

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.
@@ -2,8 +2,34 @@
2
2
  import { createRequire } from "node:module";
3
3
 
4
4
  // src/preset/groups/backend.ts
5
- import { existsSync } from "node:fs";
6
- import { posix, resolve as resolve2 } from "node:path";
5
+ import { posix as posix3, resolve as resolve3 } from "node:path";
6
+
7
+ // src/preset/preset-detect.ts
8
+ import { existsSync, readdirSync, readFileSync as readFileSync2 } from "node:fs";
9
+ import { matchesGlob, posix as posix2, resolve as resolve2 } from "node:path";
10
+
11
+ // src/preset/preset-recipes.ts
12
+ var presetUi = { react: true, kit: true, tailwind: true };
13
+ var presetRecipes = {
14
+ tanstack: { react: true, tanstack: true, tailwind: true, shadcn: true, schemas: true, forms: true },
15
+ next: { react: true, next: true, tailwind: true, shadcn: true, schemas: true, forms: true },
16
+ expo: { react: true, expo: true, schemas: true, forms: true },
17
+ backend: { backend: true, schemas: true, sql: true, layout: { strict: true } },
18
+ react: { react: true, tailwind: true, schemas: true },
19
+ desktop: { console: true },
20
+ library: { schemas: true },
21
+ ui: presetUi,
22
+ kit: presetUi,
23
+ cli: { console: true }
24
+ };
25
+ var presetTemplates = {
26
+ monorepo: {
27
+ "apps/web": presetRecipes.tanstack,
28
+ "apps/server": presetRecipes.backend,
29
+ "packages/*": presetRecipes.library,
30
+ scripts: presetRecipes.cli
31
+ }
32
+ };
7
33
 
8
34
  // src/preset/preset.constants.ts
9
35
  var presetExtensions = "*.{ts,tsx,mts,cts}";
@@ -331,7 +357,9 @@ var presetBuiltinFileRoles = [
331
357
  "test",
332
358
  "spec"
333
359
  ];
334
- var presetLiteralSegment = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;
360
+ var presetShorthandOptions = [["tests", "mocking", "preset"], ["backend", "registry", "file"]];
361
+ var presetOutputFolders = ["dist", "build", "coverage", ".output"];
362
+ var presetLiteralSegment = /^(?!\.{1,2}$)[a-zA-Z0-9._][a-zA-Z0-9._-]*$/;
335
363
  var presetIllustrationPalette = [
336
364
  "text-color",
337
365
  "bg-color",
@@ -436,7 +464,7 @@ var presetGroupOptions = {
436
464
  deny: { level: "error", imports: {}, calls: {} },
437
465
  externals: { level: "error", packages: presetExternals }
438
466
  };
439
- var presetFolderKeys = ["severity", "source", "rules"];
467
+ var presetFolderKeys = ["severity", "source", "services", "rules"];
440
468
  var presetA11yRules = [
441
469
  "alt-text",
442
470
  "anchor-has-content",
@@ -478,15 +506,15 @@ var presetExpoRouteExports = ["ErrorBoundary", "unstable_settings", "generateSta
478
506
  var presetTestFiles = ["**/{test,tests,e2e}/**/*.{ts,tsx,mts,cts}", "**/*.{test,spec}.{ts,tsx,mts,cts}"];
479
507
  var presetBaseImports = [{ group: ["zod/v4", "zod/v4/**"], message: "Import from zod, not its transition entry point." }];
480
508
  var presetDetectFrameworks = [
481
- ["tanstack", ["@tanstack/react-start"]],
482
509
  ["next", ["next", "vinext"]],
510
+ ["tanstack", ["@tanstack/react-start"]],
483
511
  ["expo", ["expo"]]
484
512
  ];
485
513
  var presetDetectDesktop = ["electrobun", "electron", "@tauri-apps/api", "@tauri-apps/cli"];
486
514
  var presetDetectBackend = ["@orpc/server", "hono", "kysely", "drizzle-orm", "express", "fastify", "elysia"];
487
515
  var presetDetectSkip = ["node_modules", "dist", "build", "coverage", "public", ".output", ".next", ".vinext", ".turbo", ".wrangler", "target"];
488
516
  var presetStylesheetNames = ["styles", "globals", "global", "app", "index", "main", "style", "tailwind"];
489
- var presetRegistryFiles = ["services/service.ts", "core/core.services.ts", "service.ts"];
517
+ var presetRegistryFiles = ["core/core.services.ts", "service.ts"];
490
518
  var presetRegistrySkip = ["brand", "database", "db", "math", "security", "tools"];
491
519
  var presetServerEntries = ["rpc", "server", "security", "services"];
492
520
  var presetOperationImports = ["*/*-action.*", "*/*-query.*", "*/actions/*", "*/queries/*"];
@@ -499,13 +527,16 @@ var presetRootImports = { "framer-motion": "Use motion/react." };
499
527
  var presetPackageImports = { "../../apps/**": "Packages stay app-agnostic; do not import app internals." };
500
528
 
501
529
  // src/preset/preset.utils.ts
502
- import { readFileSync } from "node:fs";
503
- import { resolve } from "node:path";
530
+ import { readFileSync, statSync } from "node:fs";
531
+ import { posix, resolve } from "node:path";
504
532
  class PresetError extends Error {
505
533
  }
506
534
  function presetScopes(roots, suffix = `**/${presetExtensions}`) {
507
535
  return roots.map((root) => root === "." ? suffix : `${root}/${suffix}`);
508
536
  }
537
+ function presetServiceScopes(folders, sources, suffix = `**/${presetExtensions}`) {
538
+ return presetScopes(sources.map((source) => folders[source] ?? posix.join(source, "services")), suffix);
539
+ }
509
540
  function presetOverride(files, rules, excludeFiles = []) {
510
541
  return {
511
542
  files,
@@ -583,12 +614,376 @@ function presetRuntime(project, settings, framework, rule) {
583
614
  options.rpc = project.rpcClients.map((path) => resolve(project.root, path));
584
615
  }
585
616
  return [
586
- presetOverride([...presetScopes([unit.source], "services/**/*.{ts,tsx}"), ...presetScopes([unit.source], "lib/**/*.{ts,tsx}")], {
617
+ presetOverride([...presetServiceScopes(project.serviceFolders, [unit.source], "**/*.{ts,tsx}"), ...presetScopes([unit.source], "lib/**/*.{ts,tsx}")], {
587
618
  [rule]: ["error", options]
588
619
  })
589
620
  ];
590
621
  });
591
622
  }
623
+ function presetManifest(root, directory) {
624
+ try {
625
+ return JSON.parse(readFileSync(resolve(root, directory, "package.json"), "utf8"));
626
+ } catch {
627
+ return {};
628
+ }
629
+ }
630
+ function presetIsDirectory(path) {
631
+ try {
632
+ return statSync(path, { throwIfNoEntry: false })?.isDirectory() ?? false;
633
+ } catch {
634
+ return false;
635
+ }
636
+ }
637
+ function presetServicesFolder(root, source, configured, fallback = true) {
638
+ if (configured !== undefined) {
639
+ if (!configured.split("/").every((segment) => presetLiteralSegment.test(segment))) {
640
+ throw new PresetError(`services \`${configured}\` must be a literal relative path.`);
641
+ }
642
+ return posix.join(source, configured);
643
+ }
644
+ const lib = fallback && !presetIsDirectory(resolve(root, source, "services")) && presetIsDirectory(resolve(root, source, "lib"));
645
+ return posix.join(source, lib ? "lib" : "services");
646
+ }
647
+ function presetIsFile(path) {
648
+ try {
649
+ return statSync(path, { throwIfNoEntry: false })?.isFile() ?? false;
650
+ } catch {
651
+ return false;
652
+ }
653
+ }
654
+
655
+ // src/preset/preset-detect.ts
656
+ function presetDirectories(root, base, skip = presetDetectSkip) {
657
+ if (!presetIsDirectory(resolve2(root, base))) {
658
+ return [];
659
+ }
660
+ return readdirSync(resolve2(root, base)).filter((name) => !name.startsWith(".") && !skip.includes(name) && presetIsDirectory(resolve2(root, base, name))).map((name) => posix2.join(base, name));
661
+ }
662
+ var presetNamedSkip = ["node_modules"];
663
+ function presetSource(root, directory, configured) {
664
+ if (configured !== undefined) {
665
+ return posix2.join(directory, configured);
666
+ }
667
+ return existsSync(resolve2(root, directory, "src")) ? posix2.join(directory, "src") : directory;
668
+ }
669
+ function presetRegistryFile(root, source, configured, services) {
670
+ if (configured === false) {
671
+ return;
672
+ }
673
+ const file = typeof configured === "string" ? configured : configured?.file;
674
+ if (file !== undefined) {
675
+ return posix2.normalize(file);
676
+ }
677
+ return [posix2.join(services, "service.ts"), ...presetRegistryFiles.map((path) => posix2.join(source, path))].find((path) => existsSync(resolve2(root, path)));
678
+ }
679
+ function presetDomains(root, services) {
680
+ return presetDirectories(root, services, presetNamedSkip).map((path) => posix2.basename(path)).filter((name) => existsSync(resolve2(root, services, name, `${name}.service.ts`)));
681
+ }
682
+ function presetRecipe(root, directory, app, folder) {
683
+ const manifest = presetManifest(root, directory);
684
+ const dependencies = new Set([manifest.dependencies, manifest.devDependencies, manifest.peerDependencies].flatMap((listed) => Object.keys(listed ?? {})));
685
+ const has = (names) => names.some((name) => dependencies.has(name));
686
+ const source = presetSource(root, directory, folder.source);
687
+ const framework = presetDetectFrameworks.find(([, names]) => has(names))?.[0];
688
+ const servicesFolder = presetServicesFolder(root, source, folder.services, app);
689
+ const services = resolve2(root, servicesFolder);
690
+ const entries = presetIsDirectory(services) ? readdirSync(services, { withFileTypes: true }) : [];
691
+ const folders = entries.filter((entry) => presetIsDirectory(resolve2(services, entry.name))).length;
692
+ const flat = entries.filter((entry) => entry.isFile() && /\.tsx?$/.test(entry.name)).length > folders;
693
+ if (framework) {
694
+ return flat ? { ...presetRecipes[framework], layout: { services: "flat" } } : presetRecipes[framework];
695
+ }
696
+ if (has(presetDetectDesktop)) {
697
+ return presetRecipes.desktop;
698
+ }
699
+ if (presetDomains(root, servicesFolder).length > 0 || has(presetDetectBackend) && (app || presetIsDirectory(services))) {
700
+ return presetRecipes.backend;
701
+ }
702
+ if (has(["react"]) && presetIsDirectory(resolve2(root, source, "components"))) {
703
+ return app ? presetRecipes.react : presetRecipes.ui;
704
+ }
705
+ return presetRecipes.library;
706
+ }
707
+ function presetMergeObject(base, top, depth) {
708
+ const merged = { ...base };
709
+ for (const [key, value] of Object.entries(top)) {
710
+ const previous = Reflect.get(merged, key);
711
+ const nested = previous instanceof Object && value instanceof Object && !Array.isArray(previous) && !Array.isArray(value);
712
+ if (nested && depth > 0) {
713
+ Reflect.set(merged, key, presetMergeObject(previous, value, depth - 1));
714
+ } else if (value !== true || !(previous instanceof Object)) {
715
+ Reflect.set(merged, key, value);
716
+ }
717
+ }
718
+ return merged;
719
+ }
720
+ function presetMerge(base, top) {
721
+ if (!(base instanceof Object) || !(top instanceof Object)) {
722
+ return top === true && base instanceof Object ? base : top;
723
+ }
724
+ return presetMergeObject(base, top, 2);
725
+ }
726
+ function presetImportTargets(value) {
727
+ if (typeof value === "string") {
728
+ return [value];
729
+ }
730
+ if (Array.isArray(value)) {
731
+ return value.flatMap(presetImportTargets);
732
+ }
733
+ return value instanceof Object ? Object.values(value).flatMap(presetImportTargets) : [];
734
+ }
735
+ function presetSpecifiers(manifest, target, file = false) {
736
+ const specifiers = Object.entries(manifest.imports ?? {}).flatMap(([key, value]) => presetImportTargets(value).flatMap((mapped) => {
737
+ const path = posix2.normalize(mapped);
738
+ const parts = path.split("*");
739
+ const keyParts = key.split("*");
740
+ if (parts.length === 1 && keyParts.length === 1) {
741
+ return path.replace(/\.[cm]?[jt]sx?$/, "") === target ? [key] : [];
742
+ }
743
+ if (parts.length !== 2 || keyParts.length !== 2) {
744
+ return [];
745
+ }
746
+ const [before, after] = parts;
747
+ const [head, suffix] = keyParts;
748
+ const prefix = before.replace(/\/$/, "");
749
+ if (target !== prefix && !target.startsWith(`${prefix}/`)) {
750
+ return [];
751
+ }
752
+ const rest = target.slice(prefix.length).replace(/^\//, "");
753
+ const tail = after && rest.endsWith(after) ? rest.slice(0, -after.length) : rest;
754
+ return [`${head}${tail}${file ? suffix : ""}`.replace(/\/$/, "")];
755
+ }));
756
+ return [...new Set(specifiers)];
757
+ }
758
+ function presetOverlaps(left, right) {
759
+ const [leftBase] = left.split("*");
760
+ const [rightBase] = right.split("*");
761
+ return leftBase.startsWith(rightBase) || rightBase.startsWith(leftBase);
762
+ }
763
+ function presetDeny(imports) {
764
+ return { deny: { imports } };
765
+ }
766
+ function presetFilled(conventions, exists) {
767
+ return Object.fromEntries(Object.entries(conventions).filter(([path, folder]) => {
768
+ const empty = folder instanceof Object && folder.deny instanceof Object && Object.keys(folder.deny.imports ?? {}).length === 0;
769
+ return exists(path) && !empty;
770
+ }));
771
+ }
772
+ function presetBackendConventions(root, key, value, denied) {
773
+ const manifest = presetManifest(root, key);
774
+ const source = presetSource(root, key, value.source);
775
+ const local = posix2.relative(key, source) || ".";
776
+ const at = (path) => local === "." ? path : posix2.join(local, path);
777
+ const exists = (path) => existsSync(resolve2(root, key, path));
778
+ const configured = value.backend instanceof Object ? value.backend.registry : undefined;
779
+ const servicesFolder = presetServicesFolder(root, source, value.services);
780
+ const servicesKey = posix2.relative(key, servicesFolder);
781
+ const registryFile = presetRegistryFile(root, source, configured, servicesFolder);
782
+ const registry = registryFile === undefined ? undefined : posix2.relative(key, registryFile);
783
+ const services = presetSpecifiers(manifest, servicesKey);
784
+ const message = "Use the service API, not another service’s operations.";
785
+ const operations = Object.fromEntries(services.flatMap((prefix) => presetOperationImports.map((pattern) => [`${prefix}/${pattern}`, message])));
786
+ const registryImports = registry ? Object.fromEntries(presetSpecifiers(manifest, registry.replace(/\.[cm]?[jt]sx?$/, ""), true).map((specifier) => [specifier, "Only the composition root imports the service registry."])) : {};
787
+ const entries = Object.fromEntries(presetServerEntries.map((entry) => [`${manifest.name}/${entry}`, "Server code imports its own modules, not the package entries."]));
788
+ const serviceImports = { ...registryImports, ...operations };
789
+ if (manifest.name) {
790
+ Object.assign(serviceImports, entries);
791
+ }
792
+ const conventions = {
793
+ [servicesKey]: presetDeny(serviceImports),
794
+ [at("core")]: presetDeny(operations),
795
+ [at("rpc")]: presetDeny(registryImports),
796
+ [at("routes")]: presetDeny(registryImports),
797
+ [at("cli")]: {
798
+ naming: { files: presetCliFiles },
799
+ ...presetDeny({ ...operations, ...Object.fromEntries(services.map((prefix) => [`${prefix}/*/*.service`, "Reach services through the registry."])) })
800
+ }
801
+ };
802
+ for (const domain of presetDirectories(root, servicesFolder, presetNamedSkip).map((path) => posix2.basename(path))) {
803
+ const imports = services.flatMap((prefix) => presetOperationImports.filter((pattern) => !denied.has(`${prefix}/${pattern}`)).flatMap((pattern) => [
804
+ [`${prefix}/${pattern}`, false],
805
+ [`${prefix}/${pattern.replace(/^\*/, `!(${domain})`)}`, message]
806
+ ]));
807
+ conventions[posix2.join(servicesKey, domain)] = presetDeny(Object.fromEntries(imports));
808
+ }
809
+ for (const tests of ["tests", "test"]) {
810
+ if (exists(posix2.join(tests, "kits"))) {
811
+ conventions[tests] = presetDeny(Object.fromEntries(presetKitImports.map((pattern) => [pattern, "Import the kit file you need."])));
812
+ conventions[posix2.join(tests, "kits")] = { naming: { files: presetKitFiles } };
813
+ }
814
+ }
815
+ const queue = ["."];
816
+ while (queue.length > 0) {
817
+ const folder = queue.shift() ?? ".";
818
+ for (const child of presetDirectories(root, posix2.join(key, folder)).map((path) => posix2.relative(key, path))) {
819
+ if (posix2.basename(child) === "migrations") {
820
+ conventions[child] = { layout: false, naming: { files: presetMigrationFiles, unique: presetMigrationNumber } };
821
+ } else if (child.split("/").length < 8) {
822
+ queue.push(child);
823
+ }
824
+ }
825
+ }
826
+ return presetFilled(conventions, exists);
827
+ }
828
+ function presetWebConventions(root, key, value, servers) {
829
+ const source = posix2.relative(key, presetSource(root, key, value.source)) || ".";
830
+ const at = (path) => source === "." ? path : posix2.join(source, path);
831
+ const exists = (path) => existsSync(resolve2(root, key, path));
832
+ const own = Object.keys(presetManifest(root, key).imports ?? {});
833
+ const internals = servers.flatMap((manifest) => Object.keys(manifest.imports ?? {}).filter((specifier) => !own.some((mine) => presetOverlaps(mine, specifier))).map((specifier) => specifier.replace(/\*$/, "**")));
834
+ const named = servers.flatMap((manifest) => manifest.name ? [manifest.name] : []);
835
+ const entries = Object.fromEntries(named.flatMap((name) => presetServerEntries.map((entry) => [`${name}/${entry}`, "UI code imports the client entry."])));
836
+ const clients = Object.fromEntries(named.map((name) => [`${name}/client`, "Route handlers run on the server; import the server entry."]));
837
+ const appImports = Object.fromEntries(internals.map((specifier) => [specifier, "Import the backend through its client entry."]));
838
+ if (servers.length > 0) {
839
+ appImports["**/services/*.service"] = "Import the client or rsc service file, not a server service.";
840
+ }
841
+ const conventions = {
842
+ ".": presetDeny(appImports),
843
+ [at("components")]: presetDeny(entries),
844
+ [at("routes")]: presetDeny(entries),
845
+ [at("routes/handlers")]: { deny: { imports: { ...clients, ...Object.fromEntries(Object.keys(entries).map((entry) => [entry, false])) } } }
846
+ };
847
+ return presetFilled(conventions, exists);
848
+ }
849
+ function presetWorkspacePatterns(root, manifest) {
850
+ const listed = Array.isArray(manifest.workspaces) ? manifest.workspaces : manifest.workspaces?.packages ?? [];
851
+ const file = resolve2(root, "pnpm-workspace.yaml");
852
+ if (!existsSync(file)) {
853
+ return listed;
854
+ }
855
+ const patterns = [...listed];
856
+ let inside = false;
857
+ for (const line of readFileSync2(file, "utf8").split(`
858
+ `)) {
859
+ const flow = /^packages\s*:\s*\[(.*)\]/.exec(line);
860
+ const item = /^\s*-\s*(["']?)([^"'#\s]+)\1\s*(?:#.*)?$/.exec(line);
861
+ if (flow) {
862
+ patterns.push(...flow[1].split(",").map((entry) => entry.trim().replaceAll(/^["']|["']$/g, "")).filter(Boolean));
863
+ inside = false;
864
+ } else if (/^packages\s*:/.test(line)) {
865
+ inside = true;
866
+ } else if (inside && item) {
867
+ patterns.push(item[2]);
868
+ } else if (/^\S/.test(line)) {
869
+ inside = false;
870
+ }
871
+ }
872
+ return patterns;
873
+ }
874
+ function presetWorkspaces(root, patterns) {
875
+ const clean = (pattern) => posix2.normalize(pattern.replace(/^\.\//, "")).replace(/\/$/, "");
876
+ const include = patterns.filter((pattern) => !pattern.startsWith("!")).map(clean);
877
+ const exclude = patterns.filter((pattern) => pattern.startsWith("!")).map((pattern) => clean(pattern.slice(1)));
878
+ const matches = (path, globs) => globs.some((glob) => path === glob || matchesGlob(path, glob));
879
+ const candidates = new Set;
880
+ for (const pattern of include) {
881
+ const segments = pattern.split("/");
882
+ const literal = segments.findIndex((segment) => /[*?{[]/.test(segment));
883
+ if (literal === -1) {
884
+ candidates.add(pattern);
885
+ continue;
886
+ }
887
+ const depth = segments.includes("**") ? 8 : segments.length - literal;
888
+ let level = [segments.slice(0, literal).join("/") || "."];
889
+ for (let step = 0;step < depth; step++) {
890
+ level = level.flatMap((folder) => presetDirectories(root, folder, presetNamedSkip));
891
+ level.forEach((folder) => candidates.add(folder));
892
+ }
893
+ }
894
+ return [...candidates].filter((path) => matches(path, include) && !matches(path, exclude) && existsSync(resolve2(root, path, "package.json"))).toSorted();
895
+ }
896
+ function presetContains(key, path) {
897
+ if (key === "." || key === path) {
898
+ return true;
899
+ }
900
+ const pattern = key.split("/");
901
+ const segments = path.split("/");
902
+ return pattern.length <= segments.length && pattern.every((segment, index) => segment === "*" || segment === segments[index]);
903
+ }
904
+ function presetOwner(folders, path, owns) {
905
+ const depth = (key) => key === "." ? 0 : key.split("/").length;
906
+ return Object.entries(folders).filter(([key, value]) => presetContains(key, path) && owns(value)).toSorted(([left], [right]) => depth(left) - depth(right) || Number(right.includes("*")) - Number(left.includes("*"))).at(-1)?.[1];
907
+ }
908
+ function presetGroupOff(folders, path, group) {
909
+ const owner = presetOwner(folders, path, (value) => value instanceof Object && (group in value));
910
+ return owner instanceof Object && Object(owner)[group] === false;
911
+ }
912
+ function presetDetect(root, folders) {
913
+ const manifest = presetManifest(root, ".");
914
+ const configured = (key) => {
915
+ const value = folders[key];
916
+ return value instanceof Object ? value : {};
917
+ };
918
+ const patterns = presetWorkspacePatterns(root, manifest);
919
+ const directories = presetWorkspaces(root, patterns);
920
+ const detected = Object.fromEntries(directories.map((directory) => [directory, presetRecipe(root, directory, !directory.startsWith("packages/"), configured(directory))]));
921
+ const rootRecipe = presetRecipe(root, ".", true, configured("."));
922
+ const single = Object.keys(manifest).length > 0 || rootRecipe !== presetRecipes.library;
923
+ const rootApp = rootRecipe !== presetRecipes.library && presetSource(root, ".", configured(".").source) !== ".";
924
+ if (patterns.length === 0 ? single : rootApp) {
925
+ detected["."] = rootRecipe;
926
+ }
927
+ if (existsSync(resolve2(root, "scripts")) && !directories.includes("scripts")) {
928
+ detected.scripts = presetRecipes.cli;
929
+ }
930
+ const keys = Object.keys(folders);
931
+ const covered = (key) => keys.some((user) => user !== key && user !== "." && (key.startsWith(`${user}/`) || user.endsWith("/*") && posix2.dirname(key) === user.slice(0, -2)));
932
+ const merged = { ".": presetDeny(presetRootImports) };
933
+ for (const [key, value] of Object.entries(detected)) {
934
+ if (!covered(key)) {
935
+ merged[key] = presetMerge(merged[key], value);
936
+ }
937
+ }
938
+ for (const [key, value] of Object.entries(folders)) {
939
+ merged[key] = key in merged ? presetMerge(merged[key], value) : value;
940
+ }
941
+ const length = (key) => key.split("/").length;
942
+ const nested = (key) => Object.keys(merged).some((other) => other !== "." && length(other) < length(key) && presetContains(other, key));
943
+ const units = Object.entries(merged).flatMap(([key, value]) => {
944
+ if (key.endsWith("/*")) {
945
+ return presetDirectories(root, key.slice(0, -2), presetNamedSkip).filter((directory) => !(directory in merged)).map((directory) => [directory, value]);
946
+ }
947
+ if (key.includes("*") || nested(key)) {
948
+ return [];
949
+ }
950
+ const siblings = Object.entries(merged).filter(([other]) => other !== key && other.endsWith("/*") && length(other) === length(key) && presetContains(other, key));
951
+ return [[key, siblings.reduce((inherited, [, sibling]) => presetMerge(sibling, inherited), value)]];
952
+ });
953
+ const denied = new Set(Object.values(folders).flatMap((folder) => folder instanceof Object && folder.deny instanceof Object ? Object.entries(folder.deny.imports ?? {}).flatMap(([pattern, message]) => typeof message === "string" ? [pattern] : []) : []));
954
+ const servers = units.filter(([, value]) => value instanceof Object && Boolean(value.backend)).map(([key]) => presetManifest(root, key));
955
+ const internals = servers.flatMap((server) => Object.keys(server.imports ?? {}).map((specifier) => specifier.replace(/\*$/, "**")));
956
+ const add = (key, folder) => {
957
+ if (presetOwner(folders, key, () => true) === false) {
958
+ return;
959
+ }
960
+ const kept = folder instanceof Object ? Object.fromEntries(Object.entries(folder).filter(([group]) => !presetGroupOff(folders, key, group))) : folder;
961
+ if (kept instanceof Object && Object.keys(kept).length === 0) {
962
+ return;
963
+ }
964
+ merged[key] = key in merged ? presetMerge(kept, merged[key]) : kept;
965
+ };
966
+ for (const [key, value] of units) {
967
+ if (!(value instanceof Object)) {
968
+ continue;
969
+ }
970
+ if (value.backend) {
971
+ for (const [path, folder] of Object.entries(presetBackendConventions(root, key, value, denied))) {
972
+ add(path === "." ? key : posix2.join(key, path), folder);
973
+ }
974
+ } else if (value.tanstack || value.next) {
975
+ for (const [path, folder] of Object.entries(presetWebConventions(root, key, value, servers))) {
976
+ add(path === "." ? key : posix2.join(key, path), folder);
977
+ }
978
+ } else if ((value.kit || key.startsWith("packages/")) && !value.expo) {
979
+ const own = Object.keys(presetManifest(root, key).imports ?? {});
980
+ const message = presetPackageImports["../../apps/**"];
981
+ const appImports = internals.filter((specifier) => !own.some((mine) => presetOverlaps(mine, specifier))).map((specifier) => [specifier, message]);
982
+ add(key, presetDeny({ ...presetPackageImports, ...Object.fromEntries(appImports) }));
983
+ }
984
+ }
985
+ return merged;
986
+ }
592
987
 
593
988
  // src/preset/groups/backend.ts
594
989
  var backendGroup = (project, settings) => {
@@ -606,9 +1001,9 @@ var backendGroup = (project, settings) => {
606
1001
  }
607
1002
  const configured = typeof settings.registry === "string" ? { file: settings.registry } : settings.registry ?? {};
608
1003
  const backendUnits = project.units.filter((unit) => unit.kind === "backend");
609
- const file = configured.file === undefined ? undefined : posix.normalize(configured.file);
1004
+ const file = configured.file === undefined ? undefined : posix3.normalize(configured.file);
610
1005
  const registries = file === undefined ? backendUnits.flatMap((unit) => {
611
- const found = presetRegistryFiles.map((path) => posix.join(unit.source, path)).find((path) => existsSync(resolve2(project.root, path)));
1006
+ const found = presetRegistryFile(project.root, unit.source, undefined, unit.services);
612
1007
  return found ? [{ file: found, unit }] : [];
613
1008
  }) : [{ file, unit: backendUnits.find((unit) => unit.key === "." || file.startsWith(`${unit.key}/`)) }];
614
1009
  for (const registry of registries) {
@@ -616,8 +1011,8 @@ var backendGroup = (project, settings) => {
616
1011
  throw new PresetError(`backend.registry \`${registry.file}\` must be a project-relative path inside a backend folder.`);
617
1012
  }
618
1013
  const options = {
619
- registry: resolve2(project.root, registry.file),
620
- services: resolve2(project.root, registry.unit.source, "services"),
1014
+ registry: resolve3(project.root, registry.file),
1015
+ services: resolve3(project.root, registry.unit.services),
621
1016
  skip: configured.skip ?? (file === undefined ? presetRegistrySkip : [])
622
1017
  };
623
1018
  if (configured.object !== undefined) {
@@ -635,7 +1030,7 @@ var basicsGroup = (project, settings) => {
635
1030
  return [
636
1031
  ...settings.trivial ? [
637
1032
  presetOverride(appFiles, { "arch/code-trivial-functions": ["error", trivial] }, ui),
638
- presetOverride([...presetScopes(web, "services/**/*.{client,server,rsc}.ts"), ...presetScopes(web, "services/**/*.rsc.tsx")], { "arch/code-trivial-functions": ["error", { ...trivial, services: true }] }, ui)
1033
+ presetOverride([...presetServiceScopes(project.serviceFolders, web, "**/*.{client,server,rsc}.ts"), ...presetServiceScopes(project.serviceFolders, web, "**/*.rsc.tsx")], { "arch/code-trivial-functions": ["error", { ...trivial, services: true }] }, ui)
639
1034
  ] : [],
640
1035
  ...settings.state ? [presetOverride(safetyFiles, { "arch/code-mutable-state": ["error", { kinds: ["let", "var"] }] }, ui)] : [],
641
1036
  ...settings.switch ? [presetOverride(project.backendFiles, { "arch/code-switch": ["error", { message: "Use ts-pattern in backend application code." }] })] : [],
@@ -666,8 +1061,8 @@ var denyGroup = (project, settings) => {
666
1061
  continue;
667
1062
  }
668
1063
  const files = [
669
- ...presetScopes([unit.source], "services/**/*-query.*.ts"),
670
- ...presetScopes(project.operations.filter((path) => path === unit.source), "services/*/queries/*.ts")
1064
+ ...presetServiceScopes(project.serviceFolders, [unit.source], "**/*-query.*.ts"),
1065
+ ...presetServiceScopes(project.serviceFolders, project.operations.filter((path) => path === unit.source), "*/queries/*.ts")
671
1066
  ];
672
1067
  const reads = Object.fromEntries(writes.map((name) => [name, "Query files only read; move writes to an action."]));
673
1068
  overrides.push(presetOverride(files, { "arch/boundaries-deny": ["error", { imports, calls: { ...reads, ...calls } }] }));
@@ -725,7 +1120,7 @@ var formsGroup = (project, settings) => [
725
1120
  ];
726
1121
 
727
1122
  // src/preset/groups/imports.ts
728
- import { resolve as resolve3 } from "node:path";
1123
+ import { resolve as resolve4 } from "node:path";
729
1124
  function importsPackageNames(project, kinds) {
730
1125
  return project.units.flatMap((unit) => {
731
1126
  const name = kinds.includes(unit.kind) ? presetPackageName(project.root, unit.key) : undefined;
@@ -763,7 +1158,7 @@ var importsGroup = (project, settings) => {
763
1158
  if (!settings.boundaries) {
764
1159
  return overrides;
765
1160
  }
766
- const absolute = (paths) => paths.map((path) => resolve3(project.root, path));
1161
+ const absolute = (paths) => paths.map((path) => resolve4(project.root, path));
767
1162
  overrides.push(presetOverride([...project.appFiles, ...presetScopes(project.packages, `**/${presetExtensions}`)], {
768
1163
  "arch/boundaries-imports": [
769
1164
  "error",
@@ -772,6 +1167,7 @@ var importsGroup = (project, settings) => {
772
1167
  backend: absolute(project.backend),
773
1168
  packages: absolute(project.packages),
774
1169
  roles: project.suffixes,
1170
+ services: absolute([...project.web, ...project.backend].map((source) => project.serviceFolders[source])),
775
1171
  portable: true,
776
1172
  aliases: presetAliases(project.root, settings.aliases),
777
1173
  appPackages: importsPackageNames(project, ["web", "backend"]),
@@ -784,18 +1180,19 @@ var importsGroup = (project, settings) => {
784
1180
  };
785
1181
 
786
1182
  // src/preset/groups/layout.ts
787
- import { posix as posix2 } from "node:path";
1183
+ import { posix as posix4 } from "node:path";
788
1184
  var layoutGroup = (project, settings) => {
789
1185
  const { web, nextWeb, serviceRoots, app, services, utilities, ui, testFiles, layouts, operations } = project;
790
- const folderOperations = presetScopes(operations, "services/*/{actions,queries}/*.ts");
1186
+ const folderOperations = presetServiceScopes(project.serviceFolders, operations, "*/{actions,queries}/*.ts");
791
1187
  const operationMessage = "Action/query files export exactly one named operation. Keep helpers used only by this operation private inside its function; do not export them. Move a helper to the module's .utils.ts only when it is genuinely shared, not merely to satisfy lint.";
792
1188
  const roles = [...presetBuiltinFileRoles.filter((role) => role !== "action" && role !== "query"), ...project.suffixes];
793
1189
  const conceptFiles = project.suffixes.map((suffix) => `**/*.${suffix}.ts`);
794
- const frontendEntries = [...presetScopes(web, "services/**/*.{client,server,rsc}.ts"), ...presetScopes(web, "services/**/*.rsc.tsx")];
795
- const ungroupedWeb = web.filter((path) => !layouts.some((scope) => scope.path === posix2.join(path, "components")));
1190
+ const frontendEntries = [...presetServiceScopes(project.serviceFolders, web, "**/*.{client,server,rsc}.ts"), ...presetServiceScopes(project.serviceFolders, web, "**/*.rsc.tsx")];
1191
+ const ungroupedWeb = web.filter((path) => !layouts.some((scope) => scope.path === posix4.join(path, "components")));
796
1192
  return [
797
1193
  ...layouts.flatMap(({ path, folder, mode }) => {
798
- const folders = folder === "services" && operations.some((source) => posix2.join(source, "services") === path);
1194
+ const folders = folder === "services" && operations.some((source) => posix4.join(source, "services") === path);
1195
+ const registries = folder === "services" ? project.registries : [];
799
1196
  const restricted = presetOverride(mode === "flat" ? [`${path}/*/**/${presetExtensions}`] : [`${path}/${presetExtensions}`, `${path}/*/*/**/${presetExtensions}`], {
800
1197
  "arch/files-restricted": [
801
1198
  "error",
@@ -803,31 +1200,31 @@ var layoutGroup = (project, settings) => {
803
1200
  message: mode === "flat" ? "This folder uses a flat layout; subfolders are forbidden." : "This folder uses exactly one owning domain directory; loose and deeper files are forbidden."
804
1201
  }
805
1202
  ]
806
- }, folders ? [`${path}/*/{actions,queries}/*.ts`] : []);
1203
+ }, [...folders ? [`${path}/*/{actions,queries}/*.ts`] : [], ...registries]);
807
1204
  if (mode !== "domain") {
808
1205
  return [restricted];
809
1206
  }
810
1207
  const prefix = { singularize: folder === "components" ? "trailing-s" : "none", separators: [".", "-"] };
811
1208
  const naming = folder === "hooks" ? { ...prefix, prefixes: ["use-"] } : prefix;
812
- const domain = [restricted, presetOverride([`${path}/*/${presetExtensions}`], { "arch/naming-folder": ["error", naming] })];
1209
+ const domain = [restricted, presetOverride([`${path}/*/${presetExtensions}`], { "arch/naming-folder": ["error", naming] }, registries)];
813
1210
  if (!settings.strict || folder !== "services") {
814
1211
  return domain;
815
1212
  }
816
- const strict = presetOverride([`${path}/*/${presetExtensions}`], {
1213
+ const strict = presetOverride([`${path}/*/**/${presetExtensions}`], {
817
1214
  "arch/naming-file": [
818
1215
  "error",
819
1216
  {
820
- pattern: `^[a-z0-9]+(?:-[a-z0-9]+)*[.](?:${roles.join("|")})[.]ts$`,
1217
+ pattern: `^[a-z0-9]+(?:-[a-z0-9]+)*(?:[.](?:${roles.join("|")})[.]ts|[.]rsc[.]tsx)$`,
821
1218
  message: `Domain files use domain[-part].<role>.ts with a role from: ${roles.join(", ")}. Add new roles to layout.suffixes.`
822
1219
  }
823
1220
  ]
824
- }, ["**/*-{action,query}.*.ts"]);
1221
+ }, ["**/*-{action,query}.*.ts", ...testFiles, ...registries]);
825
1222
  return [...domain, strict];
826
1223
  }),
827
1224
  presetOverride(presetScopes(ungroupedWeb, `components/*/*/**/${presetExtensions}`), { "arch/files-restricted": ["error", { message: "Components allow one group folder." }] }, ui),
828
1225
  presetOverride([...presetScopes(web, "components/**/*.ts"), ...presetScopes(web, "hooks/**/*.tsx")], { "arch/files-restricted": ["error", { message: "Components use TSX; hooks use .ts files." }] }, ui),
829
1226
  presetOverride(presetScopes(web, "**/use-*.{ts,tsx}"), { "arch/files-restricted": ["error", { message: "Hooks belong in src/hooks." }] }, [...presetScopes(web, "hooks/**"), ...ui]),
830
- presetOverride(presetScopes(web, "services/**/*.{ts,tsx}"), {
1227
+ presetOverride(presetServiceScopes(project.serviceFolders, web, "**/*.{ts,tsx}"), {
831
1228
  "arch/files-restricted": [
832
1229
  "error",
833
1230
  {
@@ -837,31 +1234,32 @@ var layoutGroup = (project, settings) => {
837
1234
  }, [
838
1235
  "**/*.{client,server,rsc,utils,constants}.ts",
839
1236
  "**/*.rsc.tsx",
840
- ...presetScopes(nextWeb, "services/**/*.action.ts"),
1237
+ ...presetServiceScopes(project.serviceFolders, nextWeb, "**/*.action.ts"),
841
1238
  ...conceptFiles,
842
- ...project.colocated
1239
+ ...project.colocated,
1240
+ ...testFiles
843
1241
  ]),
844
1242
  presetOverride(services, {
845
1243
  "arch/backend-service-functions": "error",
846
1244
  "arch/types-inline": ["error", { parameters: true, returns: true, functions: true, min: 1 }]
847
1245
  }, [...utilities, ...testFiles, ...conceptFiles]),
848
1246
  presetOverride(frontendEntries, { "arch/backend-service-functions": ["error", { frontend: true }] }, testFiles),
849
- ...conceptFiles.map((pattern) => presetOverride(presetScopes(app, `services/${pattern}`), {
1247
+ ...conceptFiles.map((pattern) => presetOverride(presetServiceScopes(project.serviceFolders, app, pattern), {
850
1248
  "arch/backend-service-functions": "off",
851
1249
  "arch/naming-exports": ["error", { stem: "full-basename", scope: "functions" }]
852
1250
  }, testFiles)),
853
- presetOverride([...presetScopes(app, "services/**/*-{action,query}.*.ts"), ...presetScopes(web, "services/**/*-{action,query}.*.rsc.tsx")], { "arch/backend-service-functions": ["error", { helpers: true, single: true, message: operationMessage }] }, testFiles),
854
- presetOverride(folderOperations, { "arch/backend-service-functions": ["error", { helpers: true, message: operationMessage }] }, testFiles),
855
- presetOverride(presetScopes(serviceRoots, "services/**/*.ts"), { "arch/files-domain-constants": "error" }, [...utilities, ...testFiles]),
856
- presetOverride(presetScopes(web, "services/**/*.{ts,tsx}"), { "arch/files-domain-constants": ["error", { data: true }] }, [
1251
+ presetOverride([...presetServiceScopes(project.serviceFolders, app, "**/*-{action,query}.*.ts"), ...presetServiceScopes(project.serviceFolders, web, "**/*-{action,query}.*.rsc.tsx")], { "arch/backend-service-functions": ["error", { helpers: true, single: true, message: operationMessage }] }, testFiles),
1252
+ presetOverride(folderOperations, { "arch/backend-service-functions": ["error", { helpers: true, single: true, message: operationMessage }] }, testFiles),
1253
+ presetOverride(presetServiceScopes(project.serviceFolders, serviceRoots, "**/*.ts"), { "arch/files-domain-constants": "error" }, [...utilities, ...testFiles]),
1254
+ presetOverride(presetServiceScopes(project.serviceFolders, web, "**/*.{ts,tsx}"), { "arch/files-domain-constants": ["error", { data: true }] }, [
857
1255
  "**/*.constants.ts",
858
1256
  ...testFiles
859
1257
  ]),
860
1258
  presetOverride(frontendEntries, { "arch/files-domain-constants": ["error", { data: true, services: true }] }, testFiles),
861
- presetOverride([...presetScopes(serviceRoots, "services/**/*-{action,query}.*.ts"), ...folderOperations, ...frontendEntries], { "arch/code-object-params": ["error", { max: 2 }] }, testFiles),
1259
+ presetOverride([...presetServiceScopes(project.serviceFolders, serviceRoots, "**/*-{action,query}.*.ts"), ...folderOperations, ...frontendEntries], { "arch/code-object-params": ["error", { max: 2 }] }, testFiles),
862
1260
  presetOverride(frontendEntries, { "arch/code-object-params": ["error", { max: 2, services: true }] }, testFiles),
863
- presetOverride(presetScopes(serviceRoots, "services/**/*.service.ts"), { "arch/files-exports": ["error", { names: settings.service, stem: "before-first-dot", types: false }] }, testFiles),
864
- presetOverride(presetScopes(app.filter((path) => !operations.includes(path)), "services/**/{actions,queries}/**/*.ts"), {
1261
+ presetOverride(presetServiceScopes(project.serviceFolders, serviceRoots, "**/*.service.ts"), { "arch/files-exports": ["error", { names: settings.service, stem: "before-first-dot", types: false }] }, testFiles),
1262
+ presetOverride(presetServiceScopes(project.serviceFolders, app.filter((path) => !operations.includes(path)), "**/{actions,queries}/**/*.ts"), {
865
1263
  "arch/files-restricted": ["error", { message: "Use domain-action.name.ts or domain-query.name.ts." }]
866
1264
  })
867
1265
  ];
@@ -878,6 +1276,10 @@ var limitsGroup = (project, settings) => [
878
1276
 
879
1277
  // src/preset/groups/naming.ts
880
1278
  var namingOperationMessage = "Use domain-action.name.ts or domain-query.name.ts.";
1279
+ var namingFrontendPrefix = { ...presetExportPrefix, scope: "functions", services: true, allow: "^\\*$" };
1280
+ function namingOperationPrefix(role) {
1281
+ return { ...presetExportPrefix, roles: [role], scope: "functions", allow: "^\\*$" };
1282
+ }
881
1283
  function namingOperation(role, file) {
882
1284
  return { mode: "template", file, export: `{domain}${role === "action" ? "Action" : "Query"}{Name}`, placeholder: "[a-z0-9-]+", camelCase: true };
883
1285
  }
@@ -885,7 +1287,7 @@ var namingGroup = (project, settings, owner) => {
885
1287
  const { web, serviceRoots, app, ui, testFiles, components, hooks, operations } = project;
886
1288
  const flatApp = app.filter((path) => !operations.includes(path));
887
1289
  const folderApp = app.filter((path) => operations.includes(path));
888
- const frontendEntries = [...presetScopes(web, "services/**/*.{client,server,rsc}.ts"), ...presetScopes(web, "services/**/*.rsc.tsx")];
1290
+ const frontendEntries = [...presetServiceScopes(project.serviceFolders, web, "**/*.{client,server,rsc}.ts"), ...presetServiceScopes(project.serviceFolders, web, "**/*.rsc.tsx")];
889
1291
  const ungroupedWeb = web.filter((path) => !project.layouts.some((scope) => scope.path === `${path}/components`));
890
1292
  const overrides = [];
891
1293
  const { files: filesPattern, exports: exportsPattern, unique } = settings;
@@ -923,8 +1325,8 @@ var namingGroup = (project, settings, owner) => {
923
1325
  add("files", [`**/${presetExtensions}`], presetNamingRules);
924
1326
  add("files", presetScopes(app, "types/**/*.ts"), { "arch/naming-file": ["error", { pattern: "^[a-z0-9]+(?:-[a-z0-9]+)*[.]types[.]ts$", message: "Use domain.types.ts filenames." }] }, ["**/*.constants.ts"]);
925
1327
  add("exports", presetScopes(app, "**/*.{constants,utils,handler,service,types,client,server,rsc}.ts"), { "arch/naming-exports": ["error", presetExportPrefix] }, [...ui, ...presetScopes(serviceRoots, "entry.*.ts")]);
926
- add("exports", frontendEntries, { "arch/naming-exports": ["error", { ...presetExportPrefix, scope: "functions", services: true, allow: "^\\*$" }] }, testFiles);
927
- add("exports", presetScopes(web, "services/**/*.constants.ts"), { "arch/naming-exports": ["error", { ...presetExportPrefix, scope: "declarations", allow: "^\\*$" }] }, testFiles);
1328
+ add("exports", frontendEntries, { "arch/naming-exports": ["error", namingFrontendPrefix] }, testFiles);
1329
+ add("exports", presetServiceScopes(project.serviceFolders, web, "**/*.constants.ts"), { "arch/naming-exports": ["error", { ...presetExportPrefix, scope: "declarations", allow: "^\\*$" }] }, testFiles);
928
1330
  add("exports", presetScopes(app, "**/*.utils.ts"), { "arch/naming-exports": ["error", { ...presetExportPrefix, scope: "functions", allow: "^\\*$" }] }, ui);
929
1331
  add("files", hooks, {
930
1332
  "arch/naming-file": [
@@ -939,20 +1341,20 @@ var namingGroup = (project, settings, owner) => {
939
1341
  add("exports", components, { "arch/files-components-only": ["error", { filename: true, types: true, roles: ["illustration"] }] }, [...ui, ...testFiles]);
940
1342
  add("folders", presetScopes(ungroupedWeb, "components/**/*.tsx"), { "arch/naming-folder": ["error", { singularize: "trailing-s", separators: ["-"] }] }, [...ui, ...testFiles]);
941
1343
  for (const role of ["action", "query"]) {
942
- const files = [...presetScopes(flatApp, `services/**/*-${role}.*.ts`), ...presetScopes(web, `services/**/*-${role}.*.rsc.tsx`)];
1344
+ const files = [...presetServiceScopes(project.serviceFolders, flatApp, `**/*-${role}.*.ts`), ...presetServiceScopes(project.serviceFolders, web, `**/*-${role}.*.rsc.tsx`)];
943
1345
  add("files", files, {
944
1346
  "arch/naming-file": ["error", { pattern: `^[a-z0-9]+(?:-[a-z0-9]+)*-${role}\\.[a-z0-9]+(?:-[a-z0-9]+)*\\.ts$`, message: namingOperationMessage }]
945
- });
1347
+ }, testFiles);
946
1348
  add("exports", files, {
947
1349
  "arch/naming-exports": [
948
1350
  "error",
949
- { ...presetExportPrefix, roles: [role], scope: "functions", allow: "^\\*$" },
1351
+ namingOperationPrefix(role),
950
1352
  namingOperation(role, `{domain}-${role}.{name}.ts`)
951
1353
  ]
952
- });
1354
+ }, testFiles);
953
1355
  }
954
1356
  for (const [role, folder, separator] of [["action", "actions", "."], ["query", "queries", "-"]]) {
955
- const files = presetScopes(folderApp, `services/*/${folder}/*.ts`);
1357
+ const files = presetServiceScopes(project.serviceFolders, folderApp, `*/${folder}/*.ts`);
956
1358
  add("files", files, {
957
1359
  "arch/naming-file": [
958
1360
  "error",
@@ -961,17 +1363,17 @@ var namingGroup = (project, settings, owner) => {
961
1363
  message: "Use domain-action.name.ts in actions/ and domain-query-name.ts in queries/."
962
1364
  }
963
1365
  ]
964
- });
1366
+ }, testFiles);
965
1367
  add("exports", files, {
966
1368
  "arch/naming-exports": [
967
1369
  "error",
968
1370
  { mode: "template", file: `{domain}-${role}${separator}{name}.ts`, export: `make{Domain}${role === "action" ? "Action" : "Query"}{Name}`, placeholder: "[a-z0-9-]+" }
969
1371
  ]
970
- });
1372
+ }, testFiles);
971
1373
  }
972
1374
  for (const role of ["action", "query"]) {
973
1375
  for (const suffix of ["client.ts", "server.ts", "rsc.ts", "rsc.tsx"]) {
974
- const files = presetScopes(web, `services/**/*-${role}.*.${suffix}`);
1376
+ const files = presetServiceScopes(project.serviceFolders, web, `**/*-${role}.*.${suffix}`);
975
1377
  add("files", files, {
976
1378
  "arch/naming-file": [
977
1379
  "error",
@@ -980,11 +1382,11 @@ var namingGroup = (project, settings, owner) => {
980
1382
  message: "Frontend operations retain a client, server or rsc runtime suffix."
981
1383
  }
982
1384
  ]
983
- });
984
- add("exports", files, { "arch/naming-exports": ["error", namingOperation(role, `{domain}-${role}.{name}.${suffix}`)] });
1385
+ }, testFiles);
1386
+ add("exports", files, { "arch/naming-exports": ["error", namingOperationPrefix(role), namingOperation(role, `{domain}-${role}.{name}.${suffix}`)] }, testFiles);
985
1387
  }
986
1388
  }
987
- add("exports", presetScopes(web, "services/**/*.rsc.{ts,tsx}"), { "arch/naming-exports": ["error", { mode: "pattern", pattern: "^[a-z][a-z0-9]*Rsc[A-Z][a-zA-Z0-9]*$" }] }, ["**/*-{action,query}.*.rsc.{ts,tsx}"]);
1389
+ add("exports", presetServiceScopes(project.serviceFolders, web, "**/*.rsc.{ts,tsx}"), { "arch/naming-exports": ["error", namingFrontendPrefix, { mode: "pattern", pattern: "^[a-z][a-z0-9]*Rsc[A-Z][a-zA-Z0-9]*$" }] }, ["**/*-{action,query}.*.rsc.{ts,tsx}", ...testFiles]);
988
1390
  if (typeof exportsPattern === "string") {
989
1391
  overrides.push(presetOverride([`**/${presetExtensions}`], { "arch/naming-exports": ["error", { mode: "pattern", pattern: exportsPattern, types: true }] }));
990
1392
  }
@@ -1118,7 +1520,7 @@ var shadcnGroup = (project) => {
1118
1520
 
1119
1521
  // src/preset/groups/sql.ts
1120
1522
  var sqlGroup = (project, settings) => [
1121
- presetOverride(presetScopes(project.serviceRoots, "services/**/*.ts"), {
1523
+ presetOverride(presetServiceScopes(project.serviceFolders, project.serviceRoots, "**/*.ts"), {
1122
1524
  "arch/backend-sql-like": [
1123
1525
  "error",
1124
1526
  { methods: ["like", "ilike"], operators: ["where"], sanitizers: settings.sanitizers, bindings: true }
@@ -1179,7 +1581,7 @@ var tanstackGroup = (project, settings) => {
1179
1581
  patterns: project.baseImports
1180
1582
  }
1181
1583
  ]
1182
- }, presetScopes(tanstackWeb, "services/**/*.rsc.{ts,tsx}")),
1584
+ }, presetServiceScopes(project.serviceFolders, tanstackWeb, "**/*.rsc.{ts,tsx}")),
1183
1585
  ...presetRuntime(project, settings, "tanstack", "arch/boundaries-tanstack")
1184
1586
  ];
1185
1587
  };
@@ -1209,8 +1611,12 @@ function optionsPatternsTest(patterns, value) {
1209
1611
  });
1210
1612
  }
1211
1613
  function optionsGlob(pattern) {
1212
- const source = pattern.replaceAll(/[.+^$()|[\]\\]/g, "\\$&").replaceAll("?", "[^/]").replaceAll("**/", "\x00").replaceAll("**", "\x01").replaceAll("*", "[^/]*").replaceAll("\x00", "(?:.*/)?").replaceAll("\x01", ".*").replaceAll(/\{([^}]*)\}/g, (_, options) => `(?:${options.split(",").join("|")})`);
1213
- return new RegExp(`^${source}$`);
1614
+ const negated = [];
1615
+ const source = pattern.replaceAll(/!\(([^()]*)\)/g, (_, options) => {
1616
+ negated.push(options.split("|").map((option) => option.replaceAll(/[.+^$()|[\]\\*?{}]/g, "\\$&")).join("|"));
1617
+ return `\x02${negated.length - 1}\x03`;
1618
+ }).replaceAll(/[.+^$()|[\]\\]/g, "\\$&").replaceAll("?", "[^/]").replaceAll("**/", "\x00").replaceAll("**", "\x01").replaceAll("*", "[^/]*").replaceAll("\x00", "(?:.*/)?").replaceAll("\x01", ".*").replaceAll(/\{([^}]*)\}/g, (_, options) => `(?:${options.split(",").join("|")})`);
1619
+ return new RegExp(`^${negated.reduce((text, names, index) => text.replace(`\x02${index}\x03`, `(?!(?:${names})(?:/|$))[^/]*`), source)}$`);
1214
1620
  }
1215
1621
  function optionsPatternLabel(pattern) {
1216
1622
  return pattern === undefined ? "" : [pattern].flat().filter((source) => source !== "").join(" | ");
@@ -1260,6 +1666,7 @@ var testsGroup = (project, settings) => {
1260
1666
  }
1261
1667
  return [
1262
1668
  presetOverride(project.testFiles, rules),
1669
+ presetOverride(presetScopes(project.units.map((unit) => unit.source), "**/*.{test,spec}.{ts,tsx,mts,cts}"), { "arch/files-restricted": ["error", { message: "Tests live in a tests/ folder, not next to the code." }] }, ["**/tests/**", "**/test/**"]),
1263
1670
  presetOverride(presetScopes(project.backendRoots, "tests/**/*.ts"), backendRules),
1264
1671
  ...allow.map((entry) => presetOverride(entry.files, { "dillon-anti-slop/no-module-mocking": entry.apis.includes("*") ? "off" : mockingRule(entry.apis) }))
1265
1672
  ];
@@ -1322,254 +1729,151 @@ var typesGroup = (project, settings) => {
1322
1729
  };
1323
1730
 
1324
1731
  // src/preset/preset-folders.ts
1325
- import { existsSync as existsSync3, readdirSync as readdirSync2, realpathSync, statSync as statSync2 } from "node:fs";
1326
- import { posix as posix4, resolve as resolve5 } from "node:path";
1327
-
1328
- // src/preset/preset-detect.ts
1329
- import { existsSync as existsSync2, readdirSync, readFileSync as readFileSync2, statSync } from "node:fs";
1330
- import { posix as posix3, resolve as resolve4 } from "node:path";
1732
+ import { existsSync as existsSync3, readdirSync as readdirSync4, realpathSync as realpathSync2 } from "node:fs";
1733
+ import { posix as posix7, resolve as resolve7 } from "node:path";
1331
1734
 
1332
- // src/preset/preset-recipes.ts
1333
- var presetUi = { react: true, kit: true, tailwind: true };
1334
- var presetRecipes = {
1335
- tanstack: { react: true, tanstack: true, tailwind: true, shadcn: true, schemas: true, forms: true },
1336
- next: { react: true, next: true, tailwind: true, shadcn: true, schemas: true, forms: true },
1337
- expo: { react: true, expo: true, schemas: true, forms: true },
1338
- backend: { backend: true, schemas: true, sql: true, layout: { strict: true } },
1339
- react: { react: true, tailwind: true, schemas: true },
1340
- desktop: { console: true },
1341
- library: { schemas: true },
1342
- ui: presetUi,
1343
- kit: presetUi,
1344
- cli: { console: true }
1345
- };
1346
- var presetTemplates = {
1347
- monorepo: {
1348
- "apps/web": presetRecipes.tanstack,
1349
- "apps/server": presetRecipes.backend,
1350
- "packages/*": presetRecipes.library,
1351
- scripts: presetRecipes.cli
1352
- }
1353
- };
1354
-
1355
- // src/preset/preset-detect.ts
1356
- function presetManifest(root, directory) {
1357
- try {
1358
- return JSON.parse(readFileSync2(resolve4(root, directory, "package.json"), "utf8"));
1359
- } catch {
1360
- return {};
1735
+ // src/preset/preset-stylesheet.ts
1736
+ import { readdirSync as readdirSync2, readFileSync as readFileSync3, realpathSync } from "node:fs";
1737
+ import { posix as posix5, resolve as resolve5 } from "node:path";
1738
+ function presetTailwindEntry(css) {
1739
+ const code = css.replaceAll(/\/\*[\s\S]*?\*\//g, "");
1740
+ return /(?:^|[;{}])\s*@(?:import\s+(?:url\(\s*(["']?)tailwindcss(?:\/[^"')\s]*)?\1\s*\)|(["'])tailwindcss(?:\/[^"']*)?\2)|tailwind\s)/.test(code);
1741
+ }
1742
+ function presetStylesheet(root, directory) {
1743
+ const found = [];
1744
+ const queue = [directory];
1745
+ const visited = new Set;
1746
+ while (queue.length > 0) {
1747
+ const folder = queue.shift() ?? directory;
1748
+ const real = realpathSync(resolve5(root, folder));
1749
+ if (visited.has(real)) {
1750
+ continue;
1751
+ }
1752
+ visited.add(real);
1753
+ for (const name of readdirSync2(resolve5(root, folder))) {
1754
+ const path = posix5.join(folder, name);
1755
+ const full = resolve5(root, path);
1756
+ const depth = posix5.relative(directory, path).split("/").length;
1757
+ if (presetIsDirectory(full)) {
1758
+ if (!name.startsWith(".") && !presetDetectSkip.includes(name) && depth < 8) {
1759
+ queue.push(path);
1760
+ }
1761
+ } else if (name.endsWith(".css") && presetIsFile(full) && presetTailwindEntry(readFileSync3(full, "utf8"))) {
1762
+ found.push(path);
1763
+ }
1764
+ }
1361
1765
  }
1766
+ const rank = (path) => {
1767
+ const index = presetStylesheetNames.indexOf(posix5.basename(path, ".css"));
1768
+ return index === -1 ? presetStylesheetNames.length : index;
1769
+ };
1770
+ return found.toSorted((left, right) => rank(left) - rank(right) || left.length - right.length).at(0);
1362
1771
  }
1363
- function presetDirectories(root, base) {
1364
- if (!existsSync2(resolve4(root, base))) {
1365
- return [];
1772
+
1773
+ // src/preset/preset-units.ts
1774
+ import { existsSync as existsSync2, readdirSync as readdirSync3 } from "node:fs";
1775
+ import { posix as posix6, resolve as resolve6 } from "node:path";
1776
+ var presetFrameworks = ["tanstack", "next", "expo"];
1777
+ var presetKindGroups = [...presetFrameworks, "backend", "console"];
1778
+ function presetUnitSource(root, key, kind, source) {
1779
+ if (source !== undefined) {
1780
+ if (source !== "." && !source.split("/").every((segment) => presetLiteralSegment.test(segment))) {
1781
+ throw new PresetError(`Folder \`${key}\` source must be \`.\` or a literal relative path.`);
1782
+ }
1783
+ return posix6.join(key, source);
1366
1784
  }
1367
- return readdirSync(resolve4(root, base)).filter((name) => !name.startsWith(".") && statSync(resolve4(root, base, name)).isDirectory()).map((name) => posix3.join(base, name));
1785
+ return kind === "console" || !existsSync2(resolve6(root, key, "src")) ? key : posix6.join(key, "src");
1368
1786
  }
1369
- function presetSource(root, directory) {
1370
- return existsSync2(resolve4(root, directory, "src")) ? posix3.join(directory, "src") : directory;
1787
+ function presetSets(folder, name) {
1788
+ return folder[name] !== undefined && folder[name] !== false;
1371
1789
  }
1372
- function presetRecipe(root, directory, app) {
1373
- const manifest = presetManifest(root, directory);
1374
- const dependencies = new Set(Object.keys({ ...manifest.dependencies, ...manifest.devDependencies }));
1375
- const has = (names) => names.some((name) => dependencies.has(name));
1376
- const source = presetSource(root, directory);
1377
- const framework = presetDetectFrameworks.find(([, names]) => has(names))?.[0];
1378
- const services = resolve4(root, source, "services");
1379
- const entries = existsSync2(services) ? readdirSync(services, { withFileTypes: true }) : [];
1380
- const flat = entries.filter((entry) => entry.isFile() && /\.tsx?$/.test(entry.name)).length > entries.filter((entry) => entry.isDirectory()).length;
1381
- if (framework) {
1382
- return flat ? { ...presetRecipes[framework], layout: { services: "flat" } } : presetRecipes[framework];
1383
- }
1384
- if (has(presetDetectDesktop)) {
1385
- return presetRecipes.desktop;
1790
+ function presetKind(key, enabled) {
1791
+ const frameworks = presetFrameworks.filter(enabled);
1792
+ if (frameworks.length > 1) {
1793
+ throw new PresetError(`Folder \`${key}\` enables more than one of tanstack, next and expo.`);
1386
1794
  }
1387
- if (has(presetDetectBackend) && (app || existsSync2(services))) {
1388
- return presetRecipes.backend;
1795
+ const [framework] = frameworks;
1796
+ if (frameworks.length > 0 && enabled("backend")) {
1797
+ throw new PresetError(`Folder \`${key}\` is either an app (tanstack, next, expo) or a backend, not both.`);
1389
1798
  }
1390
- if (has(["react"]) && existsSync2(resolve4(root, source, "components"))) {
1391
- return app ? presetRecipes.react : presetRecipes.ui;
1799
+ let kind = "library";
1800
+ if (frameworks.length > 0) {
1801
+ kind = "web";
1802
+ } else if (enabled("backend")) {
1803
+ kind = "backend";
1804
+ } else if (enabled("console")) {
1805
+ kind = "console";
1392
1806
  }
1393
- return presetRecipes.library;
1807
+ return { kind, framework };
1394
1808
  }
1395
- function presetMergeObject(base, top, depth) {
1396
- const merged = { ...base };
1397
- for (const [key, value] of Object.entries(top)) {
1398
- const previous = merged[key];
1399
- const nested = previous instanceof Object && value instanceof Object && !Array.isArray(previous) && !Array.isArray(value);
1400
- if (nested && depth > 0) {
1401
- merged[key] = presetMergeObject(previous, value, depth - 1);
1402
- } else if (value !== true || !(previous instanceof Object)) {
1403
- merged[key] = value;
1404
- }
1809
+ function presetHasManifest(root, key) {
1810
+ if (key === ".") {
1811
+ return false;
1405
1812
  }
1406
- return merged;
1407
- }
1408
- function presetMerge(base, top) {
1409
- if (!(base instanceof Object) || !(top instanceof Object)) {
1410
- return top === true && base instanceof Object ? base : top;
1813
+ if (!key.endsWith("/*")) {
1814
+ return existsSync2(resolve6(root, key, "package.json"));
1411
1815
  }
1412
- return presetMergeObject(base, top, 2);
1816
+ const base = resolve6(root, key.slice(0, -2));
1817
+ return presetIsDirectory(base) && readdirSync3(base).some((name) => existsSync2(resolve6(base, name, "package.json")));
1413
1818
  }
1414
- function presetSpecifiers(manifest, target) {
1415
- return Object.entries(manifest.imports ?? {}).flatMap(([key, value]) => {
1416
- const prefix = typeof value === "string" && value.endsWith("/*") && key.endsWith("/*") ? posix3.normalize(value.slice(0, -2)) : undefined;
1417
- if (prefix === undefined || target !== prefix && !target.startsWith(`${prefix}/`)) {
1418
- return [];
1419
- }
1420
- return [`${key.slice(0, -2)}${target.slice(prefix.length)}`];
1421
- });
1422
- }
1423
- function presetDeny(imports) {
1424
- return { deny: { imports } };
1425
- }
1426
- function presetBackendConventions(root, key, manifest) {
1427
- const source = presetSource(root, key);
1428
- const local = posix3.relative(key, source) || ".";
1429
- const at = (path) => local === "." ? path : posix3.join(local, path);
1430
- const exists = (path) => existsSync2(resolve4(root, key, path));
1431
- const registry = presetRegistryFiles.map(at).find(exists);
1432
- const services = presetSpecifiers(manifest, at("services"));
1433
- const operations = Object.fromEntries(services.flatMap((prefix) => presetOperationImports.map((pattern) => [`${prefix}/${pattern}`, "Use the service API, not another service’s operations."])));
1434
- const registryImports = registry ? Object.fromEntries(presetSpecifiers(manifest, registry.replace(/\.ts$/, "")).map((specifier) => [specifier, "Only the composition root imports the service registry."])) : {};
1435
- const entries = Object.fromEntries(presetServerEntries.map((entry) => [`${manifest.name}/${entry}`, "Server code imports its own modules, not the package entries."]));
1436
- const conventions = {
1437
- [at("services")]: presetDeny({
1438
- ...registryImports,
1439
- ...operations,
1440
- ...manifest.name ? entries : {}
1441
- }),
1442
- [at("core")]: presetDeny(operations),
1443
- [at("rpc")]: presetDeny(registryImports),
1444
- [at("routes")]: presetDeny(registryImports),
1445
- [at("cli")]: {
1446
- naming: { files: presetCliFiles },
1447
- ...presetDeny({ ...operations, ...Object.fromEntries(services.map((prefix) => [`${prefix}/*/*.service`, "Reach services through the registry."])) })
1448
- }
1449
- };
1450
- for (const tests of ["tests", "test"]) {
1451
- if (exists(posix3.join(tests, "kits"))) {
1452
- conventions[tests] = presetDeny(Object.fromEntries(presetKitImports.map((pattern) => [pattern, "Import the kit file you need."])));
1453
- conventions[posix3.join(tests, "kits")] = { naming: { files: presetKitFiles } };
1454
- }
1455
- }
1456
- const queue = ["."];
1457
- while (queue.length > 0) {
1458
- const folder = queue.shift() ?? ".";
1459
- for (const child of presetDirectories(root, posix3.join(key, folder)).map((path) => posix3.relative(key, path))) {
1460
- if (posix3.basename(child) === "migrations") {
1461
- conventions[child] = { layout: false, naming: { files: presetMigrationFiles, unique: presetMigrationNumber } };
1462
- } else if (child.split("/").length < 6 && !presetDetectSkip.includes(posix3.basename(child))) {
1463
- queue.push(child);
1819
+ function presetUnit(root, key, scope) {
1820
+ const { effective } = scope;
1821
+ const wildcard = scope.parent && scope.parent.depth === scope.depth && scope.parent.key.includes("*") ? scope.parent.folder : {};
1822
+ const folder = { ...wildcard, ...scope.folder };
1823
+ const own = presetKindGroups.some((name) => presetSets(folder, name)) || presetHasManifest(root, key);
1824
+ const { kind, framework } = presetKind(key, (name) => own ? presetSets(folder, name) : Boolean(effective[name]));
1825
+ const layout = {};
1826
+ const settings = effective.layout;
1827
+ if (settings) {
1828
+ const services2 = settings.services ?? (kind === "console" ? false : "domain");
1829
+ const hooks = settings.hooks ?? (kind === "web" ? "flat" : false);
1830
+ const modes = { services: services2, hooks, components: settings.components ?? false };
1831
+ for (const [name, mode] of Object.entries(modes)) {
1832
+ if (mode === "flat" || mode === "domain") {
1833
+ layout[name] = mode;
1464
1834
  }
1465
1835
  }
1466
1836
  }
1467
- return Object.fromEntries(Object.entries(conventions).filter(([path, folder]) => {
1468
- const empty = folder instanceof Object && folder.deny instanceof Object && Object.keys(folder.deny.imports ?? {}).length === 0;
1469
- return exists(path) && !empty;
1470
- }));
1471
- }
1472
- function presetWebConventions(root, key, servers) {
1473
- const source = posix3.relative(key, presetSource(root, key)) || ".";
1474
- const at = (path) => source === "." ? path : posix3.join(source, path);
1475
- const exists = (path) => existsSync2(resolve4(root, key, path));
1476
- const internals = servers.flatMap((manifest) => Object.keys(manifest.imports ?? {}).map((specifier) => specifier.replace(/\*$/, "**")));
1477
- const named = servers.flatMap((manifest) => manifest.name ? [manifest.name] : []);
1478
- const entries = Object.fromEntries(named.flatMap((name) => presetServerEntries.map((entry) => [`${name}/${entry}`, "UI code imports the client entry."])));
1479
- const clients = Object.fromEntries(named.map((name) => [`${name}/client`, "Route handlers run on the server; import the server entry."]));
1480
- const conventions = {
1481
- ".": presetDeny({
1482
- ...Object.fromEntries(internals.map((specifier) => [specifier, "Import the backend through its client entry."])),
1483
- ...servers.length > 0 ? { "**/services/*.service": "Import the client or rsc service file, not a server service." } : {}
1484
- }),
1485
- [at("components")]: presetDeny(entries),
1486
- [at("routes")]: presetDeny(entries),
1487
- [at("routes/handlers")]: { deny: { imports: { ...clients, ...Object.fromEntries(Object.keys(entries).map((entry) => [entry, false])) } } }
1488
- };
1489
- return Object.fromEntries(Object.entries(conventions).filter(([path]) => path === "." || exists(path)));
1837
+ const source = presetUnitSource(root, key, kind, folder.source);
1838
+ const services = presetServicesFolder(root, source, folder.services, kind === "web" || kind === "backend");
1839
+ return { key, scope, kind, framework, source, services, layout };
1490
1840
  }
1491
- function presetDetect(root, folders) {
1492
- const manifest = presetManifest(root, ".");
1493
- const workspaces = Array.isArray(manifest.workspaces) ? manifest.workspaces : manifest.workspaces?.packages ?? [];
1494
- const directories = workspaces.flatMap((pattern) => pattern.endsWith("/*") ? presetDirectories(root, pattern.slice(0, -2)) : [pattern]).filter((directory) => existsSync2(resolve4(root, directory, "package.json")));
1495
- const detected = Object.fromEntries(directories.map((directory) => [directory, presetRecipe(root, directory, !directory.startsWith("packages/"))]));
1496
- if (directories.length === 0) {
1497
- const recipe = presetRecipe(root, ".", true);
1498
- if (recipe !== presetRecipes.library) {
1499
- detected["."] = recipe;
1500
- }
1501
- }
1502
- if (existsSync2(resolve4(root, "scripts")) && !directories.includes("scripts")) {
1503
- detected.scripts = presetRecipes.cli;
1504
- }
1505
- const keys = Object.keys(folders);
1506
- const covered = (key) => keys.some((user) => user !== key && user !== "." && (key.startsWith(`${user}/`) || user.endsWith("/*") && posix3.dirname(key) === user.slice(0, -2)));
1507
- const merged = { ".": presetDeny(presetRootImports) };
1508
- for (const [key, value] of Object.entries(detected)) {
1509
- if (!covered(key)) {
1510
- merged[key] = presetMerge(merged[key], value);
1511
- }
1512
- }
1513
- for (const [key, value] of Object.entries(folders)) {
1514
- merged[key] = key in merged ? presetMerge(merged[key], value) : value;
1515
- }
1516
- const units = Object.entries(merged).filter(([key]) => !key.includes("*"));
1517
- const servers = units.filter(([, value]) => value instanceof Object && Boolean(value.backend)).map(([key]) => presetManifest(root, key));
1518
- const internals = servers.flatMap((server) => Object.keys(server.imports ?? {}).map((specifier) => specifier.replace(/\*$/, "**")));
1519
- const add = (key, folder) => {
1520
- merged[key] = key in merged ? presetMerge(folder, merged[key]) : folder;
1521
- };
1522
- for (const [key, value] of units) {
1523
- if (!(value instanceof Object)) {
1524
- continue;
1525
- }
1526
- if (value.backend) {
1527
- for (const [path, folder] of Object.entries(presetBackendConventions(root, key, presetManifest(root, key)))) {
1528
- add(path === "." ? key : posix3.join(key, path), folder);
1529
- }
1530
- } else if (value.tanstack || value.next) {
1531
- for (const [path, folder] of Object.entries(presetWebConventions(root, key, servers))) {
1532
- add(path === "." ? key : posix3.join(key, path), folder);
1533
- }
1534
- } else if ((value.kit || key.startsWith("packages/")) && !value.expo) {
1535
- const own = Object.keys(presetManifest(root, key).imports ?? {}).map((specifier) => specifier.replace(/\*$/, "**"));
1536
- const message = presetPackageImports["../../apps/**"];
1537
- const appImports = internals.filter((specifier) => !own.includes(specifier)).map((specifier) => [specifier, message]);
1538
- add(key, presetDeny({ ...presetPackageImports, ...Object.fromEntries(appImports) }));
1841
+ function presetUnits(root, scopes, ignored) {
1842
+ const [rootScope] = scopes;
1843
+ const rootKind = presetKind(".", (name) => Boolean(rootScope.effective[name])).kind;
1844
+ const candidates = [];
1845
+ for (const scope of scopes) {
1846
+ const manifest = presetHasManifest(root, scope.key);
1847
+ const own = presetKindGroups.some((name) => presetSets(scope.folder, name)) || manifest;
1848
+ const top = scope.parent?.key === "." && (rootKind === "library" || own);
1849
+ const sibling = scope.parent !== null && candidates.includes(scope.parent) && scope.parent.depth === scope.depth && !scope.key.includes("*");
1850
+ const workspace = scope.parent !== null && scope.parent.key !== "." && manifest;
1851
+ if (top || sibling || workspace) {
1852
+ candidates.push(scope);
1539
1853
  }
1540
1854
  }
1541
- return merged;
1542
- }
1543
- function presetStylesheet(root, directory) {
1544
- const found = [];
1545
- const queue = [directory];
1546
- while (queue.length > 0) {
1547
- const folder = queue.shift() ?? directory;
1548
- for (const child of readdirSync(resolve4(root, folder), { withFileTypes: true })) {
1549
- const path = posix3.join(folder, child.name);
1550
- if (child.isDirectory() && !child.name.startsWith(".") && !presetDetectSkip.includes(child.name) && path.split("/").length < 8) {
1551
- queue.push(path);
1552
- } else if (child.isFile() && child.name.endsWith(".css") && /@import\s+["']tailwindcss|@tailwind\s/.test(readFileSync2(resolve4(root, path), "utf8"))) {
1553
- found.push(path);
1554
- }
1855
+ const unitScopes = candidates.filter((scope) => !ignored.has(scope.key));
1856
+ const explicit = new Set(unitScopes.map((scope) => scope.key));
1857
+ const rootSource = presetUnitSource(root, ".", rootKind, rootScope.folder.source);
1858
+ const overlaps = (left, right) => left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`);
1859
+ const rootOwnsSource = rootSource !== "." && !unitScopes.some((scope) => overlaps(scope.key.replace(/\/\*$/, ""), rootSource));
1860
+ const rootUnit = rootKind !== "library" || rootOwnsSource || unitScopes.every((scope) => presetUnit(root, scope.key, scope).kind === "console");
1861
+ return [...rootUnit ? [rootScope] : [], ...unitScopes].flatMap((scope) => {
1862
+ if (!scope.key.endsWith("/*")) {
1863
+ return [presetUnit(root, scope.key, scope)];
1555
1864
  }
1556
- }
1557
- const rank = (path) => {
1558
- const index = presetStylesheetNames.indexOf(posix3.basename(path, ".css"));
1559
- return index === -1 ? presetStylesheetNames.length : index;
1560
- };
1561
- return found.toSorted((left, right) => rank(left) - rank(right) || left.length - right.length).at(0);
1865
+ const base = scope.key.slice(0, -2);
1866
+ return readdirSync3(resolve6(root, base)).filter((name) => !name.startsWith(".") && presetIsDirectory(resolve6(root, base, name))).map((name) => posix6.join(base, name)).filter((key) => !explicit.has(key) && !ignored.has(key)).map((key) => presetUnit(root, key, scope));
1867
+ });
1562
1868
  }
1563
1869
 
1564
1870
  // src/preset/preset-folders.ts
1565
- var presetFrameworks = ["tanstack", "next", "expo"];
1566
- var presetKindGroups = [...presetFrameworks, "backend", "console"];
1567
1871
  function presetNormalizeKey(key) {
1568
1872
  const portable = key.replaceAll("\\", "/");
1569
- if (!portable.trim() || posix4.isAbsolute(portable) || /^[a-z]:/i.test(portable)) {
1873
+ if (!portable.trim() || posix7.isAbsolute(portable) || /^[a-z]:/i.test(portable)) {
1570
1874
  throw new PresetError(`Folder keys must be project-relative paths: ${key}`);
1571
1875
  }
1572
- const normalized = posix4.normalize(portable).replace(/\/$/, "");
1876
+ const normalized = posix7.normalize(portable).replace(/\/$/, "");
1573
1877
  if (normalized === ".." || normalized.startsWith("../")) {
1574
1878
  throw new PresetError(`Folder keys must stay inside the project: ${key}`);
1575
1879
  }
@@ -1607,7 +1911,14 @@ function presetGroupValue(name, value, inherited, severity) {
1607
1911
  if (unknown) {
1608
1912
  throw new PresetError(`Unknown option \`${name}.${unknown}\`.`);
1609
1913
  }
1610
- return { ...base, ...changes, level: severity ?? base.level };
1914
+ const plain = (option) => option instanceof Object && !Array.isArray(option);
1915
+ const merged = Object.fromEntries(Object.entries(changes).map(([key, option]) => {
1916
+ const stored = Object(base)[key];
1917
+ const field = presetShorthandOptions.find(([group, setting]) => group === name && setting === key)?.[2];
1918
+ const previous = typeof stored === "string" && field !== undefined && plain(option) ? { [field]: stored } : stored;
1919
+ return [key, plain(previous) && plain(option) ? { ...Object(previous), ...Object(option) } : option];
1920
+ }));
1921
+ return { ...base, ...merged, level: severity ?? base.level };
1611
1922
  }
1612
1923
  function presetDenyEntries(inherited, changes) {
1613
1924
  return Object.fromEntries(Object.entries({ ...inherited, ...changes }).flatMap(([key, message]) => {
@@ -1653,55 +1964,6 @@ function presetFolder(key, value) {
1653
1964
  }
1654
1965
  return folder;
1655
1966
  }
1656
- function presetUnitSource(root, key, kind, source) {
1657
- if (source !== undefined) {
1658
- if (source !== "." && !source.split("/").every((segment) => presetLiteralSegment.test(segment))) {
1659
- throw new PresetError(`Folder \`${key}\` source must be \`.\` or a literal relative path.`);
1660
- }
1661
- return posix4.join(key, source);
1662
- }
1663
- return kind === "console" || key === "." && !existsSync3(resolve5(root, "src")) ? key : posix4.join(key, "src");
1664
- }
1665
- function presetSets(folder, name) {
1666
- return folder[name] !== undefined && folder[name] !== false;
1667
- }
1668
- function presetKind(key, enabled) {
1669
- const frameworks = presetFrameworks.filter(enabled);
1670
- if (frameworks.length > 1) {
1671
- throw new PresetError(`Folder \`${key}\` enables more than one of tanstack, next and expo.`);
1672
- }
1673
- const [framework] = frameworks;
1674
- if (frameworks.length > 0 && enabled("backend")) {
1675
- throw new PresetError(`Folder \`${key}\` is either an app (tanstack, next, expo) or a backend, not both.`);
1676
- }
1677
- let kind = "library";
1678
- if (frameworks.length > 0) {
1679
- kind = "web";
1680
- } else if (enabled("backend")) {
1681
- kind = "backend";
1682
- } else if (enabled("console")) {
1683
- kind = "console";
1684
- }
1685
- return { kind, framework };
1686
- }
1687
- function presetUnit(root, key, scope) {
1688
- const { folder, effective } = scope;
1689
- const own = presetKindGroups.some((name) => presetSets(folder, name));
1690
- const { kind, framework } = presetKind(key, (name) => own ? presetSets(folder, name) : Boolean(effective[name]));
1691
- const layout = {};
1692
- const settings = effective.layout;
1693
- if (settings) {
1694
- const services = settings.services ?? (kind === "console" ? false : "domain");
1695
- const hooks = settings.hooks ?? (kind === "web" ? "flat" : false);
1696
- const modes = { services, hooks, components: settings.components ?? false };
1697
- for (const [name, mode] of Object.entries(modes)) {
1698
- if (mode === "flat" || mode === "domain") {
1699
- layout[name] = mode;
1700
- }
1701
- }
1702
- }
1703
- return { key, scope, kind, framework, source: presetUnitSource(root, key, kind, scope.folder.source), layout };
1704
- }
1705
1967
  function presetScopesTree(root, folders) {
1706
1968
  const entries = Object.entries(folders).map(([key, value]) => [presetNormalizeKey(key), value]);
1707
1969
  const keys = entries.map(([key]) => key);
@@ -1711,7 +1973,7 @@ function presetScopesTree(root, folders) {
1711
1973
  }
1712
1974
  for (const key of keys) {
1713
1975
  const base = key.endsWith("/*") ? key.slice(0, -2) : key;
1714
- if (!existsSync3(resolve5(root, base))) {
1976
+ if (!existsSync3(resolve7(root, base))) {
1715
1977
  throw new PresetError(`Folder \`${key}\` does not exist.`);
1716
1978
  }
1717
1979
  }
@@ -1743,38 +2005,28 @@ function presetScopesTree(root, folders) {
1743
2005
  return scopes;
1744
2006
  }
1745
2007
  function presetProject(config) {
1746
- const given = resolve5(config.root ?? process.cwd());
1747
- const root = existsSync3(given) ? realpathSync(given) : given;
1748
- const folders = config.detect === false ? config.folders ?? {} : presetDetect(root, config.folders ?? {});
2008
+ const given = resolve7(config.root ?? process.cwd());
2009
+ const root = existsSync3(given) ? realpathSync2(given) : given;
2010
+ const listed = Object.entries(config.folders ?? {}).map(([key, value]) => [presetNormalizeKey(key), value]);
2011
+ const twice = listed.find(([key], index) => listed.findIndex(([other]) => other === key) !== index);
2012
+ if (twice) {
2013
+ throw new PresetError(`Folder \`${twice[0]}\` is listed twice.`);
2014
+ }
2015
+ const folders = config.detect === false ? Object.fromEntries(listed) : presetDetect(root, Object.fromEntries(listed));
1749
2016
  const scopes = presetScopesTree(root, folders);
1750
2017
  const ignored = new Set(Object.entries(folders).flatMap(([key, value]) => value === false ? [presetNormalizeKey(key)] : []));
1751
2018
  const live = scopes.filter((scope) => !ignored.has(scope.key));
1752
- const [rootScope] = scopes;
1753
- const rootKind = presetKind(".", (name) => Boolean(rootScope.effective[name])).kind;
1754
2019
  const dead = live.find((scope) => scope.parent && ignored.has(scope.parent.key) && scope.parent.depth < scope.depth);
1755
2020
  if (dead) {
1756
2021
  throw new PresetError(`Folder \`${dead.key}\` sits inside the skipped folder \`${dead.parent?.key}\`.`);
1757
2022
  }
1758
- const candidates = [];
1759
- for (const scope of scopes) {
1760
- const top = scope.parent?.key === "." && (rootKind === "library" || presetKindGroups.some((name) => presetSets(scope.folder, name)));
1761
- const sibling = scope.parent !== null && candidates.includes(scope.parent) && scope.parent.depth === scope.depth && !scope.key.includes("*");
1762
- if (top || sibling) {
1763
- candidates.push(scope);
1764
- }
1765
- }
1766
- const unitScopes = candidates.filter((scope) => !ignored.has(scope.key));
1767
- const explicit = new Set(unitScopes.map((scope) => scope.key));
1768
- const rootUnit = rootKind !== "library" || unitScopes.length === 0;
1769
- const units = [...rootUnit ? [rootScope] : [], ...unitScopes].flatMap((scope) => {
1770
- if (!scope.key.endsWith("/*")) {
1771
- return [presetUnit(root, scope.key, scope)];
1772
- }
1773
- const base = scope.key.slice(0, -2);
1774
- return readdirSync2(resolve5(root, base)).filter((name) => !name.startsWith(".") && statSync2(resolve5(root, base, name)).isDirectory()).map((name) => posix4.join(base, name)).filter((key) => !explicit.has(key) && !ignored.has(key)).map((key) => presetUnit(root, key, scope));
1775
- });
2023
+ const units = presetUnits(root, scopes, ignored);
1776
2024
  const unitKeys = new Set(units.map((unit) => unit.scope.key));
1777
2025
  const misplaced = live.find((scope) => !unitKeys.has(scope.key) && Object(scope.folder.layout).operations !== undefined);
2026
+ const servicesKey = live.find((scope) => !unitKeys.has(scope.key) && scope.folder.services !== undefined);
2027
+ if (servicesKey) {
2028
+ throw new PresetError(`Set services on an app or package folder, not on \`${servicesKey.key}\`.`);
2029
+ }
1778
2030
  if (misplaced) {
1779
2031
  throw new PresetError(`Set layout.operations on an app or package folder, not on \`${misplaced.key}\`.`);
1780
2032
  }
@@ -1791,6 +2043,7 @@ function presetProject(config) {
1791
2043
  const libraryUnits = of("library");
1792
2044
  const packageSources = libraryUnits.map((unit) => unit.source);
1793
2045
  const app = [...web, ...backend, ...packageSources];
2046
+ const serviceFolders = Object.fromEntries(units.map((unit) => [unit.source, unit.services]));
1794
2047
  const appFiles = presetScopes(app);
1795
2048
  const webFiles = presetScopes(web);
1796
2049
  const effective = live.map((scope) => scope.effective);
@@ -1804,9 +2057,9 @@ function presetProject(config) {
1804
2057
  const orpcScopes = effective.flatMap((groups) => groups.orpc ? [groups.orpc] : []);
1805
2058
  const listOf = (values) => [...new Set(values.flatMap((value) => value ?? []))];
1806
2059
  const client = orpcScopes.find((settings) => settings.client !== undefined)?.client;
1807
- const rpcClients = client === undefined ? webUnits.map((unit) => posix4.join(unit.source, "services", unit.layout.services === "domain" ? "rpc/rpc.client.ts" : "rpc.client.ts")) : [client];
2060
+ const rpcClients = client === undefined ? webUnits.map((unit) => posix7.join(unit.services, unit.layout.services === "domain" ? "rpc/rpc.client.ts" : "rpc.client.ts")) : [client];
1808
2061
  const kitUnits = units.filter((unit) => unit.scope.effective.kit);
1809
- const uiRoots = [...new Set([...web.map((path) => posix4.join(path, "components/ui")), ...kitUnits.map((unit) => unit.source)])];
2062
+ const uiRoots = [...new Set([...web.map((path) => posix7.join(path, "components/ui")), ...kitUnits.map((unit) => unit.source)])];
1810
2063
  const webUiRoots = uiRoots.filter((path) => web.some((webRoot) => webRoot === "." || path === webRoot || path.startsWith(`${webRoot}/`)));
1811
2064
  const stylesheet = (unit) => {
1812
2065
  const tailwind = unit.scope.effective.tailwind;
@@ -1814,7 +2067,7 @@ function presetProject(config) {
1814
2067
  return;
1815
2068
  }
1816
2069
  const css = tailwind.css ?? presetStylesheet(root, unit.key);
1817
- return css === undefined ? undefined : resolve5(root, css);
2070
+ return css === undefined ? undefined : resolve7(root, css);
1818
2071
  };
1819
2072
  const webCss = Object.fromEntries(webUnits.flatMap((unit) => {
1820
2073
  const css = stylesheet(unit);
@@ -1848,20 +2101,21 @@ function presetProject(config) {
1848
2101
  return [key];
1849
2102
  }
1850
2103
  const base = key.slice(0, -2);
1851
- return readdirSync2(resolve5(root, base)).filter((name) => statSync2(resolve5(root, base, name)).isDirectory()).map((name) => posix4.join(base, name)).filter((path) => !live.some((scope) => scope.key === path));
2104
+ return readdirSync4(resolve7(root, base)).filter((name) => presetIsDirectory(resolve7(root, base, name))).map((name) => posix7.join(base, name)).filter((path) => !live.some((scope) => scope.key === path));
1852
2105
  });
1853
2106
  return {
1854
2107
  root,
2108
+ serviceFolders,
1855
2109
  scopes: live,
1856
2110
  units,
1857
2111
  suffixes,
1858
2112
  colocated: listOf(effective.map((groups) => groups.types?.colocated)),
1859
2113
  orpc: {
1860
- public: publicProcedures.length > 0 ? publicProcedures : backendUnits.map((unit) => posix4.join(unit.source, "rpc/api")).filter((path) => existsSync3(resolve5(root, path))).map((path) => `${path}/**`),
2114
+ public: publicProcedures.length > 0 ? publicProcedures : backendUnits.map((unit) => posix7.join(unit.source, "rpc/api")).filter((path) => existsSync3(resolve7(root, path))).map((path) => `${path}/**`),
1861
2115
  composers: listOf(orpcScopes.map((settings) => settings.composers)),
1862
2116
  client: client ?? ""
1863
2117
  },
1864
- layouts: units.flatMap((unit) => Object.entries(unit.layout).map(([folder, mode]) => ({ path: posix4.join(unit.source, folder), folder, mode }))),
2118
+ layouts: units.flatMap((unit) => Object.entries(unit.layout).map(([folder, mode]) => ({ path: folder === "services" ? unit.services : posix7.join(unit.source, folder), folder, mode }))),
1865
2119
  rpcClients,
1866
2120
  operations: units.flatMap((unit) => unit.scope.effective.layout?.operations === "folders" ? [unit.source] : []),
1867
2121
  packages: libraryUnits.map((unit) => unit.key),
@@ -1884,7 +2138,7 @@ function presetProject(config) {
1884
2138
  ...presetScopes(packageSources, `hooks/**/${presetExtensions}`)
1885
2139
  ],
1886
2140
  backendFiles: presetScopes(backend),
1887
- services: presetScopes(app, `services/**/${presetExtensions}`),
2141
+ services: presetServiceScopes(serviceFolders, app),
1888
2142
  utilities: ["**/*.utils.ts", "**/*.constants.ts", "**/*.types.ts"],
1889
2143
  types: presetScopes(app, "types/**"),
1890
2144
  uiRoots,
@@ -1898,7 +2152,9 @@ function presetProject(config) {
1898
2152
  publicEntrypoints,
1899
2153
  baseImports: presetBaseImports,
1900
2154
  css: { ...webCss, ...kitCss },
1901
- ignores: skipped.map((key) => `${key}/**`)
2155
+ registries: backendUnits.flatMap((unit) => presetRegistryFile(root, unit.source, unit.scope.effective.backend?.registry, unit.services) ?? []),
2156
+ ignores: skipped.map((key) => `${key}/**`),
2157
+ outputs: [".", ...units.map((unit) => unit.key)].flatMap((key) => presetOutputFolders.map((folder) => key === "." ? `${folder}/**` : `${key}/${folder}/**`))
1902
2158
  };
1903
2159
  }
1904
2160
 
@@ -2048,10 +2304,7 @@ function preset(config) {
2048
2304
  plugins: ["typescript", "react", "react-perf", "jsx-a11y", "import", "unicorn", "promise", "vitest", "node", "oxc"],
2049
2305
  ignorePatterns: [
2050
2306
  "**/node_modules/**",
2051
- "**/dist/**",
2052
- "**/build/**",
2053
- "**/coverage/**",
2054
- "**/.output/**",
2307
+ ...project.outputs,
2055
2308
  ...project.nextWeb.length > 0 ? ["**/.next/**", "**/next-env.d.ts"] : [],
2056
2309
  ...project.expoWeb.length > 0 ? ["**/.expo/**", "**/expo-env.d.ts"] : [],
2057
2310
  ...project.generated,