oxlint-plugin-arch 0.12.1 → 0.12.3

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,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.12.3
4
+
5
+ ### Fixed
6
+
7
+ - Preset: Expo apps skip the DOM SVG rules (`svg-jsx/*`). React Native and Skia props such as `<Path style="stroke">` are not CSS strings, and the rule's autofix crashed on them.
8
+
9
+ ## 0.12.2
10
+
11
+ ### Added
12
+
13
+ - 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.
14
+
15
+ ### Fixed
16
+
17
+ - `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.
18
+
3
19
  ## 0.12.1
4
20
 
5
21
  ### 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 } }] }));
@@ -1081,6 +1097,7 @@ var expoGroup = (project, settings) => {
1081
1097
  }
1082
1098
  ]
1083
1099
  }, testFiles),
1100
+ presetOverride(presetScopes(expoWeb), { "svg-jsx/camel-case-colon": "off", "svg-jsx/no-style-string": "off" }),
1084
1101
  presetOverride(presetScopes(expoWeb, "app/**/*+api.ts"), {
1085
1102
  "arch/files-route": ["error", { export: "GET", exports: ["HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], hooks: [], intrinsic: false }]
1086
1103
  })
@@ -1151,6 +1168,7 @@ var importsGroup = (project, settings) => {
1151
1168
  backend: absolute(project.backend),
1152
1169
  packages: absolute(project.packages),
1153
1170
  roles: project.suffixes,
1171
+ services: absolute([...project.web, ...project.backend].map((source) => project.serviceFolders[source])),
1154
1172
  portable: true,
1155
1173
  aliases: presetAliases(project.root, settings.aliases),
1156
1174
  appPackages: importsPackageNames(project, ["web", "backend"]),
@@ -1163,18 +1181,18 @@ var importsGroup = (project, settings) => {
1163
1181
  };
1164
1182
 
1165
1183
  // src/preset/groups/layout.ts
1166
- import { posix as posix3 } from "node:path";
1184
+ import { posix as posix4 } from "node:path";
1167
1185
  var layoutGroup = (project, settings) => {
1168
1186
  const { web, nextWeb, serviceRoots, app, services, utilities, ui, testFiles, layouts, operations } = project;
1169
- const folderOperations = presetScopes(operations, "services/*/{actions,queries}/*.ts");
1187
+ const folderOperations = presetServiceScopes(project.serviceFolders, operations, "*/{actions,queries}/*.ts");
1170
1188
  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
1189
  const roles = [...presetBuiltinFileRoles.filter((role) => role !== "action" && role !== "query"), ...project.suffixes];
1172
1190
  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")));
1191
+ const frontendEntries = [...presetServiceScopes(project.serviceFolders, web, "**/*.{client,server,rsc}.ts"), ...presetServiceScopes(project.serviceFolders, web, "**/*.rsc.tsx")];
1192
+ const ungroupedWeb = web.filter((path) => !layouts.some((scope) => scope.path === posix4.join(path, "components")));
1175
1193
  return [
1176
1194
  ...layouts.flatMap(({ path, folder, mode }) => {
1177
- const folders = folder === "services" && operations.some((source) => posix3.join(source, "services") === path);
1195
+ const folders = folder === "services" && operations.some((source) => posix4.join(source, "services") === path);
1178
1196
  const registries = folder === "services" ? project.registries : [];
1179
1197
  const restricted = presetOverride(mode === "flat" ? [`${path}/*/**/${presetExtensions}`] : [`${path}/${presetExtensions}`, `${path}/*/*/**/${presetExtensions}`], {
1180
1198
  "arch/files-restricted": [
@@ -1207,7 +1225,7 @@ var layoutGroup = (project, settings) => {
1207
1225
  presetOverride(presetScopes(ungroupedWeb, `components/*/*/**/${presetExtensions}`), { "arch/files-restricted": ["error", { message: "Components allow one group folder." }] }, ui),
1208
1226
  presetOverride([...presetScopes(web, "components/**/*.ts"), ...presetScopes(web, "hooks/**/*.tsx")], { "arch/files-restricted": ["error", { message: "Components use TSX; hooks use .ts files." }] }, ui),
1209
1227
  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}"), {
1228
+ presetOverride(presetServiceScopes(project.serviceFolders, web, "**/*.{ts,tsx}"), {
1211
1229
  "arch/files-restricted": [
1212
1230
  "error",
1213
1231
  {
@@ -1217,7 +1235,7 @@ var layoutGroup = (project, settings) => {
1217
1235
  }, [
1218
1236
  "**/*.{client,server,rsc,utils,constants}.ts",
1219
1237
  "**/*.rsc.tsx",
1220
- ...presetScopes(nextWeb, "services/**/*.action.ts"),
1238
+ ...presetServiceScopes(project.serviceFolders, nextWeb, "**/*.action.ts"),
1221
1239
  ...conceptFiles,
1222
1240
  ...project.colocated,
1223
1241
  ...testFiles
@@ -1227,22 +1245,22 @@ var layoutGroup = (project, settings) => {
1227
1245
  "arch/types-inline": ["error", { parameters: true, returns: true, functions: true, min: 1 }]
1228
1246
  }, [...utilities, ...testFiles, ...conceptFiles]),
1229
1247
  presetOverride(frontendEntries, { "arch/backend-service-functions": ["error", { frontend: true }] }, testFiles),
1230
- ...conceptFiles.map((pattern) => presetOverride(presetScopes(app, `services/${pattern}`), {
1248
+ ...conceptFiles.map((pattern) => presetOverride(presetServiceScopes(project.serviceFolders, app, pattern), {
1231
1249
  "arch/backend-service-functions": "off",
1232
1250
  "arch/naming-exports": ["error", { stem: "full-basename", scope: "functions" }]
1233
1251
  }, 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),
1252
+ 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
1253
  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 }] }, [
1254
+ presetOverride(presetServiceScopes(project.serviceFolders, serviceRoots, "**/*.ts"), { "arch/files-domain-constants": "error" }, [...utilities, ...testFiles]),
1255
+ presetOverride(presetServiceScopes(project.serviceFolders, web, "**/*.{ts,tsx}"), { "arch/files-domain-constants": ["error", { data: true }] }, [
1238
1256
  "**/*.constants.ts",
1239
1257
  ...testFiles
1240
1258
  ]),
1241
1259
  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),
1260
+ presetOverride([...presetServiceScopes(project.serviceFolders, serviceRoots, "**/*-{action,query}.*.ts"), ...folderOperations, ...frontendEntries], { "arch/code-object-params": ["error", { max: 2 }] }, testFiles),
1243
1261
  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"), {
1262
+ presetOverride(presetServiceScopes(project.serviceFolders, serviceRoots, "**/*.service.ts"), { "arch/files-exports": ["error", { names: settings.service, stem: "before-first-dot", types: false }] }, testFiles),
1263
+ presetOverride(presetServiceScopes(project.serviceFolders, app.filter((path) => !operations.includes(path)), "**/{actions,queries}/**/*.ts"), {
1246
1264
  "arch/files-restricted": ["error", { message: "Use domain-action.name.ts or domain-query.name.ts." }]
1247
1265
  })
1248
1266
  ];
@@ -1270,7 +1288,7 @@ var namingGroup = (project, settings, owner) => {
1270
1288
  const { web, serviceRoots, app, ui, testFiles, components, hooks, operations } = project;
1271
1289
  const flatApp = app.filter((path) => !operations.includes(path));
1272
1290
  const folderApp = app.filter((path) => operations.includes(path));
1273
- const frontendEntries = [...presetScopes(web, "services/**/*.{client,server,rsc}.ts"), ...presetScopes(web, "services/**/*.rsc.tsx")];
1291
+ const frontendEntries = [...presetServiceScopes(project.serviceFolders, web, "**/*.{client,server,rsc}.ts"), ...presetServiceScopes(project.serviceFolders, web, "**/*.rsc.tsx")];
1274
1292
  const ungroupedWeb = web.filter((path) => !project.layouts.some((scope) => scope.path === `${path}/components`));
1275
1293
  const overrides = [];
1276
1294
  const { files: filesPattern, exports: exportsPattern, unique } = settings;
@@ -1309,7 +1327,7 @@ var namingGroup = (project, settings, owner) => {
1309
1327
  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
1328
  add("exports", presetScopes(app, "**/*.{constants,utils,handler,service,types,client,server,rsc}.ts"), { "arch/naming-exports": ["error", presetExportPrefix] }, [...ui, ...presetScopes(serviceRoots, "entry.*.ts")]);
1311
1329
  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);
1330
+ add("exports", presetServiceScopes(project.serviceFolders, web, "**/*.constants.ts"), { "arch/naming-exports": ["error", { ...presetExportPrefix, scope: "declarations", allow: "^\\*$" }] }, testFiles);
1313
1331
  add("exports", presetScopes(app, "**/*.utils.ts"), { "arch/naming-exports": ["error", { ...presetExportPrefix, scope: "functions", allow: "^\\*$" }] }, ui);
1314
1332
  add("files", hooks, {
1315
1333
  "arch/naming-file": [
@@ -1324,7 +1342,7 @@ var namingGroup = (project, settings, owner) => {
1324
1342
  add("exports", components, { "arch/files-components-only": ["error", { filename: true, types: true, roles: ["illustration"] }] }, [...ui, ...testFiles]);
1325
1343
  add("folders", presetScopes(ungroupedWeb, "components/**/*.tsx"), { "arch/naming-folder": ["error", { singularize: "trailing-s", separators: ["-"] }] }, [...ui, ...testFiles]);
1326
1344
  for (const role of ["action", "query"]) {
1327
- const files = [...presetScopes(flatApp, `services/**/*-${role}.*.ts`), ...presetScopes(web, `services/**/*-${role}.*.rsc.tsx`)];
1345
+ const files = [...presetServiceScopes(project.serviceFolders, flatApp, `**/*-${role}.*.ts`), ...presetServiceScopes(project.serviceFolders, web, `**/*-${role}.*.rsc.tsx`)];
1328
1346
  add("files", files, {
1329
1347
  "arch/naming-file": ["error", { pattern: `^[a-z0-9]+(?:-[a-z0-9]+)*-${role}\\.[a-z0-9]+(?:-[a-z0-9]+)*\\.ts$`, message: namingOperationMessage }]
1330
1348
  }, testFiles);
@@ -1337,7 +1355,7 @@ var namingGroup = (project, settings, owner) => {
1337
1355
  }, testFiles);
1338
1356
  }
1339
1357
  for (const [role, folder, separator] of [["action", "actions", "."], ["query", "queries", "-"]]) {
1340
- const files = presetScopes(folderApp, `services/*/${folder}/*.ts`);
1358
+ const files = presetServiceScopes(project.serviceFolders, folderApp, `*/${folder}/*.ts`);
1341
1359
  add("files", files, {
1342
1360
  "arch/naming-file": [
1343
1361
  "error",
@@ -1356,7 +1374,7 @@ var namingGroup = (project, settings, owner) => {
1356
1374
  }
1357
1375
  for (const role of ["action", "query"]) {
1358
1376
  for (const suffix of ["client.ts", "server.ts", "rsc.ts", "rsc.tsx"]) {
1359
- const files = presetScopes(web, `services/**/*-${role}.*.${suffix}`);
1377
+ const files = presetServiceScopes(project.serviceFolders, web, `**/*-${role}.*.${suffix}`);
1360
1378
  add("files", files, {
1361
1379
  "arch/naming-file": [
1362
1380
  "error",
@@ -1369,7 +1387,7 @@ var namingGroup = (project, settings, owner) => {
1369
1387
  add("exports", files, { "arch/naming-exports": ["error", namingOperationPrefix(role), namingOperation(role, `{domain}-${role}.{name}.${suffix}`)] }, testFiles);
1370
1388
  }
1371
1389
  }
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]);
1390
+ 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
1391
  if (typeof exportsPattern === "string") {
1374
1392
  overrides.push(presetOverride([`**/${presetExtensions}`], { "arch/naming-exports": ["error", { mode: "pattern", pattern: exportsPattern, types: true }] }));
1375
1393
  }
@@ -1503,7 +1521,7 @@ var shadcnGroup = (project) => {
1503
1521
 
1504
1522
  // src/preset/groups/sql.ts
1505
1523
  var sqlGroup = (project, settings) => [
1506
- presetOverride(presetScopes(project.serviceRoots, "services/**/*.ts"), {
1524
+ presetOverride(presetServiceScopes(project.serviceFolders, project.serviceRoots, "**/*.ts"), {
1507
1525
  "arch/backend-sql-like": [
1508
1526
  "error",
1509
1527
  { methods: ["like", "ilike"], operators: ["where"], sanitizers: settings.sanitizers, bindings: true }
@@ -1564,7 +1582,7 @@ var tanstackGroup = (project, settings) => {
1564
1582
  patterns: project.baseImports
1565
1583
  }
1566
1584
  ]
1567
- }, presetScopes(tanstackWeb, "services/**/*.rsc.{ts,tsx}")),
1585
+ }, presetServiceScopes(project.serviceFolders, tanstackWeb, "**/*.rsc.{ts,tsx}")),
1568
1586
  ...presetRuntime(project, settings, "tanstack", "arch/boundaries-tanstack")
1569
1587
  ];
1570
1588
  };
@@ -1713,11 +1731,11 @@ var typesGroup = (project, settings) => {
1713
1731
 
1714
1732
  // src/preset/preset-folders.ts
1715
1733
  import { existsSync as existsSync3, readdirSync as readdirSync4, realpathSync as realpathSync2 } from "node:fs";
1716
- import { posix as posix6, resolve as resolve7 } from "node:path";
1734
+ import { posix as posix7, resolve as resolve7 } from "node:path";
1717
1735
 
1718
1736
  // src/preset/preset-stylesheet.ts
1719
1737
  import { readdirSync as readdirSync2, readFileSync as readFileSync3, realpathSync } from "node:fs";
1720
- import { posix as posix4, resolve as resolve5 } from "node:path";
1738
+ import { posix as posix5, resolve as resolve5 } from "node:path";
1721
1739
  function presetTailwindEntry(css) {
1722
1740
  const code = css.replaceAll(/\/\*[\s\S]*?\*\//g, "");
1723
1741
  return /(?:^|[;{}])\s*@(?:import\s+(?:url\(\s*(["']?)tailwindcss(?:\/[^"')\s]*)?\1\s*\)|(["'])tailwindcss(?:\/[^"']*)?\2)|tailwind\s)/.test(code);
@@ -1734,9 +1752,9 @@ function presetStylesheet(root, directory) {
1734
1752
  }
1735
1753
  visited.add(real);
1736
1754
  for (const name of readdirSync2(resolve5(root, folder))) {
1737
- const path = posix4.join(folder, name);
1755
+ const path = posix5.join(folder, name);
1738
1756
  const full = resolve5(root, path);
1739
- const depth = posix4.relative(directory, path).split("/").length;
1757
+ const depth = posix5.relative(directory, path).split("/").length;
1740
1758
  if (presetIsDirectory(full)) {
1741
1759
  if (!name.startsWith(".") && !presetDetectSkip.includes(name) && depth < 8) {
1742
1760
  queue.push(path);
@@ -1747,7 +1765,7 @@ function presetStylesheet(root, directory) {
1747
1765
  }
1748
1766
  }
1749
1767
  const rank = (path) => {
1750
- const index = presetStylesheetNames.indexOf(posix4.basename(path, ".css"));
1768
+ const index = presetStylesheetNames.indexOf(posix5.basename(path, ".css"));
1751
1769
  return index === -1 ? presetStylesheetNames.length : index;
1752
1770
  };
1753
1771
  return found.toSorted((left, right) => rank(left) - rank(right) || left.length - right.length).at(0);
@@ -1755,7 +1773,7 @@ function presetStylesheet(root, directory) {
1755
1773
 
1756
1774
  // src/preset/preset-units.ts
1757
1775
  import { existsSync as existsSync2, readdirSync as readdirSync3 } from "node:fs";
1758
- import { posix as posix5, resolve as resolve6 } from "node:path";
1776
+ import { posix as posix6, resolve as resolve6 } from "node:path";
1759
1777
  var presetFrameworks = ["tanstack", "next", "expo"];
1760
1778
  var presetKindGroups = [...presetFrameworks, "backend", "console"];
1761
1779
  function presetUnitSource(root, key, kind, source) {
@@ -1763,9 +1781,9 @@ function presetUnitSource(root, key, kind, source) {
1763
1781
  if (source !== "." && !source.split("/").every((segment) => presetLiteralSegment.test(segment))) {
1764
1782
  throw new PresetError(`Folder \`${key}\` source must be \`.\` or a literal relative path.`);
