oxlint-plugin-arch 0.12.0 → 0.12.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,41 @@
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
+
13
+ ## 0.12.1
14
+
15
+ ### Changed
16
+
17
+ - Preset: tests live in a `tests/` (or `test/`) folder. A `*.test.*` or `*.spec.*` file inside a unit's source is reported by `arch/files-restricted`.
18
+
19
+ ### Fixed
20
+
21
+ - Preset detection reads `pnpm-workspace.yaml` (block or flow lists), workspace globs with `*` anywhere, `**`, `{a,b}` and `!exclude`, and peer dependencies (a UI package with React as a peer is `ui`). Packages and service domains named like output folders (`build`, `public`) are no longer skipped.
22
+ - Preset detection keeps a root app's or backend's recipe next to its workspace packages (when the root has its own source folder), gives a single root package its recipe (`library` included), and keeps the root unit next to a detected `scripts/` CLI. A folder with its own `package.json` is a unit with its own kind, also when nested in another workspace or listed through a `*` key, instead of inheriting the root app's kind.
23
+ - Preset: a unit without `src/` uses its own folder as source (a workspace Next app with `app/` at the package root).
24
+ - Preset detection honors a configured `source`, `backend.registry` file or `false`, expands `*` keys (with exact keys beside them, which also inherit the wildcard's `source`) before adding conventions, and understands import maps such as `"#/*": "./src/*.ts"`, `"#/*.ts": "./src/*.ts"`, exact and conditional entries.
25
+ - Preset detection: a service may import its own domain's operations; other domains stay banned through one `!(domain)` pattern per domain, and a ban you configure yourself is never lifted. A web app's or package's own aliases that overlap the backend's (`#/*`, `#/lib/*`) are no longer banned.
26
+ - Preset detection skips broken symlinks, follows symlinked folders, and drops conventions inside a `false` folder or for a group a parent sets to `false` (an exact key beats a `*` key at the same depth, and an exact key that revives a unit under a `false` wildcard keeps its conventions), instead of throwing.
27
+ - Preset: folder keys accept dots (`packages/my.lib`, `.agents`), and `apps/web/` or `./apps` match the detected `apps/web` and `apps`.
28
+ - Preset: nested group options merge with the inherited ones (`tests.mocking: { allow: [] }` keeps the parent's `preset` and `deny`, also when the parent used the `'strict'` shorthand).
29
+ - Preset: the registry file is exempt from the domain layout and its file and folder name checks; test files skip the domain, operation and frontend runtime file name checks (the `tests/` check below reports them once); operation files in `actions/` and `queries/` keep the single-export check; `layout.strict` accepts `.rsc.tsx`.
30
+ - Preset: Tailwind stylesheet detection ignores comments, strings and plugins such as `tailwindcss-animate`, accepts `@import url("tailwindcss")` with or without quotes, and follows symlinked folders.
31
+ - Preset: `dist`, `build`, `coverage` and `.output` are ignored at the project root and at each unit's root, not at any depth, so a `services/build/` domain is linted.
32
+ - `arch/boundaries-deny` import globs accept `!(a|b)`: one path segment that is none of the names.
33
+ - Preset: `layout.strict` checks domain file names at any depth under `services/<domain>/`, so a nested or misnamed file (`order/queries/order-query-list.ts`) is reported for its name, not only its folder. `layout.operations: 'folders'` keeps its own `actions/` and `queries/` names.
34
+ - Preset detection: a `next` or `vinext` dependency wins over a leftover `@tanstack/react-start` one.
35
+ - Preset detection: a package with `services/<domain>/<domain>.service.ts` gets the `backend` recipe without a backend framework dependency.
36
+ - Preset: `services/**/*.rsc.{ts,tsx}` files and frontend operation files (`*-action.*.client.ts` and the other runtime suffixes) keep the export prefix check next to their name pattern or template. Since 0.12.0 the later override replaced it; 0.11 ran both.
37
+ - `dillon-anti-slop/no-shape-in-symbol-names` reports only names you declare: not another API's property (`schema.shape`, `record.shape = 1`), a renamed destructured property or import, a qualified type read, a JSX member or attribute, or a renamed re-export from another module. A shorthand property reports once.
38
+
3
39
  ## 0.12.0
4
40
 
5
41
  ### Breaking: rebuilt package
package/dist/index.js CHANGED
@@ -32,8 +32,12 @@ function optionsPatternsTest(patterns, value) {
32
32
  });
33
33
  }
