oxlint-plugin-arch 0.13.0 → 0.13.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.
Files changed (38) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/README.md +7 -7
  3. package/dist/index.js +2154 -504
  4. package/dist/preset/groups/top.d.ts +2 -0
  5. package/dist/preset/preset-sets.d.ts +2 -0
  6. package/dist/preset/preset.constants.d.ts +4 -1
  7. package/dist/preset/preset.js +299 -57
  8. package/dist/preset/preset.utils.d.ts +2 -0
  9. package/dist/rules/code/code-inside.d.ts +1 -0
  10. package/dist/rules/effect/effect-layer-keys.d.ts +1 -0
  11. package/dist/rules/effect/effect-service-params.d.ts +1 -0
  12. package/dist/rules/effect/effect-yield-name.d.ts +1 -0
  13. package/dist/rules/effect/effect-yields-first.d.ts +1 -0
  14. package/dist/rules/files/files-siblings.d.ts +1 -0
  15. package/dist/rules/files/files-top-level.d.ts +1 -0
  16. package/dist/rules/layout/layout-type-arguments.d.ts +1 -0
  17. package/dist/rules/naming/naming-imports.d.ts +1 -0
  18. package/dist/rules/naming/naming-keys.d.ts +1 -0
  19. package/dist/rules/naming/naming-labels.d.ts +1 -0
  20. package/dist/rules/naming/naming-pairs.d.ts +1 -0
  21. package/dist/types/boundaries.types.d.ts +33 -1
  22. package/dist/types/code.types.d.ts +12 -0
  23. package/dist/types/comments.types.d.ts +1 -0
  24. package/dist/types/dillon-anti-slop.types.d.ts +6 -0
  25. package/dist/types/effect.types.d.ts +21 -0
  26. package/dist/types/files.types.d.ts +33 -3
  27. package/dist/types/layout.types.d.ts +16 -0
  28. package/dist/types/naming.types.d.ts +74 -2
  29. package/dist/types/preset.types.d.ts +26 -0
  30. package/dist/types/react.types.d.ts +13 -0
  31. package/dist/types/types.types.d.ts +4 -0
  32. package/dist/utils/ast.utils.d.ts +1 -2
  33. package/dist/utils/boundaries.utils.d.ts +72 -4
  34. package/dist/utils/naming.utils.d.ts +4 -1
  35. package/dist/utils/options.utils.d.ts +2 -0
  36. package/dist/vendor/dillon-anti-slop/index.js +40 -15
  37. package/package.json +1 -1
  38. package/src/vendor/dillon-anti-slop/UPSTREAM.md +3 -0
@@ -2,7 +2,7 @@ import { createRequire } from "node:module";
2
2
  var __require = /* @__PURE__ */ createRequire(import.meta.url);
3
3
 
4
4
  // src/preset/preset.ts
5
- import { globSync } from "node:fs";
5
+ import { globSync as globSync2 } from "node:fs";
6
6
  import { createRequire as createRequire3 } from "node:module";
7
7
  import { matchesGlob as matchesGlob2, posix as posix9 } from "node:path";
8
8
 
@@ -399,6 +399,7 @@ var presetGroupOrder = [
399
399
  "kit",
400
400
  "limits",
401
401
  "deny",
402
+ "top",
402
403
  "externals"
403
404
  ];