1765
1783
  }
1766
- return posix5.join(key, source);
1784
+ return posix6.join(key, source);
1767
1785
  }
1768
- return kind === "console" || !existsSync2(resolve6(root, key, "src")) ? key : posix5.join(key, "src");
1786
+ return kind === "console" || !existsSync2(resolve6(root, key, "src")) ? key : posix6.join(key, "src");
1769
1787
  }
1770
1788
  function presetSets(folder, name) {
1771
1789
  return folder[name] !== undefined && folder[name] !== false;
@@ -1808,16 +1826,18 @@ function presetUnit(root, key, scope) {
1808
1826
  const layout = {};
1809
1827
  const settings = effective.layout;
1810
1828
  if (settings) {
1811
- const services = settings.services ?? (kind === "console" ? false : "domain");
1829
+ const services2 = settings.services ?? (kind === "console" ? false : "domain");
1812
1830
  const hooks = settings.hooks ?? (kind === "web" ? "flat" : false);
1813
- const modes = { services, hooks, components: settings.components ?? false };
1831
+ const modes = { services: services2, hooks, components: settings.components ?? false };
1814
1832
  for (const [name, mode] of Object.entries(modes)) {
1815
1833
  if (mode === "flat" || mode === "domain") {
1816
1834
  layout[name] = mode;
1817
1835
  }
1818
1836
  }
1819
1837
  }
1820
- return { key, scope, kind, framework, source: presetUnitSource(root, key, kind, folder.source), layout };
1838
+ const source = presetUnitSource(root, key, kind, folder.source);
1839
+ const services = presetServicesFolder(root, source, folder.services, kind === "web" || kind === "backend");
1840
+ return { key, scope, kind, framework, source, services, layout };
1821
1841
  }
1822
1842
  function presetUnits(root, scopes, ignored) {
1823
1843
  const [rootScope] = scopes;
@@ -1844,17 +1864,17 @@ function presetUnits(root, scopes, ignored) {
1844
1864
  return [presetUnit(root, scope.key, scope)];
1845
1865
  }
1846
1866
  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));
