oxlint-plugin-arch 0.12.1 → 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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.12.2
4
+
5
+ ### Added
6
+
7
+ - Preset: folder key `services` names an app's or package's service folder inside `source` (default `services`). An app or backend without `services/` uses `lib/` as its service folder, so a flat `lib/` gets the same layout, naming, import-boundary and registry checks; next to `services/`, `lib/` stays portable helpers. `arch/boundaries-imports` gets the matching `services` option.
8
+
9
+ ### Fixed
10
+
11
+ - `arch/schemas-local` no longer reports `z.config(...)` and zod's error formatters (`prettifyError`, `treeifyError`, `flattenError`, `formatError`), or an import used only for them; they build no schema. The new `calls` option sets that list.
12
+
3
13
  ## 0.12.1
4
14
 
5
15
  ### Changed
package/dist/index.js CHANGED
@@ -1111,15 +1111,19 @@ function boundariesContains(directory, candidate) {
1111
1111
  function boundariesStripExtension(path) {
1112
1112
  return path.replace(/\.[cm]?[jt]sx?$/, "");
1113
1113
  }
1114
- function boundariesDomain(path, appRoot) {
1114
+ function boundariesServiceFolder(options, root) {
1115
+ const nested = [...options.web, ...options.backend].filter((other) => other.length > root.length && boundariesContains(root, other));
1116
+ return options.services?.find((folder) => folder.startsWith(`${root}/`) && !nested.some((other) => boundariesContains(other, folder))) ?? `${root}/services`;
1117
+ }
1118
+ function boundariesDomain(path, appRoot, services) {
1115
1119
  const relative = path.slice(appRoot.length + 1);
1116
1120
  if (relative.startsWith("types/") && relative.endsWith(".types.ts")) {
1117
1121
  return relative.slice("types/".length).split(".")[0];
1118
1122
  }
1119
- if (!relative.startsWith("services/")) {
1123
+ if (!boundariesContains(services, path) || path === services) {
1120
1124
  return;
1121
1125
  }
1122
- const parts = relative.slice("services/".length).split("/");
1126
+ const parts = path.slice(services.length + 1).split("/");
1123
1127
  return parts.length > 1 ? parts[0] : parts[0].split(".")[0].replace(/-(?:action|query|driver)$/, "");
1124
1128
  }
1125
1129
  function boundariesPrivateDomain({ options, file, target, reExport, roots, owner }) {
@@ -1128,8 +1132,8 @@ function boundariesPrivateDomain({ options, file, target, reExport, roots, owner
1128
1132
  return;
1129
1133
  }
1130
1134
  const targetOwner = roots.find((path) => boundariesContains(path, target));
1131
- const fileDomain = owner ? boundariesDomain(file, owner) : undefined;
1132
- const targetDomain = targetOwner ? boundariesDomain(target, targetOwner) : undefined;
1135
+ const fileDomain = owner ? boundariesDomain(file, owner, boundariesServiceFolder(options, owner)) : undefined;
1136
+ const targetDomain = targetOwner ? boundariesDomain(target, targetOwner, boundariesServiceFolder(options, targetOwner)) : undefined;
1133
1137
  const sameDomain = owner && owner === targetOwner && fileDomain && fileDomain === targetDomain;
1134
1138
  const targetStem = boundariesStripExtension(target);
1135
1139
  const publicSurface = boundariesPublicSurface.test(targetStem) && !boundariesActionOrQuery.test(targetStem);
@@ -1144,11 +1148,11 @@ function boundariesPrivateDomain({ options, file, target, reExport, roots, owner
1144
1148
  }
1145
1149
  function boundariesPortableLib(check) {
1146
1150
  const { options, file, source, target, typeOnly, reExport, roots, web, backendImport } = check;
1147
- if (!options.portable || !web || !boundariesContains(`${web}/lib`, file)) {
1151
+ if (!options.portable || !web || !boundariesContains(`${web}/lib`, file) || boundariesServiceFolder(options, web) === `${web}/lib`) {
1148
1152
  return;
1149
1153
  }
1150
1154
  const portableTarget = target.startsWith("/") ? resolve2(target).replaceAll("\\", "/") : target;
1151
- const appService = roots.some((path) => boundariesContains(`${path}/services`, portableTarget));
1155
+ const appService = roots.some((path) => boundariesContains(boundariesServiceFolder(options, path), portableTarget));
1152
1156
  const backendTarget = options.backend.some((path) => boundariesContains(path, portableTarget));
1153
1157
  const appPackage = boundariesMatchesImport(options.appPackages, source);
1154
1158
  if (backendImport || backendTarget || appService || appPackage) {
@@ -1175,6 +1179,7 @@ var boundariesImports = defineRule7({
1175
1179
  backend: { type: "array", items: { type: "string" } },
1176
1180
  packages: { anyOf: [{ type: "string" }, { type: "array", items: { type: "string" } }] },
1177
1181
  roles: { type: "array", items: { type: "string" } },
1182
+ services: { type: "array", items: { type: "string" } },
1178
1183
  portable: { type: "boolean" },
1179
1184
  backendPackages: { type: "array", items: { type: "string" } },
1180
1185
  entrypoints: { type: "array", items: { type: "string" } },
@@ -1200,11 +1205,14 @@ var boundariesImports = defineRule7({
1200
1205
  const imported = { options, file, source, target, typeOnly, reExport, roots, owner, web, backendImport };
1201
1206
  const inPackage = [options.packages].flat().some((path) => path && boundariesContains(path, file));
1202
1207
  const appTarget = backendImport || options.web.some((path) => boundariesContains(path, target)) || boundariesMatchesImport(options.appPackages, source);
1203
- const domainContract = /\.(?:types|constants)\.ts$/.test(file) && !typeOnly && /\/(?:core\/|services\/.*(?:\.(?:service|utils)|-(?:action|query)\.|\/(?:actions|queries)\/))/.test(target);
1208
+ const serviceFolders = roots.map((path) => boundariesServiceFolder(options, path));
1209
+ const serviceTarget = serviceFolders.find((folder) => boundariesContains(folder, target));
1210
+ const serviceFile = serviceFolders.find((folder) => boundariesContains(folder, file));
1211
+ const domainContract = /\.(?:types|constants)\.ts$/.test(file) && !typeOnly && (/\/(?:core\/|services\/.*(?:\.(?:service|utils)|-(?:action|query)\.|\/(?:actions|queries)\/))/.test(target) || serviceTarget !== undefined && /\/.*(?:\.(?:service|utils)|-(?:action|query)\.|\/(?:actions|queries)\/)/.test(target.slice(serviceTarget.length)));
1204
1212
  const reason = [
1205
1213
  web && backendImport && !options.entrypoints.includes(source) ? "frontend code uses only public backend entry points" : undefined,
1206
1214
  inPackage && appTarget ? "shared packages do not import app internals" : undefined,
1207
- file.includes("/services/") && /\/services\/service(?:\.ts)?$/.test(target) && !typeOnly ? "service dependencies are injected rather than read from the registry" : undefined,
1215
+ (file.includes("/services/") && /\/services\/service(?:\.ts)?$/.test(target) || serviceFile !== undefined && /^\/service(?:\.ts)?$/.test(target.slice(serviceFile.length))) && !typeOnly ? "service dependencies are injected rather than read from the registry" : undefined,
1208
1216
  domainContract ? "domain types/constants use type-only imports from implementations" : undefined,
1209
1217
  file.endsWith(".client.ts") && !typeOnly && boundariesMatchesImport(options.uiPackages ?? ["react", "react-dom"], source) ? "client services are React-free; use hooks for React behavior" : undefined,
1210
1218
  boundariesPrivateDomain(imported),
@@ -1245,9 +1253,9 @@ var boundariesImports = defineRule7({
1245
1253
  }
1246
1254
  },
1247
1255
  TSImportType(node) {
1248
- const { portable: portableLib, web } = { ...boundariesImportsDefaults, ...optionsFirst(context) };
1256
+ const options = { ...boundariesImportsDefaults, ...optionsFirst(context) };
1249
1257
  const file = context.filename.replaceAll("\\", "/");
1250
- if (portableLib && web.some((path) => file.startsWith(`${path}/lib/`))) {
1258
+ if (options.portable && options.web.some((path) => file.startsWith(`${path}/lib/`) && boundariesServiceFolder(options, path) !== `${path}/lib`)) {
1251
1259
  check(node, node.source.value, true);
1252
1260
  }
1253
1261
  }
@@ -5394,6 +5402,7 @@ var schemasInline = defineRule46({
5394
5402
  import { defineRule as defineRule47 } from "@oxlint/plugins";
5395
5403
  var schemasLocalImportMessage = "Do not import runtime schema constructors from {{source}}; import a named schema instead.";
5396
5404
  var schemasLocalConstructionMessage = "Do not construct schemas locally; import a named schema instead.";
5405
+ var schemasLocalCalls = ["config", "prettifyError", "treeifyError", "flattenError", "formatError"];
5397
5406
  var schemasLocal = defineRule47({
5398
5407
  meta: {
5399
5408
  type: "problem",
@@ -5404,6 +5413,7 @@ var schemasLocal = defineRule47({
5404
5413
  properties: {
5405
5414
  packages: { type: "array", items: { type: "string" } },
5406
5415
  namespaces: { type: "array", items: { type: "string" } },
5416
+ calls: { type: "array", items: { type: "string" } },
5407
5417
  allow: { type: "array", items: { type: "string" } },
5408
5418
  patterns: { type: "array", items: { type: "string" } },
5409
5419
  message: { type: "string" },
@@ -5421,8 +5431,15 @@ var schemasLocal = defineRule47({
5421
5431
  createOnce(context) {
5422
5432
  let constructors = new Set;
5423
5433
  let packages = new Set;
5434
+ let calls = new Set;
5435
+ let pending = [];
5436
+ let schemaUses = new Set;
5437
+ let callUses = new Set;
5424
5438
  let importMessage = schemasLocalImportMessage;
5425
5439
  let constructionMessage = schemasLocalConstructionMessage;
5440
+ function isAllowedCall(node, member) {
5441
+ return member?.type === "MemberExpression" && member.object === node && !member.computed && member.property.type === "Identifier" && calls.has(member.property.name) && member.parent.type === "CallExpression" && member.parent.callee === member;
5442
+ }
5426
5443
  function isPackage(source) {
5427
5444
  return packages.has(source) || [...packages].some((name) => source.startsWith(`${name}/`));
5428
5445
  }
@@ -5447,6 +5464,10 @@ var schemasLocal = defineRule47({
5447
5464
  const filename = context.filename.replaceAll("\\", "/");
5448
5465
  constructors = new Set(options.namespaces);
5449
5466
  packages = new Set(options.packages);
5467
+ calls = new Set(options.calls ?? schemasLocalCalls);
5468
+ pending = [];
5469
+ schemaUses = new Set;
5470
+ callUses = new Set;
5450
5471
  importMessage = options.importMessage ?? options.message ?? schemasLocalImportMessage;
5451
5472
  constructionMessage = options.constructionMessage ?? options.message ?? schemasLocalConstructionMessage;
5452
5473
  if ((options.allow ?? []).some((suffix) => filename.endsWith(suffix)) || (options.patterns ?? []).some((pattern) => new RegExp(pattern).test(filename))) {
@@ -5464,7 +5485,27 @@ var schemasLocal = defineRule47({
5464
5485
  for (const name of locals) {
5465
5486
  constructors.add(name);
5466
5487
  }
5467
- reportRuntimeImport(node, node.source.value);
5488
+ if (locals.length === 0) {
5489
+ reportRuntimeImport(node, node.source.value);
5490
+ return;
5491
+ }
5492
+ pending.push({ node, locals });
5493
+ },
5494
+ Identifier(node) {
5495
+ if (!constructors.has(node.name) || node.parent.type.startsWith("Import")) {
5496
+ return;
5497
+ }
5498
+ if (isAllowedCall(node, node.parent)) {
5499
+ callUses.add(node.name);
5500
+ return;
5501
+ }
5502
+ schemaUses.add(node.name);
5503
+ },
5504
+ "Program:exit"() {
5505
+ const runtime = pending.filter(({ locals }) => locals.some((name) => schemaUses.has(name) || !callUses.has(name)));
5506
+ for (const { node } of runtime) {
5507
+ reportRuntimeImport(node, node.source.value);
5508
+ }
5468
5509
  },
5469
5510
  ExportNamedDeclaration: checkReexport,
5470
5511
  ExportAllDeclaration: checkReexport,
@@ -5473,7 +5514,7 @@ var schemasLocal = defineRule47({
5473
5514
  while (root.type === "MemberExpression") {
5474
5515
  root = root.object;
5475
5516
  }
5476
- if (root.type !== "Identifier" || !constructors.has(root.name)) {
5517
+ if (root.type !== "Identifier" || !constructors.has(root.name) || node.callee.type === "MemberExpression" && node.callee.object === root && isAllowedCall(root, root.parent)) {
5477
5518
  return;
5478
5519
  }
5479
5520
  context.report({
@@ -1,3 +1,3 @@
1
1
  import type * as PresetTypes from '../types/preset.types.ts';
2
- export declare function presetRegistryFile(root: string, source: string, configured: PresetTypes.PresetBackendOptions['registry']): string | undefined;
2
+ export declare function presetRegistryFile(root: string, source: string, configured: PresetTypes.PresetBackendOptions['registry'], services: string): string | undefined;
3
3
  export declare function presetDetect(root: string, folders: PresetTypes.PresetFolders): PresetTypes.PresetFolders;
@@ -2,11 +2,11 @@
2
2
  import { createRequire } from "node:module";
3
3
 
4
4
  // src/preset/groups/backend.ts
5
- import { posix as posix2, resolve as resolve3 } from "node:path";
5
+ import { posix as posix3, resolve as resolve3 } from "node:path";
6
6
 
7
7
  // src/preset/preset-detect.ts
8
8
  import { existsSync, readdirSync, readFileSync as readFileSync2 } from "node:fs";
9
- import { matchesGlob, posix, resolve as resolve2 } from "node:path";
9
+ import { matchesGlob, posix as posix2, resolve as resolve2 } from "node:path";
10
10
 
11
11
  // src/preset/preset-recipes.ts
12
12
  var presetUi = { react: true, kit: true, tailwind: true };
@@ -464,7 +464,7 @@ var presetGroupOptions = {
464
464
  deny: { level: "error", imports: {}, calls: {} },
465
465
  externals: { level: "error", packages: presetExternals }
466
466
  };
467
- var presetFolderKeys = ["severity", "source", "rules"];
467
+ var presetFolderKeys = ["severity", "source", "services", "rules"];
468
468
  var presetA11yRules = [
469
469
  "alt-text",
470
470
  "anchor-has-content",
@@ -514,7 +514,7 @@ var presetDetectDesktop = ["electrobun", "electron", "@tauri-apps/api", "@tauri-
514
514
  var presetDetectBackend = ["@orpc/server", "hono", "kysely", "drizzle-orm", "express", "fastify", "elysia"];
515
515
  var presetDetectSkip = ["node_modules", "dist", "build", "coverage", "public", ".output", ".next", ".vinext", ".turbo", ".wrangler", "target"];
516
516
  var presetStylesheetNames = ["styles", "globals", "global", "app", "index", "main", "style", "tailwind"];
517
- var presetRegistryFiles = ["services/service.ts", "core/core.services.ts", "service.ts"];
517
+ var presetRegistryFiles = ["core/core.services.ts", "service.ts"];
518
518
  var presetRegistrySkip = ["brand", "database", "db", "math", "security", "tools"];
519
519
  var presetServerEntries = ["rpc", "server", "security", "services"];
520
520
  var presetOperationImports = ["*/*-action.*", "*/*-query.*", "*/actions/*", "*/queries/*"];
@@ -528,12 +528,15 @@ var presetPackageImports = { "../../apps/**": "Packages stay app-agnostic; do no
528
528
 
529
529
  // src/preset/preset.utils.ts
530
530
  import { readFileSync, statSync } from "node:fs";
531
- import { resolve } from "node:path";
531
+ import { posix, resolve } from "node:path";
532
532
  class PresetError extends Error {
533
533
  }
534
534
  function presetScopes(roots, suffix = `**/${presetExtensions}`) {
535
535
  return roots.map((root) => root === "." ? suffix : `${root}/${suffix}`);
536
536
  }
537
+ function presetServiceScopes(folders, sources, suffix = `**/${presetExtensions}`) {
538
+ return presetScopes(sources.map((source) => folders[source] ?? posix.join(source, "services")), suffix);
539
+ }
537
540
  function presetOverride(files, rules, excludeFiles = []) {
538
541
  return {
539
542
  files,
@@ -611,7 +614,7 @@ function presetRuntime(project, settings, framework, rule) {
611
614
  options.rpc = project.rpcClients.map((path) => resolve(project.root, path));
612
615
  }
613
616
  return [
614
- 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}")], {
615
618
  [rule]: ["error", options]
616
619
  })
617
620
  ];
@@ -631,6 +634,16 @@ function presetIsDirectory(path) {
631
634
  return false;
632
635
  }
633
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
+ }
634
647
  function presetIsFile(path) {
635
648
  try {
636
649
  return statSync(path, { throwIfNoEntry: false })?.isFile() ?? false;
@@ -644,35 +657,36 @@ function presetDirectories(root, base, skip = presetDetectSkip) {
644
657
  if (!presetIsDirectory(resolve2(root, base))) {
645
658
  return [];
646
659
  }
647
- return readdirSync(resolve2(root, base)).filter((name) => !name.startsWith(".") && !skip.includes(name) && presetIsDirectory(resolve2(root, base, name))).map((name) => posix.join(base, name));
660
+ return readdirSync(resolve2(root, base)).filter((name) => !name.startsWith(".") && !skip.includes(name) && presetIsDirectory(resolve2(root, base, name))).map((name) => posix2.join(base, name));
648
661
  }
649
662
  var presetNamedSkip = ["node_modules"];
650
663
  function presetSource(root, directory, configured) {
651
664
  if (configured !== undefined) {
652
- return posix.join(directory, configured);
665
+ return posix2.join(directory, configured);
653
666
  }
654
- return existsSync(resolve2(root, directory, "src")) ? posix.join(directory, "src") : directory;
667
+ return existsSync(resolve2(root, directory, "src")) ? posix2.join(directory, "src") : directory;
655
668
  }
656
- function presetRegistryFile(root, source, configured) {
669
+ function presetRegistryFile(root, source, configured, services) {
657
670
  if (configured === false) {
658
671
  return;
659
672
  }
660
673
  const file = typeof configured === "string" ? configured : configured?.file;
661
674
  if (file !== undefined) {
662
- return posix.normalize(file);
675
+ return posix2.normalize(file);
663
676
  }
664
- return presetRegistryFiles.map((path) => posix.join(source, path)).find((path) => existsSync(resolve2(root, path)));
677
+ return [posix2.join(services, "service.ts"), ...presetRegistryFiles.map((path) => posix2.join(source, path))].find((path) => existsSync(resolve2(root, path)));
665
678
  }
666
- function presetDomains(root, source) {
667
- return presetDirectories(root, posix.join(source, "services"), presetNamedSkip).map((path) => posix.basename(path)).filter((name) => existsSync(resolve2(root, source, "services", name, `${name}.service.ts`)));
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`)));
668
681
  }
669
- function presetRecipe(root, directory, app, configured) {
682
+ function presetRecipe(root, directory, app, folder) {
670
683
  const manifest = presetManifest(root, directory);
671
684
  const dependencies = new Set([manifest.dependencies, manifest.devDependencies, manifest.peerDependencies].flatMap((listed) => Object.keys(listed ?? {})));
672
685
  const has = (names) => names.some((name) => dependencies.has(name));
673
- const source = presetSource(root, directory, configured);
686
+ const source = presetSource(root, directory, folder.source);
674
687
  const framework = presetDetectFrameworks.find(([, names]) => has(names))?.[0];
675
- const services = resolve2(root, source, "services");
688
+ const servicesFolder = presetServicesFolder(root, source, folder.services, app);
689
+ const services = resolve2(root, servicesFolder);
676
690
  const entries = presetIsDirectory(services) ? readdirSync(services, { withFileTypes: true }) : [];
677
691
  const folders = entries.filter((entry) => presetIsDirectory(resolve2(services, entry.name))).length;
678
692
  const flat = entries.filter((entry) => entry.isFile() && /\.tsx?$/.test(entry.name)).length > folders;
@@ -682,7 +696,7 @@ function presetRecipe(root, directory, app, configured) {
682
696
  if (has(presetDetectDesktop)) {
683
697
  return presetRecipes.desktop;
684
698
  }
685
- if (presetDomains(root, source).length > 0 || has(presetDetectBackend) && (app || presetIsDirectory(services))) {
699
+ if (presetDomains(root, servicesFolder).length > 0 || has(presetDetectBackend) && (app || presetIsDirectory(services))) {
686
700
  return presetRecipes.backend;
687
701
  }
688
702
  if (has(["react"]) && presetIsDirectory(resolve2(root, source, "components"))) {
@@ -720,7 +734,7 @@ function presetImportTargets(value) {
720
734
  }
721
735
  function presetSpecifiers(manifest, target, file = false) {
722
736
  const specifiers = Object.entries(manifest.imports ?? {}).flatMap(([key, value]) => presetImportTargets(value).flatMap((mapped) => {
723
- const path = posix.normalize(mapped);
737
+ const path = posix2.normalize(mapped);
724
738
  const parts = path.split("*");
725
739
  const keyParts = key.split("*");
726
740
  if (parts.length === 1 && keyParts.length === 1) {
@@ -758,13 +772,15 @@ function presetFilled(conventions, exists) {
758
772
  function presetBackendConventions(root, key, value, denied) {
759
773
  const manifest = presetManifest(root, key);
760
774
  const source = presetSource(root, key, value.source);
761
- const local = posix.relative(key, source) || ".";
762
- const at = (path) => local === "." ? path : posix.join(local, path);
775
+ const local = posix2.relative(key, source) || ".";
776
+ const at = (path) => local === "." ? path : posix2.join(local, path);
763
777
  const exists = (path) => existsSync(resolve2(root, key, path));
764
778
  const configured = value.backend instanceof Object ? value.backend.registry : undefined;
765
- const registryFile = presetRegistryFile(root, source, configured);
766
- const registry = registryFile === undefined ? undefined : posix.relative(key, registryFile);
767
- const services = presetSpecifiers(manifest, at("services"));
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);
768
784
  const message = "Use the service API, not another service’s operations.";
769
785
  const operations = Object.fromEntries(services.flatMap((prefix) => presetOperationImports.map((pattern) => [`${prefix}/${pattern}`, message])));
770
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."])) : {};
@@ -774,7 +790,7 @@ function presetBackendConventions(root, key, value, denied) {
774
790
  Object.assign(serviceImports, entries);
775
791
  }
776
792
  const conventions = {
777
- [at("services")]: presetDeny(serviceImports),
793
+ [servicesKey]: presetDeny(serviceImports),
778
794
  [at("core")]: presetDeny(operations),
779
795
  [at("rpc")]: presetDeny(registryImports),
780
796
  [at("routes")]: presetDeny(registryImports),
@@ -783,24 +799,24 @@ function presetBackendConventions(root, key, value, denied) {
783
799
  ...presetDeny({ ...operations, ...Object.fromEntries(services.map((prefix) => [`${prefix}/*/*.service`, "Reach services through the registry."])) })
784
800
  }
785
801
  };
786
- for (const domain of presetDirectories(root, posix.join(source, "services"), presetNamedSkip).map((path) => posix.basename(path))) {
802
+ for (const domain of presetDirectories(root, servicesFolder, presetNamedSkip).map((path) => posix2.basename(path))) {
787
803
  const imports = services.flatMap((prefix) => presetOperationImports.filter((pattern) => !denied.has(`${prefix}/${pattern}`)).flatMap((pattern) => [
788
804
  [`${prefix}/${pattern}`, false],
789
805
  [`${prefix}/${pattern.replace(/^\*/, `!(${domain})`)}`, message]
790
806
  ]));
791
- conventions[at(`services/${domain}`)] = presetDeny(Object.fromEntries(imports));
807
+ conventions[posix2.join(servicesKey, domain)] = presetDeny(Object.fromEntries(imports));
792
808
  }
793
809
  for (const tests of ["tests", "test"]) {
794
- if (exists(posix.join(tests, "kits"))) {
810
+ if (exists(posix2.join(tests, "kits"))) {
795
811
  conventions[tests] = presetDeny(Object.fromEntries(presetKitImports.map((pattern) => [pattern, "Import the kit file you need."])));
796
- conventions[posix.join(tests, "kits")] = { naming: { files: presetKitFiles } };
812
+ conventions[posix2.join(tests, "kits")] = { naming: { files: presetKitFiles } };
797
813
  }
798
814
  }
799
815
  const queue = ["."];
800
816
  while (queue.length > 0) {
801
817
  const folder = queue.shift() ?? ".";
802
- for (const child of presetDirectories(root, posix.join(key, folder)).map((path) => posix.relative(key, path))) {
803
- if (posix.basename(child) === "migrations") {
818
+ for (const child of presetDirectories(root, posix2.join(key, folder)).map((path) => posix2.relative(key, path))) {
819
+ if (posix2.basename(child) === "migrations") {
804
820
  conventions[child] = { layout: false, naming: { files: presetMigrationFiles, unique: presetMigrationNumber } };
805
821
  } else if (child.split("/").length < 8) {
806
822
  queue.push(child);
@@ -810,8 +826,8 @@ function presetBackendConventions(root, key, value, denied) {
810
826
  return presetFilled(conventions, exists);
811
827
  }
812
828
  function presetWebConventions(root, key, value, servers) {
813
- const source = posix.relative(key, presetSource(root, key, value.source)) || ".";
814
- const at = (path) => source === "." ? path : posix.join(source, path);
829
+ const source = posix2.relative(key, presetSource(root, key, value.source)) || ".";
830
+ const at = (path) => source === "." ? path : posix2.join(source, path);
815
831
  const exists = (path) => existsSync(resolve2(root, key, path));
816
832
  const own = Object.keys(presetManifest(root, key).imports ?? {});
817
833
  const internals = servers.flatMap((manifest) => Object.keys(manifest.imports ?? {}).filter((specifier) => !own.some((mine) => presetOverlaps(mine, specifier))).map((specifier) => specifier.replace(/\*$/, "**")));
@@ -856,7 +872,7 @@ function presetWorkspacePatterns(root, manifest) {
856
872
  return patterns;
857
873
  }
858
874
  function presetWorkspaces(root, patterns) {
859
- const clean = (pattern) => posix.normalize(pattern.replace(/^\.\//, "")).replace(/\/$/, "");
875
+ const clean = (pattern) => posix2.normalize(pattern.replace(/^\.\//, "")).replace(/\/$/, "");
860
876
  const include = patterns.filter((pattern) => !pattern.startsWith("!")).map(clean);
861
877
  const exclude = patterns.filter((pattern) => pattern.startsWith("!")).map((pattern) => clean(pattern.slice(1)));
862
878
  const matches = (path, globs) => globs.some((glob) => path === glob || matchesGlob(path, glob));
@@ -895,16 +911,16 @@ function presetGroupOff(folders, path, group) {
895
911
  }
896
912
  function presetDetect(root, folders) {
897
913
  const manifest = presetManifest(root, ".");
898
- const configuredSource = (key) => {
914
+ const configured = (key) => {
899
915
  const value = folders[key];
900
- return value instanceof Object ? value.source : undefined;
916
+ return value instanceof Object ? value : {};
901
917
  };
902
918
  const patterns = presetWorkspacePatterns(root, manifest);
903
919
  const directories = presetWorkspaces(root, patterns);
904
- const detected = Object.fromEntries(directories.map((directory) => [directory, presetRecipe(root, directory, !directory.startsWith("packages/"), configuredSource(directory))]));
905
- const rootRecipe = presetRecipe(root, ".", true, configuredSource("."));
920
+ const detected = Object.fromEntries(directories.map((directory) => [directory, presetRecipe(root, directory, !directory.startsWith("packages/"), configured(directory))]));
921
+ const rootRecipe = presetRecipe(root, ".", true, configured("."));
906
922
  const single = Object.keys(manifest).length > 0 || rootRecipe !== presetRecipes.library;
907
- const rootApp = rootRecipe !== presetRecipes.library && presetSource(root, ".", configuredSource(".")) !== ".";
923
+ const rootApp = rootRecipe !== presetRecipes.library && presetSource(root, ".", configured(".").source) !== ".";
908
924
  if (patterns.length === 0 ? single : rootApp) {
909
925
  detected["."] = rootRecipe;
910
926
  }
@@ -912,7 +928,7 @@ function presetDetect(root, folders) {
912
928
  detected.scripts = presetRecipes.cli;
913
929
  }
914
930
  const keys = Object.keys(folders);
915
- const covered = (key) => keys.some((user) => user !== key && user !== "." && (key.startsWith(`${user}/`) || user.endsWith("/*") && posix.dirname(key) === user.slice(0, -2)));
931
+ const covered = (key) => keys.some((user) => user !== key && user !== "." && (key.startsWith(`${user}/`) || user.endsWith("/*") && posix2.dirname(key) === user.slice(0, -2)));
916
932
  const merged = { ".": presetDeny(presetRootImports) };
917
933
  for (const [key, value] of Object.entries(detected)) {
918
934
  if (!covered(key)) {
@@ -953,11 +969,11 @@ function presetDetect(root, folders) {
953
969
  }
954
970
  if (value.backend) {
955
971
  for (const [path, folder] of Object.entries(presetBackendConventions(root, key, value, denied))) {
956
- add(path === "." ? key : posix.join(key, path), folder);
972
+ add(path === "." ? key : posix2.join(key, path), folder);
957
973
  }
958
974
  } else if (value.tanstack || value.next) {
959
975
  for (const [path, folder] of Object.entries(presetWebConventions(root, key, value, servers))) {
960
- add(path === "." ? key : posix.join(key, path), folder);
976
+ add(path === "." ? key : posix2.join(key, path), folder);
961
977
  }
962
978
  } else if ((value.kit || key.startsWith("packages/")) && !value.expo) {
963
979
  const own = Object.keys(presetManifest(root, key).imports ?? {});
@@ -985,9 +1001,9 @@ var backendGroup = (project, settings) => {
985
1001
  }
986
1002
  const configured = typeof settings.registry === "string" ? { file: settings.registry } : settings.registry ?? {};
987
1003
  const backendUnits = project.units.filter((unit) => unit.kind === "backend");
988
- const file = configured.file === undefined ? undefined : posix2.normalize(configured.file);
1004
+ const file = configured.file === undefined ? undefined : posix3.normalize(configured.file);
989
1005
  const registries = file === undefined ? backendUnits.flatMap((unit) => {
990
- const found = presetRegistryFile(project.root, unit.source, undefined);
1006
+ const found = presetRegistryFile(project.root, unit.source, undefined, unit.services);
991
1007
  return found ? [{ file: found, unit }] : [];
992
1008
  }) : [{ file, unit: backendUnits.find((unit) => unit.key === "." || file.startsWith(`${unit.key}/`)) }];
993
1009
  for (const registry of registries) {
@@ -996,7 +1012,7 @@ var backendGroup = (project, settings) => {
996
1012
  }
997
1013
  const options = {
998
1014
  registry: resolve3(project.root, registry.file),
999
- services: resolve3(project.root, registry.unit.source, "services"),
1015
+ services: resolve3(project.root, registry.unit.services),
1000
1016
  skip: configured.skip ?? (file === undefined ? presetRegistrySkip : [])
1001
1017
  };
1002
1018
  if (configured.object !== undefined) {
@@ -1014,7 +1030,7 @@ var basicsGroup = (project, settings) => {
1014
1030
  return [
1015
1031
  ...settings.trivial ? [
1016
1032
  presetOverride(appFiles, { "arch/code-trivial-functions": ["error", trivial] }, ui),
1017
- 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)
1018
1034
  ] : [],
1019
1035
  ...settings.state ? [presetOverride(safetyFiles, { "arch/code-mutable-state": ["error", { kinds: ["let", "var"] }] }, ui)] : [],
1020
1036
  ...settings.switch ? [presetOverride(project.backendFiles, { "arch/code-switch": ["error", { message: "Use ts-pattern in backend application code." }] })] : [],
@@ -1045,8 +1061,8 @@ var denyGroup = (project, settings) => {
1045
1061
  continue;
1046
1062
  }
1047
1063
  const files = [
1048
- ...presetScopes([unit.source], "services/**/*-query.*.ts"),
1049
- ...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")
1050
1066
  ];
1051
1067
  const reads = Object.fromEntries(writes.map((name) => [name, "Query files only read; move writes to an action."]));
1052
1068
  overrides.push(presetOverride(files, { "arch/boundaries-deny": ["error", { imports, calls: { ...reads, ...calls } }] }));
@@ -1151,6 +1167,7 @@ var importsGroup = (project, settings) => {
1151
1167
  backend: absolute(project.backend),
1152
1168
  packages: absolute(project.packages),
1153
1169
  roles: project.suffixes,
1170
+ services: absolute([...project.web, ...project.backend].map((source) => project.serviceFolders[source])),
1154
1171
  portable: true,
1155
1172
  aliases: presetAliases(project.root, settings.aliases),
1156
1173
  appPackages: importsPackageNames(project, ["web", "backend"]),
@@ -1163,18 +1180,18 @@ var importsGroup = (project, settings) => {
1163
1180
  };
1164
1181
 
1165
1182
  // src/preset/groups/layout.ts
1166
- import { posix as posix3 } from "node:path";
1183
+ import { posix as posix4 } from "node:path";
1167
1184
  var layoutGroup = (project, settings) => {
1168
1185
  const { web, nextWeb, serviceRoots, app, services, utilities, ui, testFiles, layouts, operations } = project;
1169
- const folderOperations = presetScopes(operations, "services/*/{actions,queries}/*.ts");
1186
+ const folderOperations = presetServiceScopes(project.serviceFolders, operations, "*/{actions,queries}/*.ts");
1170
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.";
1171
1188
  const roles = [...presetBuiltinFileRoles.filter((role) => role !== "action" && role !== "query"), ...project.suffixes];
1172
1189
  const conceptFiles = project.suffixes.map((suffix) => `**/*.${suffix}.ts`);
1173
- const frontendEntries = [...presetScopes(web, "services/**/*.{client,server,rsc}.ts"), ...presetScopes(web, "services/**/*.rsc.tsx")];
1174
- const ungroupedWeb = web.filter((path) => !layouts.some((scope) => scope.path === posix3.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")));
1175
1192
  return [
1176
1193
  ...layouts.flatMap(({ path, folder, mode }) => {
1177
- const folders = folder === "services" && operations.some((source) => posix3.join(source, "services") === path);
1194
+ const folders = folder === "services" && operations.some((source) => posix4.join(source, "services") === path);
1178
1195
  const registries = folder === "services" ? project.registries : [];
1179
1196
  const restricted = presetOverride(mode === "flat" ? [`${path}/*/**/${presetExtensions}`] : [`${path}/${presetExtensions}`, `${path}/*/*/**/${presetExtensions}`], {
1180
1197
  "arch/files-restricted": [
@@ -1207,7 +1224,7 @@ var layoutGroup = (project, settings) => {
1207
1224
  presetOverride(presetScopes(ungroupedWeb, `components/*/*/**/${presetExtensions}`), { "arch/files-restricted": ["error", { message: "Components allow one group folder." }] }, ui),
1208
1225
  presetOverride([...presetScopes(web, "components/**/*.ts"), ...presetScopes(web, "hooks/**/*.tsx")], { "arch/files-restricted": ["error", { message: "Components use TSX; hooks use .ts files." }] }, ui),
1209
1226
  presetOverride(presetScopes(web, "**/use-*.{ts,tsx}"), { "arch/files-restricted": ["error", { message: "Hooks belong in src/hooks." }] }, [...presetScopes(web, "hooks/**"), ...ui]),
1210
- presetOverride(presetScopes(web, "services/**/*.{ts,tsx}"), {
1227
+ presetOverride(presetServiceScopes(project.serviceFolders, web, "**/*.{ts,tsx}"), {
1211
1228
  "arch/files-restricted": [
1212
1229
  "error",
1213
1230
  {
@@ -1217,7 +1234,7 @@ var layoutGroup = (project, settings) => {
1217
1234
  }, [
1218
1235
  "**/*.{client,server,rsc,utils,constants}.ts",
1219
1236
  "**/*.rsc.tsx",
1220
- ...presetScopes(nextWeb, "services/**/*.action.ts"),
1237
+ ...presetServiceScopes(project.serviceFolders, nextWeb, "**/*.action.ts"),
1221
1238
  ...conceptFiles,
1222
1239
  ...project.colocated,
1223
1240
  ...testFiles
@@ -1227,22 +1244,22 @@ var layoutGroup = (project, settings) => {
1227
1244
  "arch/types-inline": ["error", { parameters: true, returns: true, functions: true, min: 1 }]
1228
1245
  }, [...utilities, ...testFiles, ...conceptFiles]),
1229
1246
  presetOverride(frontendEntries, { "arch/backend-service-functions": ["error", { frontend: true }] }, testFiles),
1230
- ...conceptFiles.map((pattern) => presetOverride(presetScopes(app, `services/${pattern}`), {
1247
+ ...conceptFiles.map((pattern) => presetOverride(presetServiceScopes(project.serviceFolders, app, pattern), {
1231
1248
  "arch/backend-service-functions": "off",
1232
1249
  "arch/naming-exports": ["error", { stem: "full-basename", scope: "functions" }]
1233
1250
  }, testFiles)),
1234
- 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),
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),
1235
1252
  presetOverride(folderOperations, { "arch/backend-service-functions": ["error", { helpers: true, single: true, message: operationMessage }] }, testFiles),
1236
- presetOverride(presetScopes(serviceRoots, "services/**/*.ts"), { "arch/files-domain-constants": "error" }, [...utilities, ...testFiles]),
1237
- presetOverride(presetScopes(web, "services/**/*.{ts,tsx}"), { "arch/files-domain-constants": ["error", { data: true }] }, [
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 }] }, [
1238
1255
  "**/*.constants.ts",
1239
1256
  ...testFiles
1240
1257
  ]),
1241
1258
  presetOverride(frontendEntries, { "arch/files-domain-constants": ["error", { data: true, services: true }] }, testFiles),
1242
- 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),
1243
1260
  presetOverride(frontendEntries, { "arch/code-object-params": ["error", { max: 2, services: true }] }, testFiles),
1244
- presetOverride(presetScopes(serviceRoots, "services/**/*.service.ts"), { "arch/files-exports": ["error", { names: settings.service, stem: "before-first-dot", types: false }] }, testFiles),
1245
- 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"), {
1246
1263
  "arch/files-restricted": ["error", { message: "Use domain-action.name.ts or domain-query.name.ts." }]
1247
1264
  })
1248
1265
  ];
@@ -1270,7 +1287,7 @@ var namingGroup = (project, settings, owner) => {
1270
1287
  const { web, serviceRoots, app, ui, testFiles, components, hooks, operations } = project;
1271
1288
  const flatApp = app.filter((path) => !operations.includes(path));
1272
1289
  const folderApp = app.filter((path) => operations.includes(path));
1273
- 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")];
1274
1291
  const ungroupedWeb = web.filter((path) => !project.layouts.some((scope) => scope.path === `${path}/components`));
1275
1292
  const overrides = [];
1276
1293
  const { files: filesPattern, exports: exportsPattern, unique } = settings;
@@ -1309,7 +1326,7 @@ var namingGroup = (project, settings, owner) => {
1309
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"]);
1310
1327
  add("exports", presetScopes(app, "**/*.{constants,utils,handler,service,types,client,server,rsc}.ts"), { "arch/naming-exports": ["error", presetExportPrefix] }, [...ui, ...presetScopes(serviceRoots, "entry.*.ts")]);
1311
1328
  add("exports", frontendEntries, { "arch/naming-exports": ["error", namingFrontendPrefix] }, testFiles);
1312
- add("exports", presetScopes(web, "services/**/*.constants.ts"), { "arch/naming-exports": ["error", { ...presetExportPrefix, scope: "declarations", allow: "^\\*$" }] }, testFiles);
1329
+ add("exports", presetServiceScopes(project.serviceFolders, web, "**/*.constants.ts"), { "arch/naming-exports": ["error", { ...presetExportPrefix, scope: "declarations", allow: "^\\*$" }] }, testFiles);
1313
1330
  add("exports", presetScopes(app, "**/*.utils.ts"), { "arch/naming-exports": ["error", { ...presetExportPrefix, scope: "functions", allow: "^\\*$" }] }, ui);
1314
1331
  add("files", hooks, {
1315
1332
  "arch/naming-file": [
@@ -1324,7 +1341,7 @@ var namingGroup = (project, settings, owner) => {
1324
1341
  add("exports", components, { "arch/files-components-only": ["error", { filename: true, types: true, roles: ["illustration"] }] }, [...ui, ...testFiles]);
1325
1342
  add("folders", presetScopes(ungroupedWeb, "components/**/*.tsx"), { "arch/naming-folder": ["error", { singularize: "trailing-s", separators: ["-"] }] }, [...ui, ...testFiles]);
1326
1343
  for (const role of ["action", "query"]) {
1327
- 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`)];
1328
1345
  add("files", files, {
1329
1346
  "arch/naming-file": ["error", { pattern: `^[a-z0-9]+(?:-[a-z0-9]+)*-${role}\\.[a-z0-9]+(?:-[a-z0-9]+)*\\.ts$`, message: namingOperationMessage }]
1330
1347
  }, testFiles);
@@ -1337,7 +1354,7 @@ var namingGroup = (project, settings, owner) => {
1337
1354
  }, testFiles);
1338
1355
  }
1339
1356
  for (const [role, folder, separator] of [["action", "actions", "."], ["query", "queries", "-"]]) {
1340
- const files = presetScopes(folderApp, `services/*/${folder}/*.ts`);
1357
+ const files = presetServiceScopes(project.serviceFolders, folderApp, `*/${folder}/*.ts`);
1341
1358
  add("files", files, {
1342
1359
  "arch/naming-file": [
1343
1360
  "error",
@@ -1356,7 +1373,7 @@ var namingGroup = (project, settings, owner) => {
1356
1373
  }
1357
1374
  for (const role of ["action", "query"]) {
1358
1375
  for (const suffix of ["client.ts", "server.ts", "rsc.ts", "rsc.tsx"]) {
1359
- const files = presetScopes(web, `services/**/*-${role}.*.${suffix}`);
1376
+ const files = presetServiceScopes(project.serviceFolders, web, `**/*-${role}.*.${suffix}`);
1360
1377
  add("files", files, {
1361
1378
  "arch/naming-file": [
1362
1379
  "error",
@@ -1369,7 +1386,7 @@ var namingGroup = (project, settings, owner) => {
1369
1386
  add("exports", files, { "arch/naming-exports": ["error", namingOperationPrefix(role), namingOperation(role, `{domain}-${role}.{name}.${suffix}`)] }, testFiles);
1370
1387
  }
1371
1388
  }
1372
- add("exports", presetScopes(web, "services/**/*.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]);
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]);
1373
1390
  if (typeof exportsPattern === "string") {
1374
1391
  overrides.push(presetOverride([`**/${presetExtensions}`], { "arch/naming-exports": ["error", { mode: "pattern", pattern: exportsPattern, types: true }] }));
1375
1392
  }
@@ -1503,7 +1520,7 @@ var shadcnGroup = (project) => {
1503
1520
 
1504
1521
  // src/preset/groups/sql.ts
1505
1522
  var sqlGroup = (project, settings) => [
1506
- presetOverride(presetScopes(project.serviceRoots, "services/**/*.ts"), {
1523
+ presetOverride(presetServiceScopes(project.serviceFolders, project.serviceRoots, "**/*.ts"), {
1507
1524
  "arch/backend-sql-like": [
1508
1525
  "error",
1509
1526
  { methods: ["like", "ilike"], operators: ["where"], sanitizers: settings.sanitizers, bindings: true }
@@ -1564,7 +1581,7 @@ var tanstackGroup = (project, settings) => {
1564
1581
  patterns: project.baseImports
1565
1582
  }
1566
1583
  ]
1567
- }, presetScopes(tanstackWeb, "services/**/*.rsc.{ts,tsx}")),
1584
+ }, presetServiceScopes(project.serviceFolders, tanstackWeb, "**/*.rsc.{ts,tsx}")),
1568
1585
  ...presetRuntime(project, settings, "tanstack", "arch/boundaries-tanstack")
1569
1586
  ];
1570
1587
  };
@@ -1713,11 +1730,11 @@ var typesGroup = (project, settings) => {
1713
1730
 
1714
1731
  // src/preset/preset-folders.ts
1715
1732
  import { existsSync as existsSync3, readdirSync as readdirSync4, realpathSync as realpathSync2 } from "node:fs";
1716
- import { posix as posix6, resolve as resolve7 } from "node:path";
1733
+ import { posix as posix7, resolve as resolve7 } from "node:path";
1717
1734
 
1718
1735
  // src/preset/preset-stylesheet.ts
1719
1736
  import { readdirSync as readdirSync2, readFileSync as readFileSync3, realpathSync } from "node:fs";
1720
- import { posix as posix4, resolve as resolve5 } from "node:path";
1737
+ import { posix as posix5, resolve as resolve5 } from "node:path";
1721
1738
  function presetTailwindEntry(css) {
1722
1739
  const code = css.replaceAll(/\/\*[\s\S]*?\*\//g, "");
1723
1740
  return /(?:^|[;{}])\s*@(?:import\s+(?:url\(\s*(["']?)tailwindcss(?:\/[^"')\s]*)?\1\s*\)|(["'])tailwindcss(?:\/[^"']*)?\2)|tailwind\s)/.test(code);
@@ -1734,9 +1751,9 @@ function presetStylesheet(root, directory) {
1734
1751
  }
1735
1752
  visited.add(real);
1736
1753
  for (const name of readdirSync2(resolve5(root, folder))) {
1737
- const path = posix4.join(folder, name);
1754
+ const path = posix5.join(folder, name);
1738
1755
  const full = resolve5(root, path);
1739
- const depth = posix4.relative(directory, path).split("/").length;
1756
+ const depth = posix5.relative(directory, path).split("/").length;
1740
1757
  if (presetIsDirectory(full)) {
1741
1758
  if (!name.startsWith(".") && !presetDetectSkip.includes(name) && depth < 8) {
1742
1759
  queue.push(path);
@@ -1747,7 +1764,7 @@ function presetStylesheet(root, directory) {
1747
1764
  }
1748
1765
  }
1749
1766
  const rank = (path) => {
1750
- const index = presetStylesheetNames.indexOf(posix4.basename(path, ".css"));
1767
+ const index = presetStylesheetNames.indexOf(posix5.basename(path, ".css"));
1751
1768
  return index === -1 ? presetStylesheetNames.length : index;
1752
1769
  };
1753
1770
  return found.toSorted((left, right) => rank(left) - rank(right) || left.length - right.length).at(0);
@@ -1755,7 +1772,7 @@ function presetStylesheet(root, directory) {
1755
1772
 
1756
1773
  // src/preset/preset-units.ts
1757
1774
  import { existsSync as existsSync2, readdirSync as readdirSync3 } from "node:fs";
1758
- import { posix as posix5, resolve as resolve6 } from "node:path";
1775
+ import { posix as posix6, resolve as resolve6 } from "node:path";
1759
1776
  var presetFrameworks = ["tanstack", "next", "expo"];
1760
1777
  var presetKindGroups = [...presetFrameworks, "backend", "console"];
1761
1778
  function presetUnitSource(root, key, kind, source) {
@@ -1763,9 +1780,9 @@ function presetUnitSource(root, key, kind, source) {
1763
1780
  if (source !== "." && !source.split("/").every((segment) => presetLiteralSegment.test(segment))) {
1764
1781
  throw new PresetError(`Folder \`${key}\` source must be \`.\` or a literal relative path.`);
1765
1782
  }
1766
- return posix5.join(key, source);
1783
+ return posix6.join(key, source);
1767
1784
  }
1768
- return kind === "console" || !existsSync2(resolve6(root, key, "src")) ? key : posix5.join(key, "src");
1785
+ return kind === "console" || !existsSync2(resolve6(root, key, "src")) ? key : posix6.join(key, "src");
1769
1786
  }
1770
1787
  function presetSets(folder, name) {
1771
1788
  return folder[name] !== undefined && folder[name] !== false;
@@ -1808,16 +1825,18 @@ function presetUnit(root, key, scope) {
1808
1825
  const layout = {};
1809
1826
  const settings = effective.layout;
1810
1827
  if (settings) {
1811
- const services = settings.services ?? (kind === "console" ? false : "domain");
1828
+ const services2 = settings.services ?? (kind === "console" ? false : "domain");
1812
1829
  const hooks = settings.hooks ?? (kind === "web" ? "flat" : false);
1813
- const modes = { services, hooks, components: settings.components ?? false };
1830
+ const modes = { services: services2, hooks, components: settings.components ?? false };
1814
1831
  for (const [name, mode] of Object.entries(modes)) {
1815
1832
  if (mode === "flat" || mode === "domain") {
1816
1833
  layout[name] = mode;
1817
1834
  }
1818
1835
  }
1819
1836
  }
1820
- return { key, scope, kind, framework, source: presetUnitSource(root, key, kind, folder.source), layout };
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 };
1821
1840
  }
1822
1841
  function presetUnits(root, scopes, ignored) {
1823
1842
  const [rootScope] = scopes;
@@ -1844,17 +1863,17 @@ function presetUnits(root, scopes, ignored) {
1844
1863
  return [presetUnit(root, scope.key, scope)];
1845
1864
  }
1846
1865
  const base = scope.key.slice(0, -2);
1847
- return readdirSync3(resolve6(root, base)).filter((name) => !name.startsWith(".") && presetIsDirectory(resolve6(root, base, name))).map((name) => posix5.join(base, name)).filter((key) => !explicit.has(key) && !ignored.has(key)).map((key) => presetUnit(root, key, scope));
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));
1848
1867
  });
1849
1868
  }
1850
1869
 
1851
1870
  // src/preset/preset-folders.ts
1852
1871
  function presetNormalizeKey(key) {
1853
1872
  const portable = key.replaceAll("\\", "/");
1854
- if (!portable.trim() || posix6.isAbsolute(portable) || /^[a-z]:/i.test(portable)) {
1873
+ if (!portable.trim() || posix7.isAbsolute(portable) || /^[a-z]:/i.test(portable)) {
1855
1874
  throw new PresetError(`Folder keys must be project-relative paths: ${key}`);
1856
1875
  }
1857
- const normalized = posix6.normalize(portable).replace(/\/$/, "");
1876
+ const normalized = posix7.normalize(portable).replace(/\/$/, "");
1858
1877
  if (normalized === ".." || normalized.startsWith("../")) {
1859
1878
  throw new PresetError(`Folder keys must stay inside the project: ${key}`);
1860
1879
  }
@@ -2004,6 +2023,10 @@ function presetProject(config) {
2004
2023
  const units = presetUnits(root, scopes, ignored);
2005
2024
  const unitKeys = new Set(units.map((unit) => unit.scope.key));
2006
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
+ }
2007
2030
  if (misplaced) {
2008
2031
  throw new PresetError(`Set layout.operations on an app or package folder, not on \`${misplaced.key}\`.`);
2009
2032
  }
@@ -2020,6 +2043,7 @@ function presetProject(config) {
2020
2043
  const libraryUnits = of("library");
2021
2044
  const packageSources = libraryUnits.map((unit) => unit.source);
2022
2045
  const app = [...web, ...backend, ...packageSources];
2046
+ const serviceFolders = Object.fromEntries(units.map((unit) => [unit.source, unit.services]));
2023
2047
  const appFiles = presetScopes(app);
2024
2048
  const webFiles = presetScopes(web);
2025
2049
  const effective = live.map((scope) => scope.effective);
@@ -2033,9 +2057,9 @@ function presetProject(config) {
2033
2057
  const orpcScopes = effective.flatMap((groups) => groups.orpc ? [groups.orpc] : []);
2034
2058
  const listOf = (values) => [...new Set(values.flatMap((value) => value ?? []))];
2035
2059
  const client = orpcScopes.find((settings) => settings.client !== undefined)?.client;
2036
- const rpcClients = client === undefined ? webUnits.map((unit) => posix6.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];
2037
2061
  const kitUnits = units.filter((unit) => unit.scope.effective.kit);
2038
- const uiRoots = [...new Set([...web.map((path) => posix6.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)])];
2039
2063
  const webUiRoots = uiRoots.filter((path) => web.some((webRoot) => webRoot === "." || path === webRoot || path.startsWith(`${webRoot}/`)));
2040
2064
  const stylesheet = (unit) => {
2041
2065
  const tailwind = unit.scope.effective.tailwind;
@@ -2077,20 +2101,21 @@ function presetProject(config) {
2077
2101
  return [key];
2078
2102
  }
2079
2103
  const base = key.slice(0, -2);
2080
- return readdirSync4(resolve7(root, base)).filter((name) => presetIsDirectory(resolve7(root, base, name))).map((name) => posix6.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));
2081
2105
  });
2082
2106
  return {
2083
2107
  root,
2108
+ serviceFolders,
2084
2109
  scopes: live,
2085
2110
  units,
2086
2111
  suffixes,
2087
2112
  colocated: listOf(effective.map((groups) => groups.types?.colocated)),
2088
2113
  orpc: {
2089
- public: publicProcedures.length > 0 ? publicProcedures : backendUnits.map((unit) => posix6.join(unit.source, "rpc/api")).filter((path) => existsSync3(resolve7(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}/**`),
2090
2115
  composers: listOf(orpcScopes.map((settings) => settings.composers)),
2091
2116
  client: client ?? ""
2092
2117
  },
2093
- layouts: units.flatMap((unit) => Object.entries(unit.layout).map(([folder, mode]) => ({ path: posix6.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 }))),
2094
2119
  rpcClients,
2095
2120
  operations: units.flatMap((unit) => unit.scope.effective.layout?.operations === "folders" ? [unit.source] : []),
2096
2121
  packages: libraryUnits.map((unit) => unit.key),
@@ -2113,7 +2138,7 @@ function presetProject(config) {
2113
2138
  ...presetScopes(packageSources, `hooks/**/${presetExtensions}`)
2114
2139
  ],
2115
2140
  backendFiles: presetScopes(backend),
2116
- services: presetScopes(app, `services/**/${presetExtensions}`),
2141
+ services: presetServiceScopes(serviceFolders, app),
2117
2142
  utilities: ["**/*.utils.ts", "**/*.constants.ts", "**/*.types.ts"],
2118
2143
  types: presetScopes(app, "types/**"),
2119
2144
  uiRoots,
@@ -2127,7 +2152,7 @@ function presetProject(config) {
2127
2152
  publicEntrypoints,
2128
2153
  baseImports: presetBaseImports,
2129
2154
  css: { ...webCss, ...kitCss },
2130
- registries: backendUnits.flatMap((unit) => presetRegistryFile(root, unit.source, unit.scope.effective.backend?.registry) ?? []),
2155
+ registries: backendUnits.flatMap((unit) => presetRegistryFile(root, unit.source, unit.scope.effective.backend?.registry, unit.services) ?? []),
2131
2156
  ignores: skipped.map((key) => `${key}/**`),
2132
2157
  outputs: [".", ...units.map((unit) => unit.key)].flatMap((key) => presetOutputFolders.map((folder) => key === "." ? `${folder}/**` : `${key}/${folder}/**`))
2133
2158
  };
@@ -3,6 +3,7 @@ import type { OxlintOverride } from 'oxlint';
3
3
  export declare class PresetError extends Error {
4
4
  }
5
5
  export declare function presetScopes(roots: string[], suffix?: string): string[];
6
+ export declare function presetServiceScopes(folders: Record<string, string>, sources: string[], suffix?: string): string[];
6
7
  export declare function presetOverride(files: string[], rules: PresetTypes.PresetRules, excludeFiles?: string[]): OxlintOverride;
7
8
  export declare function presetSeverity(rules: PresetTypes.PresetRules, level: PresetTypes.PresetLevel): PresetTypes.PresetRules;
8
9
  export declare function presetPackageName(root: string, directory: string): string | undefined;
@@ -11,4 +12,5 @@ export declare function presetAliases(root: string, aliases: Record<string, stri
11
12
  export declare function presetRuntime(project: PresetTypes.PresetProject, settings: Required<PresetTypes.PresetFrameworkOptions>, framework: PresetTypes.PresetFramework, rule: string): OxlintOverride[];
12
13
  export declare function presetManifest(root: string, directory: string): PresetTypes.PresetManifest;
13
14
  export declare function presetIsDirectory(path: string): boolean;
15
+ export declare function presetServicesFolder(root: string, source: string, configured?: string, fallback?: boolean): string;
14
16
  export declare function presetIsFile(path: string): boolean;
@@ -4,6 +4,7 @@ export interface BoundariesOptions {
4
4
  backend: string[];
5
5
  packages: string | string[];
6
6
  roles?: string[];
7
+ services?: string[];
7
8
  portable?: boolean;
8
9
  backendPackages: string[];
9
10
  appPackages: string[];
@@ -153,6 +153,7 @@ export type PresetGroupName = keyof PresetGroups;
153
153
  export interface PresetFolder extends PresetGroups {
154
154
  severity?: PresetLevel;
155
155
  source?: string;
156
+ services?: string;
156
157
  rules?: PresetRules;
157
158
  }
158
159
  export type PresetFolders = Record<string, boolean | PresetFolder>;
@@ -224,6 +225,7 @@ export interface PresetUnit {
224
225
  kind: PresetKind;
225
226
  framework?: PresetFramework;
226
227
  source: string;
228
+ services: string;
227
229
  layout: Record<string, PresetLayoutMode>;
228
230
  }
229
231
  export interface PresetLayoutScope {
@@ -261,6 +263,7 @@ export interface PresetManifest {
261
263
  }
262
264
  export interface PresetProject {
263
265
  root: string;
266
+ serviceFolders: Record<string, string>;
264
267
  scopes: PresetScope[];
265
268
  units: PresetUnit[];
266
269
  suffixes: string[];
@@ -7,6 +7,7 @@ export interface SchemasElementOptions {
7
7
  export interface SchemasLocalOptions {
8
8
  packages: string[];
9
9
  namespaces: string[];
10
+ calls?: string[];
10
11
  allow?: string[];
11
12
  patterns?: string[];
12
13
  message?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oxlint-plugin-arch",
3
- "version": "0.12.1",
3
+ "version": "0.12.2",
4
4
  "description": "Configurable architecture rules for Oxlint JavaScript plugins.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",