34
34
  function optionsGlob(pattern) {
35
- const source = pattern.replaceAll(/[.+^$()|[\]\\]/g, "\\$&").replaceAll("?", "[^/]").replaceAll("**/", "\x00").replaceAll("**", "\x01").replaceAll("*", "[^/]*").replaceAll("\x00", "(?:.*/)?").replaceAll("\x01", ".*").replaceAll(/\{([^}]*)\}/g, (_, options) => `(?:${options.split(",").join("|")})`);
36
- return new RegExp(`^${source}$`);
35
+ const negated = [];
36
+ const source = pattern.replaceAll(/!\(([^()]*)\)/g, (_, options) => {
37
+ negated.push(options.split("|").map((option) => option.replaceAll(/[.+^$()|[\]\\*?{}]/g, "\\$&")).join("|"));
38
+ return `\x02${negated.length - 1}\x03`;
39
+ }).replaceAll(/[.+^$()|[\]\\]/g, "\\$&").replaceAll("?", "[^/]").replaceAll("**/", "\x00").replaceAll("**", "\x01").replaceAll("*", "[^/]*").replaceAll("\x00", "(?:.*/)?").replaceAll("\x01", ".*").replaceAll(/\{([^}]*)\}/g, (_, options) => `(?:${options.split(",").join("|")})`);
40
+ return new RegExp(`^${negated.reduce((text, names, index) => text.replace(`\x02${index}\x03`, `(?!(?:${names})(?:/|$))[^/]*`), source)}$`);
37
41
  }