1867
+ 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
1868
  });
1849
1869
  }
1850
1870
 
1851
1871
  // src/preset/preset-folders.ts
1852
1872
  function presetNormalizeKey(key) {
1853
1873
  const portable = key.replaceAll("\\", "/");
1854
- if (!portable.trim() || posix6.isAbsolute(portable) || /^[a-z]:/i.test(portable)) {
1874
+ if (!portable.trim() || posix7.isAbsolute(portable) || /^[a-z]:/i.test(portable)) {
1855
1875
  throw new PresetError(`Folder keys must be project-relative paths: ${key}`);
1856
1876
  }
1857
- const normalized = posix6.normalize(portable).replace(/\/$/, "");
1877
+ const normalized = posix7.normalize(portable).replace(/\/$/, "");
1858
1878
  if (normalized === ".." || normalized.startsWith("../")) {
1859
1879
  throw new PresetError(`Folder keys must stay inside the project: ${key}`);
1860
1880
  }
@@ -2004,6 +2024,10 @@ function presetProject(config) {
2004
2024
  const units = presetUnits(root, scopes, ignored);
2005
2025
  const unitKeys = new Set(units.map((unit) => unit.scope.key));
2006
2026
  const misplaced = live.find((scope) => !unitKeys.has(scope.key) && Object(scope.folder.layout).operations !== undefined);
2027
+ const servicesKey = live.find((scope) => !unitKeys.has(scope.key) && scope.folder.services !== undefined);
2028
+ if (servicesKey) {
2029
+ throw new PresetError(`Set services on an app or package folder, not on \`${servicesKey.key}\`.`);
2030
+ }
2007
2031
  if (misplaced) {
2008
2032
  throw new PresetError(`Set layout.operations on an app or package folder, not on \`${misplaced.key}\`.`);
2009
2033
  }
@@ -2020,6 +2044,7 @@ function presetProject(config) {
2020
2044
  const libraryUnits = of("library");
2021
2045
  const packageSources = libraryUnits.map((unit) => unit.source);
2022
2046
  const app = [...web, ...backend, ...packageSources];
2047
+ const serviceFolders = Object.fromEntries(units.map((unit) => [unit.source, unit.services]));
2023
2048
  const appFiles = presetScopes(app);
2024
2049
  const webFiles = presetScopes(web);
2025
2050
  const effective = live.map((scope) => scope.effective);
@@ -2033,9 +2058,9 @@ function presetProject(config) {
2033
2058
  const orpcScopes = effective.flatMap((groups) => groups.orpc ? [groups.orpc] : []);
2034
2059
  const listOf = (values) => [...new Set(values.flatMap((value) => value ?? []))];
2035
2060
  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];
2061
+ const rpcClients = client === undefined ? webUnits.map((unit) => posix7.join(unit.services, unit.layout.services === "domain" ? "rpc/rpc.client.ts" : "rpc.client.ts")) : [client];
2037
2062
  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)])];