404
405
  var presetDefaultGroups = [
@@ -412,6 +413,7 @@ var presetDefaultGroups = [
412
413
  "imports",
413
414
  "tests",
414
415
  "deny",
416
+ "top",
415
417
  "externals"
416
418
  ];
417
419
  var presetExternals = [
@@ -432,7 +434,7 @@ var presetGroupOptions = {
432
434
  limits: { level: "error", lines: 400, types: 1000, complexity: 32 },
433
435
  style: { level: "error", quotes: "single", semicolons: true, multiline: true, padding: true, dashes: true },
434
436
  basics: { level: "error", trivial: true, state: true, switch: true, negation: true },
435
- naming: { level: "error", files: true, exports: true, folders: true, unique: undefined },
437
+ naming: { level: "error", files: true, exports: true, folders: true, unique: undefined, imports: {} },
436
438
  comments: { level: "error", directives: true },
437
439
  types: { level: "error", placement: true, safety: true, colocated: [] },
438
440
  imports: { level: "error", sort: true, boundaries: true, aliases: {}, internal: undefined },
@@ -449,9 +451,10 @@ var presetGroupOptions = {
449
451
  schemas: { level: "error" },
450
452
  forms: { level: "error" },
451
453
  sql: { level: "error" },
452
- effect: { level: "error", code: true, width: 140, exports: undefined, skip: undefined, entries: [] },
454
+ effect: { level: "error", code: true, width: 140, exports: undefined, skip: undefined, entries: [], files: [], strict: true },
453
455
  console: { level: "error" },
454
- deny: { level: "error", imports: {}, calls: {} },
456
+ deny: { level: "error", imports: {}, calls: {}, only: [] },
457
+ top: { level: "error", allow: undefined, deny: undefined, message: undefined, stem: undefined, singularize: undefined, roles: undefined, separators: undefined, file: undefined, placeholder: undefined },
455
458
  externals: { level: "error" }
456
459
  };
457
460
  var presetQueryWrites = ["insertInto", "updateTable", "deleteFrom", "mergeInto", "replaceInto", "dispatch", "send"];
@@ -511,10 +514,13 @@ var presetEffectImports = {
511
514
  "ts-pattern": "Use effect/Match.",
512
515
  dayjs: "Use effect/DateTime."
513
516
  };
517
+ var presetEffectServerImports = {
518
+ ai: "Use effect/ai.",
519
+ "@ai-sdk/**": "Use effect/ai."
520
+ };
514
521
  var presetRecipeMark = Symbol("recipe");
515
522
  var presetDetectBackend = ["@orpc/server", "hono", "kysely", "drizzle-orm", "express", "fastify", "elysia"];
516
523
  var presetDetectSkip = ["node_modules", "dist", "build", "coverage", "public", ".output", ".next", ".vinext", ".turbo", ".wrangler", "target"];
517
- var presetStylesheetNames = ["styles", "globals", "global", "app", "index", "main", "style", "tailwind"];
518
524
  var presetRegistryFiles = ["core/core.services.ts", "service.ts"];
519
525
  var presetRegistrySkip = ["brand", "database", "db", "math", "security", "tools"];
520
526
  var presetServerEntries = ["rpc", "server", "security", "services"];
@@ -588,7 +594,7 @@ var presetTemplates = {
588
594
  };
589
595
 
590
596
  // src/preset/preset.utils.ts
591
- import { existsSync, readFileSync, statSync } from "node:fs";
597
+ import { existsSync, globSync, readFileSync, statSync } from "node:fs";
592
598
  import { createRequire as createRequire2 } from "node:module";
593
599
  import { posix, resolve } from "node:path";
594
600
  class PresetError extends Error {
@@ -703,6 +709,23 @@ function presetIntersect(glob, scope) {
703
709
  function presetGlobEscape(path) {
704
710
  return path.replaceAll(/[[\]]/g, (bracket) => `[${bracket}]`);
705
711
  }
712
+ function presetFileOverlaps(root, build, keys) {
713
+ const matched = new Map;
714
+ for (const key of keys) {
715
+ for (const path of globSync(key.pattern, { cwd: root, exclude: (name) => posix.basename(name) === "node_modules" })) {
716
+ matched.set(path, [...matched.get(path) ?? [], key]);
717
+ }
718
+ }
719
+ const reach = new Map(keys.map((key) => [key, [...matched.values()].filter((found) => found.includes(key)).length]));
720
+ const merged = [...matched].flatMap(([path, found]) => {
721
+ if (found.length < 2) {
722
+ return [];
723
+ }
724
+ const ordered = found.toSorted((left, right) => (reach.get(right) ?? 0) - (reach.get(left) ?? 0));
725
+ return [build(presetGlobEscape(path), ordered.reduce((folder, key) => Object(presetMerge(folder, key.folder)), {}))];
726
+ });
727
+ return [...keys, ...merged];
728
+ }
706
729
  function presetAliases(root, aliases) {
707
730
  return Object.fromEntries(Object.entries(aliases ?? {}).map(([alias, path]) => [alias.replace(/\/$/, ""), resolve(root, path)]));
708
731
  }
@@ -769,7 +792,8 @@ function presetSource(root, directory, configured) {
769
792
  }
770
793
  function presetFilled(conventions, exists) {
771
794
  return Object.fromEntries(Object.entries(conventions).filter(([path, folder]) => {
772
- const empty = folder instanceof Object && folder.deny instanceof Object && Object.keys(folder.deny.imports ?? {}).length === 0;
795
+ const denied = folder instanceof Object && folder.deny instanceof Object ? { ...folder.deny.imports, ...folder.deny.calls } : undefined;
796
+ const empty = denied !== undefined && Object.keys(denied).length === 0;
773
797
  return exists(path) && !empty;
774
798
  }));
775
799
  }
@@ -793,8 +817,27 @@ function presetMerge(base, top) {
793
817
  const merged = presetMergeObject(base, top, 2);
794
818
  const before = base.layout instanceof Object ? base.layout.files : undefined;
795
819
  const after = top.layout instanceof Object ? top.layout.files : undefined;
820
+ const owned = [base.deny, top.deny].flatMap((deny) => deny instanceof Object ? deny.only ?? [] : []);
821
+ if (owned.length > 0 && merged.deny instanceof Object) {
822
+ merged.deny = { ...merged.deny, only: owned };
823
+ }
796
824
  return before && after && merged.layout instanceof Object ? { ...merged, layout: { ...merged.layout, files: [...before, ...after] } } : merged;
797
825
  }
826
+ function presetNames(inherited, entries, option) {
827
+ if (!Array.isArray(entries) || !entries.every((entry) => typeof entry === "string" && presetLayoutFileName.test(entry))) {
828
+ throw new PresetError(`${option} takes file names such as \`*.prompts.ts\`; \`!*.utils.ts\` removes one.`);
829
+ }
830
+ const names = [...inherited];
831
+ for (const entry of entries) {
832
+ const index = names.indexOf(entry.startsWith("!") ? entry.slice(1) : entry);
833
+ if (entry.startsWith("!") && index !== -1) {
834
+ names.splice(index, 1);
835
+ } else if (!entry.startsWith("!") && index === -1) {
836
+ names.push(entry);
837
+ }
838
+ }
839
+ return names;
840
+ }
798
841
 
799
842
  // src/preset/preset-effect.ts
800
843
  var presetEffectQueryWrites = Object.fromEntries(presetQueryWrites.map((name) => [name, "A queries file only reads; write in an actions file."]));
@@ -802,8 +845,11 @@ var presetEffectWorkflowCalls = Object.fromEntries(["selectFrom", ...presetQuery
802
845
  var presetEffectFrameworks = Object.fromEntries(["@tanstack/*", "react-dom", "react-dom/**", "next", "next/**", "vite"].map((name) => [name, "The server imports no web framework."]));
803
846
  var presetEffectRouters = ["@tanstack/react-router", "next/link", "next/navigation", "next/router", "expo-router"];
804
847
  var presetEffectErrors = ["Schema.TaggedError", "Schema.ErrorClass", "Data.TaggedError", "Data.Error"];
805
- var presetEffectContractNames = "^[a-z0-9]+(?:-[a-z0-9]+)*(?:[.]schemas|[.]admin[.]schemas|[.]contract|[.]admin[.]contract|[.]errors|[.]layer[.]types)[.]ts$";
806
- var presetEffectContractMessage = "Contract files use <d>.schemas.ts, <d>-<part>.schemas.ts, <d>.admin.schemas.ts, <d>.contract.ts, <d>.admin.contract.ts, <d>.errors.ts or <d>.layer.types.ts.";
848
+ var presetEffectContractKinds = ["*.schemas.ts", "*.admin.schemas.ts", "*.contract.ts", "*.admin.contract.ts", "*.errors.ts", "*.layer.types.ts", "*.service.ts"];
849
+ var presetEffectSpans = [
850
+ "error",
851
+ { labels: [{ callee: "Effect.fn", name: "{name}", drop: ["Action", "Query"], loose: true }] }
852
+ ];
807
853
  var presetEffectSchemaUses = {
808
854
  namespaces: ["Schema"],
809
855
  methods: ["Array", "Union", "Record", "Tuple"],
@@ -848,7 +894,7 @@ function presetEffectServerKeys(at) {
848
894
  rules: {
849
895
  "arch/types-declarations": ["error", { modules: true }],
850
896
  "arch/types-inline": ["error", { variables: true, assertions: true, arguments: true }],
851
- "arch/comments-code": ["error", { why: false, jsdoc: true, allow: ["^\\s*SAFETY: "] }]
897
+ "arch/comments-code": ["error", { why: false, jsdoc: true, allow: ["^\\s*SAFETY: "], steps: true }]
852
898
  }
853
899
  },
854
900
  [at("domains")]: {
@@ -876,17 +922,18 @@ function presetEffectServerKeys(at) {
876
922
  },
877
923
  [at("domains/*/*.actions.ts")]: {
878
924
  limits: { lines: 1000 },
879
- rules: { "arch/files-exports": ["error", { stem: "folder", patterns: ["{domain}Action[A-Z]\\w*"], types: true }] }
925
+ rules: { "arch/files-exports": ["error", { stem: "folder", patterns: ["{domain}Action[A-Z]\\w*"], types: true }], "arch/naming-labels": presetEffectSpans }
880
926
  },
881
927
  [at("domains/*/*.queries.ts")]: {
882
928
  limits: { lines: 1000 },
883
929
  deny: { calls: presetEffectQueryWrites, imports: { "./*.actions{,.ts}": "A queries file imports no actions." } },
884
- rules: { "arch/files-exports": ["error", { stem: "folder", patterns: ["{domain}Query[A-Z]\\w*"], types: true }] }
930
+ rules: { "arch/files-exports": ["error", { stem: "folder", patterns: ["{domain}Query[A-Z]\\w*"], types: true }], "arch/naming-labels": presetEffectSpans }
885
931
  },
886
932
  [at("domains/*/*.constants.ts")]: { rules: { "arch/files-no-functions": ["error", { reexports: false }] } },
887
- [at("domains/*/*.layer.ts")]: { rules: { "arch/effect-layer-file": "error" } },
888
- [at("domains/*/*.driver.ts")]: { rules: { "arch/effect-layer-file": "error" } },
933
+ [at("domains/*/*.layer.ts")]: { rules: { "arch/effect-layer-file": ["error", { suffix: "Layer" }] } },
934
+ [at("domains/*/*.driver.ts")]: { rules: { "arch/effect-layer-file": ["error", { suffix: "Layer" }] } },
889
935
  [at("domains/*/*.workflows.ts")]: {
936
+ rules: { "arch/naming-labels": ["error", { labels: [{ callee: "Workflow.make", name: "{name}", drop: "Workflow$" }] }] },
890
937
  deny: {
891
938
  calls: presetEffectWorkflowCalls,
892
939
  imports: {
@@ -905,7 +952,14 @@ function presetEffectServerKeys(at) {
905
952
  [at("rpc")]: { rules: { "arch/code-delegate": "error" } }
906
953
  };
907
954
  }
908
- function presetEffectContractKeys(at) {
955
+ function presetEffectContractKeys(at, files) {
956
+ const kinds = presetNames(presetEffectContractKinds, files ?? [], "effect.files");
957
+ if (kinds.some((kind) => !/^\*\.[a-z0-9.-]+\.ts$/.test(kind))) {
958
+ throw new PresetError("effect.files takes contract file kinds such as `*.service.ts`.");
959
+ }
960
+ const pattern = `^[a-z0-9]+(?:-[a-z0-9]+)*(?:${kinds.map((kind) => kind.slice(1, -3).replaceAll(".", "[.]")).join("|")})[.]ts$`;
961
+ const listed = kinds.flatMap((kind) => kind === "*.schemas.ts" ? ["<d>.schemas.ts", "<d>-<part>.schemas.ts"] : [`<d>${kind.slice(1)}`]);
962
+ const message = `Contract files use ${listed.slice(0, -1).join(", ")} or ${listed.at(-1)}.`;
909
963
  const schemas = {
910
964
  rules: {
911
965
  "arch/files-constants-only": ["error", { types: true, functions: true }],
@@ -918,7 +972,7 @@ function presetEffectContractKeys(at) {
918
972
  return {
919
973
  [at("*/*.ts")]: {
920
974
  rules: {
921
- "arch/naming-file": ["error", { pattern: presetEffectContractNames, message: presetEffectContractMessage }],
975
+ "arch/naming-file": ["error", { pattern, message }],
922
976
  "arch/naming-folder": ["error", { singularize: "none", separators: [".", "-"] }],
923
977
  "arch/files-exports": ["error", { stem: "folder", singularize: "trailing-s", patterns: ["{Domain}\\w*"] }]
924
978
  }
@@ -928,6 +982,7 @@ function presetEffectContractKeys(at) {
928
982
  [at("*/*.contract.ts")]: schemas,
929
983
  [at("*/*.errors.ts")]: noFunctions,
930
984
  [at("*/*.layer.types.ts")]: noFunctions,
985
+ [at("*/*.service.ts")]: noFunctions,
931
986
  [at("shared/*.ts")]: {
932
987
  rules: {
933
988
  "arch/naming-file": "off",
@@ -946,19 +1001,116 @@ function presetEffectContractKeys(at) {
946
1001
  }
947
1002
  };
948
1003
  }
949
- function presetEffectUnit(root, key, value) {
1004
+ function presetEffectTop(allow) {
1005
+ return { top: { allow, stem: "folder" } };
1006
+ }
1007
+ function presetEffectStrictServer(at, service) {
1008
+ const operations = presetEffectTop([{ kind: "const", exported: true, from: "^Effect\\.fn" }, { kind: "const", names: "^decode[A-Z]" }]);
1009
+ const layerKeys = service === undefined ? {} : { "arch/effect-layer-keys": ["error", { service }] };
1010
+ return {
1011
+ [at(".")]: {
1012
+ rules: {
1013
+ "arch/effect-yield-name": "error",
1014
+ "arch/effect-yields-first": "error",
1015
+ "arch/code-inside": ["error", { inside: [{ callee: "*.transaction", calls: { "*Workflow.execute": "Start the workflow after the commit." } }] }]
1016
+ }
1017
+ },
1018
+ [at("domains")]: { naming: { imports: { "./{file}.ts": "{File}" } } },
1019
+ [at("domains/*/*.actions.ts")]: operations,
1020
+ [at("domains/*/*.queries.ts")]: operations,
1021
+ [at("domains/*/*.utils.ts")]: {
1022
+ ...operations,
1023
+ rules: { "arch/naming-labels": ["error", { labels: [{ callee: "Effect.fn", name: "{name}", loose: true }] }] }
1024
+ },
1025
+ [at("domains/*/*.layer.ts")]: {
1026
+ rules: { "arch/naming-keys": ["error", { callees: ["*.bind", "*.of"], name: "^{folder}(?:Action|Query){Path}$", inline: false }], ...layerKeys }
1027
+ },
1028
+ [at("domains/*/*.workflows.ts")]: {
1029
+ ...presetEffectTop([{ kind: "const", from: "(?:^Workflow\\.make|\\.toLayer|^Layer\\.\\w+|^Schedule\\.\\w+)$" }]),
1030
+ rules: {
1031
+ "arch/files-exports": ["error", { stem: "folder", patterns: ["{Domain}\\w*Workflow", "{Domain}WorkflowsLayer", "{domain}Schedule[A-Z]\\w*"] }],
1032
+ "arch/naming-labels": [
1033
+ "error",
1034
+ {
1035
+ labels: [
1036
+ { callee: "Workflow.make", name: "{name}", drop: "Workflow$" },
1037
+ { callee: "Effect.fn", name: "{name}", within: "*.toLayer", loose: true }
1038
+ ]
1039
+ }
1040
+ ]
1041
+ }
1042
+ },
1043
+ [at("domains/*/*.constants.ts")]: presetEffectTop([{ kind: "const", exported: true, names: "^{DOMAIN}_" }]),
1044
+ [at("domains/*/*.config.ts")]: presetEffectTop([{ kind: "const", exported: true, names: "^{domain}\\w*Config$" }]),
1045
+ [at("domains/*/*.prompts.ts")]: { rules: { "arch/files-exports": ["error", { stem: "folder", patterns: ["{domain}Prompt[A-Z]\\w*"], types: true }] } },
1046
+ [at("rpc/*/*.ts")]: {
1047
+ ...presetEffectTop([{ kind: "const", exported: true }]),
1048
+ rules: {
1049
+ "arch/naming-file": ["error", { template: "{group}/rpc.{group}.{area}.ts", message: "Handlers use rpc/<group>/rpc.<group>.<area>.ts." }],
1050
+ "arch/files-exports": ["error", { file: "rpc.{group}.{area}.ts", names: ["rpc{Group}{Area}"] }],
1051
+ "arch/naming-keys": ["error", { exports: "^rpc[A-Z]", delegate: true }]
1052
+ }
1053
+ },
1054
+ [at("mails/*.mail.tsx")]: { rules: { "arch/files-exports": ["error", { file: "{name}.mail.tsx", names: ["{name}Mail", "{Name}Email"], types: true }] } }
1055
+ };
1056
+ }
1057
+ function presetEffectStrictContract(at, name, files) {
1058
+ const key = name === undefined ? [] : [{ callee: "Context.Service", name: `${name}/{folder}/{file}/{name}` }];
1059
+ const labels = key.length > 0 ? { "arch/naming-labels": ["error", { labels: key }] } : {};
1060
+ const service = presetNames(presetEffectContractKinds, files ?? [], "effect.files").includes("*.service.ts");
1061
+ const sibling = service ? { "arch/files-siblings": ["error", { require: "{folder}.service.ts", message: "A domain is reached through its service." }] } : {};
1062
+ return {
1063
+ [at("*/*.schemas.ts")]: presetEffectTop(["type", "interface", "namespace", { kind: "const", exported: true }]),
1064
+ [at("*/*.contract.ts")]: {
1065
+ ...presetEffectTop(["type", { kind: "const", exported: true }, { kind: "const", exported: false, from: "^[A-Z]\\w*\\." }]),
1066
+ deny: { calls: { "Schema.Struct": "Name the Schema in <d>.schemas.ts." } },
1067
+ rules: sibling
1068
+ },
1069
+ [at("*/*.service.ts")]: {
1070
+ ...presetEffectTop(["interface", "type", "class", "namespace"]),
1071
+ rules: { ...labels, "arch/effect-service-params": "error" }
1072
+ },
1073
+ [at("*/*.errors.ts")]: {
1074
+ ...presetEffectTop([{ kind: "class", exported: true }]),
1075
+ rules: {
1076
+ "arch/naming-labels": [
1077
+ "error",
1078
+ {
1079
+ labels: [
1080
+ { callee: ["Schema.TaggedErrorClass", "Schema.TaggedError", "Data.TaggedError"], name: "{name}" },
1081
+ { callee: "*.Tagged", property: "tag", name: "{name}" },
1082
+ { callee: "*.Tagged", property: "code", pattern: "^[A-Z][A-Z0-9_]*$" }
1083
+ ]
1084
+ }
1085
+ ]
1086
+ }
1087
+ }
1088
+ };
1089
+ }
1090
+ var presetEffectStrictTests = { top: { allow: ["describe*", "it*", "test*", "layer*"] } };
1091
+ function presetEffectUnit(root, key, value, contract) {
950
1092
  if (!value.effect) {
951
1093
  return {};
952
1094
  }
953
1095
  const at = presetEffectAt(root, key, value);
954
- const contract = presetEffectContract(root, key, value);
1096
+ const own = presetEffectContract(root, key, value);
955
1097
  const server = presetEffectDomains(root, key, value);
956
- const keys = server || contract ? presetFilled(server ? presetEffectServerKeys(at) : presetEffectContractKeys(at), presetEffectExists(root, key)) : {};
1098
+ const options = value.effect instanceof Object ? value.effect : {};
1099
+ const service = contract && `${posix2.join(root, contract.source)}/{folder}/{folder}.service.ts`;
1100
+ const base = server ? presetEffectServerKeys(at) : presetEffectContractKeys(at, options.files);
1101
+ const tests = { "tests/**/*.test.ts": presetEffectStrictTests, "test/**/*.test.ts": presetEffectStrictTests };
1102
+ const strict = server ? { ...presetEffectStrictServer(at, service), ...tests } : presetEffectStrictContract(at, presetManifest(root, key).name, options.files);
1103
+ const locked = options.strict === false ? {} : strict;
1104
+ const paths = [...new Set([...Object.keys(base), ...Object.keys(locked)])];
1105
+ const merged = (path) => (path in base) && (path in locked) ? presetMerge(base[path], locked[path]) : base[path] ?? locked[path];
1106
+ const conventions = Object.fromEntries(paths.map((path) => [path, merged(path)]));
1107
+ const keys = server || own ? presetFilled(conventions, presetEffectExists(root, key)) : {};
957
1108
  const errors = Object.fromEntries(presetEffectErrors.map((name) => [name, "Tagged errors live in <d>.errors.ts."]));
958
- const placement = server || contract ? { types: { placement: false } } : {};
1109
+ const placement = server || own ? { types: { placement: false } } : {};
1110
+ const imports = server || own ? { ...presetEffectImports, ...presetEffectServerImports } : presetEffectImports;
959
1111
  return {
960
1112
  ...keys,
961
- ".": presetMerge({ ...placement, deny: { imports: presetEffectImports, calls: errors } }, keys["."] ?? {}),
1113
+ ".": presetMerge({ ...placement, deny: { imports, calls: errors } }, keys["."] ?? {}),
962
1114
  [at("**/*.errors.ts")]: { deny: { calls: Object.fromEntries(presetEffectErrors.map((name) => [name, false])) } }
963
1115
  };
964
1116
  }
@@ -1015,9 +1167,13 @@ function presetEffectKit(root, key, value, units) {
1015
1167
  return { ".": { deny: { imports: Object.fromEntries(names.flatMap((name) => [[name, message], [`${name}/**`, message]])) } } };
1016
1168
  }
1017
1169
  function presetEffectConventions(root, key, value, units) {
1018
- const contracts = units.flatMap(([other, folder]) => presetEffectContract(root, other, folder) ? presetManifest(root, other).name ?? [] : []);
1170
+ const packages = units.flatMap(([other, folder]) => {
1171
+ const name = presetEffectContract(root, other, folder) ? presetManifest(root, other).name : undefined;
1172
+ return name === undefined ? [] : [{ name, source: presetSource(root, other, folder instanceof Object ? folder.source : undefined) }];
1173
+ });
1174
+ const contracts = packages.map((item) => item.name);
1019
1175
  return [
1020
- ...Object.entries(presetEffectUnit(root, key, value)),
1176
+ ...Object.entries(presetEffectUnit(root, key, value, packages.at(0))),
1021
1177
  ...Object.entries(presetEffectFrontend(root, key, value)),
1022
1178
  ...Object.entries(presetEffectWeb(root, key, value, contracts)),
1023
1179
  ...Object.entries(presetEffectKit(root, key, value, units))
@@ -1422,8 +1578,9 @@ var denyGroup = (project, settings) => {
1422
1578
  const imports = kept(settings.imports);
1423
1579
  const calls = kept(settings.calls);
1424
1580
  const overrides = [];
1425
- if (Object.keys(imports).length > 0 || Object.keys(calls).length > 0) {
1426
- overrides.push(presetOverride([`**/${presetExtensions}`], { "arch/boundaries-deny": ["error", { imports, calls }] }));
1581
+ const only = settings.only.length > 0 ? { only: settings.only } : {};
1582
+ if (Object.keys(imports).length > 0 || Object.keys(calls).length > 0 || settings.only.length > 0) {
1583
+ overrides.push(presetOverride([`**/${presetExtensions}`], { "arch/boundaries-deny": ["error", { imports, calls, ...only }] }));
1427
1584
  }
1428
1585
  const reads = Object.fromEntries(presetQueryWrites.map((name) => [name, "Query files only read; move writes to an action."]));
1429
1586
  for (const unit of project.units.filter((item) => item.kind === "backend")) {
@@ -1431,7 +1588,7 @@ var denyGroup = (project, settings) => {
1431
1588
  ...presetServiceScopes(project.serviceFolders, [unit.source], "**/*-query.*.ts"),
1432
1589
  ...presetServiceScopes(project.serviceFolders, project.operations.filter((path) => path === unit.source), "*/queries/*.ts")
1433
1590
  ];
1434
- overrides.push(presetOverride(files, { "arch/boundaries-deny": ["error", { imports, calls: kept({ ...reads, ...settings.calls }) }] }));
1591
+ overrides.push(presetOverride(files, { "arch/boundaries-deny": ["error", { imports, calls: kept({ ...reads, ...settings.calls }), ...only }] }));
1435
1592
  }
1436
1593
  return overrides;
1437
1594
  };
@@ -1500,17 +1657,19 @@ var effectGroup = (project, settings) => {
1500
1657
  "arch/code-switch": ["error", { message: "Branch with Match." }],
1501
1658
  "arch/naming-exports": "off",
1502
1659
  "arch/code-function-nesting": ["error", { max: 4 }],
1503
- "arch/code-inline": ["error", { max, nesting: 2, keep: ["^[A-Z]", "^layer"], allow: "^(?:decode|encode)[A-Z]", services: ["of"] }],
1660
+ "arch/code-inline": ["error", { max, nesting: 2, callbacks: true, keep: ["^[A-Z]", "^layer"], allow: "^(?:decode|encode)[A-Z]", services: ["of"] }],
1504
1661
  "arch/code-yield-argument": "error",
1505
1662
  "arch/effect-layers": "error",
1506
1663
  "arch/effect-merge-pipe": "error",
1507
1664
  "arch/effect-values-first": "error",
1508
1665
  "arch/layout-arrow-body": ["error", { max }],
1509
- "arch/layout-call-arguments": ["error", { split: false, hug: { max }, join: { max, nesting: 2, keep: ["Layer.mergeAll"] } }],
1510
- "arch/layout-call-depth": ["error", { max: 2 }],
1666
+ "arch/layout-call-arguments": ["error", { split: false, pipe: { min: 2 }, hug: { max }, join: { max, nesting: 2, keep: ["Layer.mergeAll"] } }],
1667
+ "arch/layout-call-depth": ["error", { max: 2, callbacks: true }],
1511
1668
  "arch/layout-items": "error",
1669
+ "arch/layout-object": ["error", { min: 3, scope: "call-args", params: true, nested: true, siblings: true, inner: 2 }],
1512
1670
  "arch/layout-pipe": "error",
1513
- "arch/layout-ternary": ["error", { fit: false, branch: { max: 3 } }]
1671
+ "arch/layout-ternary": ["error", { max: 100, branch: { max: 3 } }],
1672
+ "arch/layout-type-arguments": ["error", { min: 3 }]
1514
1673
  }),
1515
1674
  presetOverride(files, {
1516
1675
  "arch/code-justified-call": [
@@ -1845,6 +2004,10 @@ var namingGroup = (project, settings, owner) => {
1845
2004
  }
1846
2005
  }
1847
2006
  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]);
2007
+ const imports = Object.entries(settings.imports).flatMap(([source, name]) => name === false ? [] : [{ source, name }]);
2008
+ if (imports.length > 0) {
2009
+ overrides.push(presetOverride([`**/${presetExtensions}`], { "arch/naming-imports": ["error", { imports }] }));
2010
+ }
1848
2011
  if (typeof exportsPattern === "string") {
1849
2012
  overrides.push(presetOverride([`**/${presetExtensions}`], { "arch/naming-exports": ["error", { mode: "pattern", pattern: exportsPattern, types: true }] }));
1850
2013
  }
@@ -1978,7 +2141,10 @@ var shadcnGroup = (project) => {
1978
2141
 
1979
2142
  // src/preset/groups/sql.ts
1980
2143
  var sqlGroup = (project) => [
1981
- presetOverride(presetServiceScopes(project.serviceFolders, project.serviceRoots, "**/*.ts"), { "arch/backend-sql-like": "error" })
2144
+ presetOverride(presetServiceScopes(project.serviceFolders, project.serviceRoots, "**/*.ts"), {
2145
+ "arch/backend-sql-like": "error",
2146
+ "arch/layout-chain": ["error", { groups: [{ min: 3, methods: ["selectFrom", "insertInto", "updateTable", "deleteFrom", "mergeInto", "with"] }] }]
2147
+ })
1982
2148
  ];
1983
2149
 
1984
2150
  // src/preset/groups/style.ts
@@ -2079,6 +2245,12 @@ function optionsAssertMockingApis(patterns) {
2079
2245
  throw new OptionsError("Test mocking APIs must be `*` or `<vi|jest|*>.<method|*>`.");
2080
2246
  }
2081
2247
  }
2248
+ function optionsEscape(value) {
2249
+ return value.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&");
2250
+ }
2251
+ function optionsWildcards(names) {
2252
+ return [names ?? []].flat().map((name) => new RegExp(`^${name.split("*").map(optionsEscape).join(".*")}$`));
2253
+ }
2082
2254
 
2083
2255
  // src/preset/groups/tests.ts
2084
2256
  var testsGroup = (project, settings) => {
@@ -2125,6 +2297,16 @@ var testsGroup = (project, settings) => {
2125
2297
  ];
2126
2298
  };
2127
2299
 
2300
+ // src/preset/groups/top.ts
2301
+ var topGroup = (_project, { level: _level, allow, ...rest }) => {
2302
+ if (allow === undefined && rest.deny === undefined) {
2303
+ return [];
2304
+ }
2305
+ const listed = allow === undefined || allow.includes("import") ? allow : ["import", ...allow];
2306
+ const options = Object.fromEntries(Object.entries({ allow: listed, ...rest }).filter(([, value]) => value !== undefined));
2307
+ return [presetOverride([`**/${presetExtensions}`], { "arch/files-top-level": ["error", options] })];
2308
+ };
2309
+
2128
2310
  // src/preset/groups/types.ts
2129
2311
  var typesGroup = (project, settings) => {
2130
2312
  const { web, backend, packageSources, app, appFiles, services, utilities, types, ui, testFiles, components, hooks } = project;
@@ -2185,9 +2367,69 @@ var typesGroup = (project, settings) => {
2185
2367
  import { existsSync as existsSync5, readdirSync as readdirSync5, realpathSync as realpathSync2 } from "node:fs";
2186
2368
  import { posix as posix8, resolve as resolve8 } from "node:path";
2187
2369
 
2370
+ // src/preset/preset-sets.ts
2371
+ var presetSetsReserved = ["domain", "stem", "file", "folder", "parent", "name", "key", "value"];
2372
+ function presetSets(folders, sets = {}) {
2373
+ const names = Object.keys(sets);
2374
+ const reserved = names.find((name) => presetSetsReserved.includes(name) || !/^[a-z][A-Za-z0-9]*$/.test(name));
2375
+ if (reserved) {
2376
+ throw new PresetError(`Set \`${reserved}\` needs another name: start with a lowercase letter, use letters and digits, and avoid ${presetSetsReserved.join(", ")}.`);
2377
+ }
2378
+ if (names.length === 0) {
2379
+ return folders;
2380
+ }
2381
+ const token = new RegExp(`\\{(${names.join("|")})\\}`);
2382
+ const pairs = (name) => {
2383
+ if (!Object.hasOwn(sets, name)) {
2384
+ throw new PresetError(`Unknown set \`${name}\`.`);
2385
+ }
2386
+ const set = sets[name];
2387
+ return Array.isArray(set) ? set.map((item) => [item, item]) : Object.entries(set);
2388
+ };
2389
+ const text = (value, key, item) => value.replaceAll("{key}", key).replaceAll("{value}", item);
2390
+ const fill = (value, key, item) => {
2391
+ if (typeof value === "string") {
2392
+ return text(value, key, item);
2393
+ }
2394
+ if (Array.isArray(value)) {
2395
+ return value.map((entry) => fill(entry, key, item));
2396
+ }
2397
+ return value instanceof Object ? Object.fromEntries(Object.entries(value).map(([name, entry]) => [text(name, key, item), fill(entry, key, item)])) : value;
2398
+ };
2399
+ const spread = (value) => {
2400
+ const match = token.exec(value);
2401
+ return match ? pairs(match[1]).flatMap(([key]) => spread(value.replace(match[0], key))) : [value];
2402
+ };
2403
+ const expandEntries = (value) => Object.entries(value).flatMap(([name, entry]) => {
2404
+ const match = token.exec(name);
2405
+ return match ? pairs(match[1]).flatMap(([key, item]) => expandEntries({ [name.replace(match[0], key)]: fill(entry, key, item) })) : [[name, name === "message" ? entry : expand(entry)]];
2406
+ });
2407
+ const expand = (value) => {
2408
+ if (typeof value === "string") {
2409
+ return token.test(value) ? spread(value) : value;
2410
+ }
2411
+ if (!Array.isArray(value)) {
2412
+ const marks = value instanceof Object ? Object.getOwnPropertySymbols(value).map((mark) => [mark, Reflect.get(value, mark)]) : [];
2413
+ return value instanceof Object ? Object.assign(Object.fromEntries(expandEntries(value)), Object.fromEntries(marks)) : value;
2414
+ }
2415
+ return value.flatMap((entry) => {
2416
+ if (typeof entry === "string") {
2417
+ return spread(entry);
2418
+ }
2419
+ if (!(entry instanceof Object) || Array.isArray(entry) || typeof entry.each !== "string") {
2420
+ return [expand(entry)];
2421
+ }
2422
+ const { each, ...rest } = entry;
2423
+ return pairs(each).map(([key, item]) => expand(fill(rest, key, item)));
2424
+ });
2425
+ };
2426
+ return expand(folders);
2427
+ }
2428
+
2188
2429
  // src/preset/preset-stylesheet.ts
2189
2430
  import { readdirSync as readdirSync3, readFileSync as readFileSync3, realpathSync } from "node:fs";
2190
2431
  import { posix as posix6, resolve as resolve6 } from "node:path";
2432
+ var presetStylesheetNames = ["styles", "globals", "global", "app", "index", "main", "style", "tailwind"];
2191
2433
  function presetTailwindEntry(css) {
2192
2434
  const code = css.replaceAll(/\/\*[\s\S]*?\*\//g, "");
2193
2435
  return /(?:^|[;{}])\s*@(?:import\s+(?:url\(\s*(["']?)tailwindcss(?:\/[^"')\s]*)?\1\s*\)|(["'])tailwindcss(?:\/[^"']*)?\2)|tailwind\s)/.test(code);
@@ -2237,7 +2479,7 @@ function presetUnitSource(root, key, kind, source) {
2237
2479
  }
2238
2480
  return kind === "console" || !existsSync4(resolve7(root, key, "src")) ? key : posix7.join(key, "src");
2239
2481
  }
2240
- function presetSets(folder, name) {
2482
+ function presetSets2(folder, name) {
2241
2483
  return folder[name] !== undefined && folder[name] !== false;
2242
2484
  }
2243
2485
  function presetKind(key, enabled) {
@@ -2273,8 +2515,8 @@ function presetUnit(root, key, scope) {
2273
2515
  const { effective } = scope;
2274
2516
  const wildcard = scope.parent && scope.parent.depth === scope.depth && scope.parent.key.includes("*") ? scope.parent.folder : {};
2275
2517
  const folder = { ...wildcard, ...scope.folder };
2276
- const own = presetKindGroups.some((name) => presetSets(folder, name)) || presetHasManifest(root, key);
2277
- const { kind, framework } = presetKind(key, (name) => own ? presetSets(folder, name) : Boolean(effective[name]));
2518
+ const own = presetKindGroups.some((name) => presetSets2(folder, name)) || presetHasManifest(root, key);
2519
+ const { kind, framework } = presetKind(key, (name) => own ? presetSets2(folder, name) : Boolean(effective[name]));
2278
2520
  const layout = {};
2279
2521
  const settings = effective.layout;
2280
2522
  if (settings) {
@@ -2297,7 +2539,7 @@ function presetUnits(root, scopes, ignored) {
2297
2539
  const candidates = [];
2298
2540
  for (const scope of scopes) {
2299
2541
  const manifest = presetHasManifest(root, scope.key);
2300
- const own = presetKindGroups.some((name) => presetSets(scope.folder, name)) || manifest;
2542
+ const own = presetKindGroups.some((name) => presetSets2(scope.folder, name)) || manifest;
2301
2543
  const top = scope.parent?.key === "." && (rootKind === "library" || own);
2302
2544
  const sibling = scope.parent !== null && candidates.includes(scope.parent) && scope.parent.depth === scope.depth && !scope.key.includes("*");
2303
2545
  const workspace = scope.parent !== null && scope.parent.key !== "." && manifest;
@@ -2404,20 +2646,16 @@ function presetDenyEntries(inherited, changes) {
2404
2646
  return [key, message];
2405
2647
  }));
2406
2648
  }
2407
- function presetLayoutNames(inherited, entries) {
2408
- if (!Array.isArray(entries) || !entries.every((entry) => typeof entry === "string" && presetLayoutFileName.test(entry))) {
2409
- throw new PresetError("layout.files takes file names such as `*.prompts.ts`; `!*.utils.ts` removes one.");
2649
+ function presetDenyOnly(inherited, entries = []) {
2650
+ const each = entries.find((entry) => entry.each !== undefined)?.each;
2651
+ if (each !== undefined) {
2652
+ throw new PresetError(`Unknown set \`${each}\`.`);
2410
2653
  }
2411
- const names = [...inherited];
2412
- for (const entry of entries) {
2413
- const index = names.indexOf(entry.startsWith("!") ? entry.slice(1) : entry);
2414
- if (entry.startsWith("!") && index !== -1) {
2415
- names.splice(index, 1);
2416
- } else if (!entry.startsWith("!") && index === -1) {
2417
- names.push(entry);
2418
- }
2654
+ const known = (entry) => ("in" in entry) && (("imports" in entry) || ("calls" in entry)) && Object.keys(entry).every((key) => ["imports", "calls", "in", "message"].includes(key));
2655
+ if (!Array.isArray(entries) || !entries.every((entry) => entry instanceof Object && known(entry))) {
2656
+ throw new PresetError("deny.only takes entries such as `{ imports: [...], in: [...], message }`, with `imports` or `calls` and the places that may use them.");
2419
2657
  }
2420
- return names;
2658
+ return [...inherited, ...entries];
2421
2659
  }
2422
2660
  function presetEffective(folder, parent) {
2423
2661
  const resolveGroup = (name) => {
@@ -2431,13 +2669,14 @@ function presetEffective(folder, parent) {
2431
2669
  effective.deny = {
2432
2670
  ...effective.deny,
2433
2671
  imports: presetDenyEntries(inherited.imports, deny.imports),
2434
- calls: presetDenyEntries(inherited.calls, deny.calls)
2672
+ calls: presetDenyEntries(inherited.calls, deny.calls),
2673
+ only: presetDenyOnly(inherited.only, deny.only)
2435
2674
  };
2436
2675
  }
2437
2676
  const layout = folder.layout;
2438
2677
  if (effective.layout && layout instanceof Object && layout.files !== undefined) {
2439
2678
  const inherited = (parent ? parent.layout?.files : null) ?? presetGroupOptions.layout.files;
2440
- effective.layout = { ...effective.layout, files: presetLayoutNames(inherited, layout.files) };
2679
+ effective.layout = { ...effective.layout, files: presetNames(inherited, layout.files, "layout.files") };
2441
2680
  }
2442
2681
  return effective;
2443
2682
  }
@@ -2458,7 +2697,7 @@ function presetFolder(key, value) {
2458
2697
  if (folder.severity !== undefined && !["error", "warn"].includes(folder.severity)) {
2459
2698
  throw new PresetError(`Folder \`${key}\` severity must be 'error' or 'warn'.`);
2460
2699
  }
2461
- return folder;
2700
+ return Array.isArray(folder.top) ? { ...folder, top: { allow: folder.top } } : folder;
2462
2701
  }
2463
2702
  function presetScopesTree(root, folders) {
2464
2703
  const entries = Object.entries(folders).map(([key, value]) => [presetNormalizeKey(key), value]);
@@ -2503,7 +2742,7 @@ function presetScopesTree(root, folders) {
2503
2742
  function presetProject(config) {
2504
2743
  const given = resolve8(config.root ?? process.cwd());
2505
2744
  const root = existsSync5(given) ? realpathSync2(given) : given;
2506
- const expanded = Object.entries(config.folders ?? {}).flatMap(([key, value]) => presetBraces(key).map((path) => [presetPortableKey(path), value, key.includes("{")]));
2745
+ const expanded = Object.entries(presetSets(config.folders ?? {}, config.sets)).flatMap(([key, value]) => presetBraces(key).map((path) => [presetPortableKey(path), value, key.includes("{")]));
2507
2746
  const files = expanded.filter(([key]) => presetIsFileKey(root, key));
2508
2747
  const listed = expanded.filter((entry) => !files.includes(entry)).map(([key, value, fromList]) => [presetNormalizeKey(key), value, fromList]);
2509
2748
  const twice = listed.find(([key, , fromList], index) => listed.some(([other, , otherFromList], at) => at < index && other === key && !fromList && !otherFromList));
@@ -2528,7 +2767,11 @@ function presetProject(config) {
2528
2767
  if (dead) {
2529
2768
  throw new PresetError(`Folder \`${dead.key}\` sits inside the skipped folder \`${dead.parent?.key}\`.`);
2530
2769
  }
2531
- const fileKeys = fileEntries.flatMap(([key, value]) => {
2770
+ const keyFor = (pattern, folder) => {
2771
+ const reached = live.filter((scope) => presetIntersect(pattern, scope.key).length > 0).map((scope) => ({ key: scope.key, parent: scope, depth: scope.depth, folder, effective: presetEffective(folder, scope.effective) }));
2772
+ return { pattern, folder, scopes: reached };
2773
+ };
2774
+ const fileKeys = presetFileOverlaps(root, keyFor, fileEntries.flatMap(([key, value]) => {
2532
2775
  if (value === false) {
2533
2776
  return [];
2534
2777
  }
@@ -2540,10 +2783,8 @@ function presetProject(config) {
2540
2783
  if (Object(folder.layout).operations !== undefined) {
2541
2784
  throw new PresetError(`Set layout.operations on an app or package folder, not on \`${key}\`.`);
2542
2785
  }
2543
- const pattern = presetGlobEscape(key);
2544
- const reached = live.filter((scope) => presetIntersect(pattern, scope.key).length > 0).map((scope) => ({ key: scope.key, parent: scope, depth: scope.depth, folder, effective: presetEffective(folder, scope.effective) }));
2545
- return [{ pattern, folder, scopes: reached }];
2546
- });
2786
+ return [keyFor(presetGlobEscape(key), folder)];
2787
+ }));
2547
2788
  const units = presetUnits(root, scopes, ignored);
2548
2789
  const unitKeys = new Set(units.map((unit) => unit.scope.key));
2549
2790
  const misplaced = live.find((scope) => !unitKeys.has(scope.key) && Object(scope.folder.layout).operations !== undefined);
@@ -2710,6 +2951,7 @@ var presetBuilders = {
2710
2951
  shadcn: shadcnGroup,
2711
2952
  limits: limitsGroup,
2712
2953
  deny: denyGroup,
2954
+ top: topGroup,
2713
2955
  externals: externalsGroup
2714
2956
  };
2715
2957
  function presetLevel(scope) {
@@ -2845,7 +3087,7 @@ function preset(config) {
2845
3087
  const level = presetLevel(root);
2846
3088
  const expanded = new Map;
2847
3089
  const expand = (fileKey) => {
2848
- const paths = expanded.get(fileKey.pattern) ?? globSync(fileKey.pattern, { cwd: project.root, exclude: (path) => posix9.basename(path) === "node_modules" });
3090
+ const paths = expanded.get(fileKey.pattern) ?? globSync2(fileKey.pattern, { cwd: project.root, exclude: (path) => posix9.basename(path) === "node_modules" });
2849
3091
  expanded.set(fileKey.pattern, paths);
2850
3092
  return paths;
2851
3093
  };