38
42
  function optionsPatternLabel(pattern) {
39
43
  return pattern === undefined ? "" : [pattern].flat().filter((source) => source !== "").join(" | ");
@@ -1107,15 +1111,19 @@ function boundariesContains(directory, candidate) {
1107
1111
  function boundariesStripExtension(path) {
1108
1112
  return path.replace(/\.[cm]?[jt]sx?$/, "");
1109
1113
  }
1110
- 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) {
1111
1119
  const relative = path.slice(appRoot.length + 1);
1112
1120
  if (relative.startsWith("types/") && relative.endsWith(".types.ts")) {
1113
1121
  return relative.slice("types/".length).split(".")[0];
1114
1122
  }
1115
- if (!relative.startsWith("services/")) {
1123
+ if (!boundariesContains(services, path) || path === services) {
1116
1124
  return;
1117
1125
  }
1118
- const parts = relative.slice("services/".length).split("/");
1126
+ const parts = path.slice(services.length + 1).split("/");
1119
1127
  return parts.length > 1 ? parts[0] : parts[0].split(".")[0].replace(/-(?:action|query|driver)$/, "");
1120
1128
  }
1121
1129
  function boundariesPrivateDomain({ options, file, target, reExport, roots, owner }) {
@@ -1124,8 +1132,8 @@ function boundariesPrivateDomain({ options, file, target, reExport, roots, owner
1124
1132
  return;
1125
1133
  }
1126
1134
  const targetOwner = roots.find((path) => boundariesContains(path, target));
1127
- const fileDomain = owner ? boundariesDomain(file, owner) : undefined;
1128
- 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;
1129
1137
  const sameDomain = owner && owner === targetOwner && fileDomain && fileDomain === targetDomain;
1130
1138
  const targetStem = boundariesStripExtension(target);
1131
1139
  const publicSurface = boundariesPublicSurface.test(targetStem) && !boundariesActionOrQuery.test(targetStem);
@@ -1140,11 +1148,11 @@ function boundariesPrivateDomain({ options, file, target, reExport, roots, owner
1140
1148
  }
1141
1149
  function boundariesPortableLib(check) {
1142
1150
  const { options, file, source, target, typeOnly, reExport, roots, web, backendImport } = check;
1143
- if (!options.portable || !web || !boundariesContains(`${web}/lib`, file)) {
1151
+ if (!options.portable || !web || !boundariesContains(`${web}/lib`, file) || boundariesServiceFolder(options, web) === `${web}/lib`) {
1144
1152
  return;
1145
1153
  }
1146
1154
  const portableTarget = target.startsWith("/") ? resolve2(target).replaceAll("\\", "/") : target;
1147
- const appService = roots.some((path) => boundariesContains(`${path}/services`, portableTarget));
1155
+ const appService = roots.some((path) => boundariesContains(boundariesServiceFolder(options, path), portableTarget));
1148
1156
  const backendTarget = options.backend.some((path) => boundariesContains(path, portableTarget));
1149
1157
  const appPackage = boundariesMatchesImport(options.appPackages, source);
1150
1158
  if (backendImport || backendTarget || appService || appPackage) {
@@ -1171,6 +1179,7 @@ var boundariesImports = defineRule7({
1171
1179
  backend: { type: "array", items: { type: "string" } },
1172
1180
  packages: { anyOf: [{ type: "string" }, { type: "array", items: { type: "string" } }] },
1173
1181
  roles: { type: "array", items: { type: "string" } },
1182
+ services: { type: "array", items: { type: "string" } },
1174
1183
  portable: { type: "boolean" },
1175
1184
  backendPackages: { type: "array", items: { type: "string" } },
1176
1185
  entrypoints: { type: "array", items: { type: "string" } },
@@ -1196,11 +1205,14 @@ var boundariesImports = defineRule7({
1196
1205
  const imported = { options, file, source, target, typeOnly, reExport, roots, owner, web, backendImport };
1197
1206
  const inPackage = [options.packages].flat().some((path) => path && boundariesContains(path, file));
1198
1207
  const appTarget = backendImport || options.web.some((path) => boundariesContains(path, target)) || boundariesMatchesImport(options.appPackages, source);
1199
- 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)));
1200
1212
  const reason = [
1201
1213
  web && backendImport && !options.entrypoints.includes(source) ? "frontend code uses only public backend entry points" : undefined,
1202
1214
  inPackage && appTarget ? "shared packages do not import app internals" : undefined,
1203
- 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,
1204
1216
  domainContract ? "domain types/constants use type-only imports from implementations" : undefined,
1205
1217
  file.endsWith(".client.ts") && !typeOnly && boundariesMatchesImport(options.uiPackages ?? ["react", "react-dom"], source) ? "client services are React-free; use hooks for React behavior" : undefined,
1206
1218
  boundariesPrivateDomain(imported),
@@ -1241,9 +1253,9 @@ var boundariesImports = defineRule7({
1241
1253
  }
1242
1254
  },
1243
1255
  TSImportType(node) {
1244
- const { portable: portableLib, web } = { ...boundariesImportsDefaults, ...optionsFirst(context) };
1256
+ const options = { ...boundariesImportsDefaults, ...optionsFirst(context) };
1245
1257
  const file = context.filename.replaceAll("\\", "/");
1246
- 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`)) {
1247
1259
  check(node, node.source.value, true);
1248
1260
  }
1249
1261
  }
@@ -5390,6 +5402,7 @@ var schemasInline = defineRule46({
5390
5402
  import { defineRule as defineRule47 } from "@oxlint/plugins";
5391
5403
  var schemasLocalImportMessage = "Do not import runtime schema constructors from {{source}}; import a named schema instead.";
5392
5404
  var schemasLocalConstructionMessage = "Do not construct schemas locally; import a named schema instead.";
5405
+ var schemasLocalCalls = ["config", "prettifyError", "treeifyError", "flattenError", "formatError"];
5393
5406
  var schemasLocal = defineRule47({
5394
5407
  meta: {
5395
5408
  type: "problem",
@@ -5400,6 +5413,7 @@ var schemasLocal = defineRule47({
5400
5413
  properties: {
5401
5414
  packages: { type: "array", items: { type: "string" } },
5402
5415
  namespaces: { type: "array", items: { type: "string" } },
5416
+ calls: { type: "array", items: { type: "string" } },
5403
5417
  allow: { type: "array", items: { type: "string" } },
5404
5418
  patterns: { type: "array", items: { type: "string" } },
5405
5419
  message: { type: "string" },
@@ -5417,8 +5431,15 @@ var schemasLocal = defineRule47({
5417
5431
  createOnce(context) {
5418
5432
  let constructors = new Set;
5419
5433
  let packages = new Set;
5434
+ let calls = new Set;
5435
+ let pending = [];
5436
+ let schemaUses = new Set;
5437
+ let callUses = new Set;
5420
5438
  let importMessage = schemasLocalImportMessage;
5421
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
+ }
5422
5443
  function isPackage(source) {
5423
5444
  return packages.has(source) || [...packages].some((name) => source.startsWith(`${name}/`));
5424
5445
  }
@@ -5443,6 +5464,10 @@ var schemasLocal = defineRule47({
5443
5464
  const filename = context.filename.replaceAll("\\", "/");
5444
5465
  constructors = new Set(options.namespaces);
5445
5466
  packages = new Set(options.packages);
5467
+ calls = new Set(options.calls ?? schemasLocalCalls);
5468
+ pending = [];
5469
+ schemaUses = new Set;
5470
+ callUses = new Set;
5446
5471
  importMessage = options.importMessage ?? options.message ?? schemasLocalImportMessage;
5447
5472
  constructionMessage = options.constructionMessage ?? options.message ?? schemasLocalConstructionMessage;
5448
5473
  if ((options.allow ?? []).some((suffix) => filename.endsWith(suffix)) || (options.patterns ?? []).some((pattern) => new RegExp(pattern).test(filename))) {
@@ -5460,7 +5485,27 @@ var schemasLocal = defineRule47({
5460
5485
  for (const name of locals) {
5461
5486
  constructors.add(name);
5462
5487
  }
5463
- 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
+ }
5464
5509
  },
5465
5510
  ExportNamedDeclaration: checkReexport,
5466
5511
  ExportAllDeclaration: checkReexport,
@@ -5469,7 +5514,7 @@ var schemasLocal = defineRule47({
5469
5514
  while (root.type === "MemberExpression") {
5470
5515
  root = root.object;
5471
5516
  }
5472
- 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)) {
5473
5518
  return;
5474
5519
  }
5475
5520
  context.report({
@@ -1,4 +1,3 @@
1
1
  import type * as PresetTypes from '../types/preset.types.ts';
2
- export declare function presetManifest(root: string, directory: string): PresetTypes.PresetManifest;
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;
4
- export declare function presetStylesheet(root: string, directory: string): string | undefined;
@@ -0,0 +1 @@
1
+ export declare function presetStylesheet(root: string, directory: string): string | undefined;
@@ -0,0 +1,2 @@
1
+ import type * as PresetTypes from '../types/preset.types.ts';
2
+ export declare function presetUnits(root: string, scopes: PresetTypes.PresetScope[], ignored: Set<string>): PresetTypes.PresetUnit[];
@@ -41,6 +41,8 @@ export declare const presetExportPrefix: {
41
41
  };
42
42
  export declare const presetGeneratedFiles: string[];
43
43
  export declare const presetBuiltinFileRoles: string[];
44
+ export declare const presetShorthandOptions: readonly [readonly ['tests', 'mocking', 'preset'], readonly ['backend', 'registry', 'file']];
45
+ export declare const presetOutputFolders: string[];
44
46
  export declare const presetLiteralSegment: RegExp;
45
47
  export declare const presetIllustrationPalette: string[];
46
48
  export declare const presetGroupOrder: PresetTypes.PresetGroupName[];