2063
+ const uiRoots = [...new Set([...web.map((path) => posix7.join(path, "components/ui")), ...kitUnits.map((unit) => unit.source)])];
2039
2064
  const webUiRoots = uiRoots.filter((path) => web.some((webRoot) => webRoot === "." || path === webRoot || path.startsWith(`${webRoot}/`)));
2040
2065
  const stylesheet = (unit) => {
2041
2066
  const tailwind = unit.scope.effective.tailwind;
@@ -2077,20 +2102,21 @@ function presetProject(config) {
2077
2102
  return [key];
2078
2103
  }
2079
2104
  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));
2105
+ 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
2106
  });
2082
2107
  return {
2083
2108
  root,
2109
+ serviceFolders,
2084
2110
  scopes: live,
2085
2111
  units,
2086
2112
  suffixes,
2087
2113
  colocated: listOf(effective.map((groups) => groups.types?.colocated)),
2088
2114
  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}/**`),
2115
+ public: publicProcedures.length > 0 ? publicProcedures : backendUnits.map((unit) => posix7.join(unit.source, "rpc/api")).filter((path) => existsSync3(resolve7(root, path))).map((path) => `${path}/**`),
2090
2116
  composers: listOf(orpcScopes.map((settings) => settings.composers)),
2091
2117
  client: client ?? ""
2092
2118
  },
2093
- layouts: units.flatMap((unit) => Object.entries(unit.layout).map(([folder, mode]) => ({ path: posix6.join(unit.source, folder), folder, mode }))),
2119
+ layouts: units.flatMap((unit) => Object.entries(unit.layout).map(([folder, mode]) => ({ path: folder === "services" ? unit.services : posix7.join(unit.source, folder), folder, mode }))),
2094
2120
  rpcClients,
2095
2121
  operations: units.flatMap((unit) => unit.scope.effective.layout?.operations === "folders" ? [unit.source] : []),
2096
2122
  packages: libraryUnits.map((unit) => unit.key),
@@ -2113,7 +2139,7 @@ function presetProject(config) {
2113
2139
  ...presetScopes(packageSources, `hooks/**/${presetExtensions}`)
2114
2140
  ],
2115
2141
  backendFiles: presetScopes(backend),
2116
- services: presetScopes(app, `services/**/${presetExtensions}`),
2142
+ services: presetServiceScopes(serviceFolders, app),
2117
2143
  utilities: ["**/*.utils.ts", "**/*.constants.ts", "**/*.types.ts"],
2118
2144
  types: presetScopes(app, "types/**"),
2119
2145
  uiRoots,
@@ -2127,7 +2153,7 @@ function presetProject(config) {
2127
2153
  publicEntrypoints,
2128
2154
  baseImports: presetBaseImports,
2129
2155
  css: { ...webCss, ...kitCss },
2130
- registries: backendUnits.flatMap((unit) => presetRegistryFile(root, unit.source, unit.scope.effective.backend?.registry) ?? []),
2156
+ registries: backendUnits.flatMap((unit) => presetRegistryFile(root, unit.source, unit.scope.effective.backend?.registry, unit.services) ?? []),
2131
2157
  ignores: skipped.map((key) => `${key}/**`),
2132
2158
  outputs: [".", ...units.map((unit) => unit.key)].flatMap((key) => presetOutputFolders.map((folder) => key === "." ? `${folder}/**` : `${key}/${folder}/**`))
2133
2159
  };
@@ -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.3",
4
4
  "description": "Configurable architecture rules for Oxlint JavaScript plugins.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",