oxlint-architecture-rules 0.1.0-beta.0

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 (152) hide show
  1. package/LICENSE +21 -0
  2. package/build/dts/adapters/cli/main.d.ts +3 -0
  3. package/build/dts/adapters/cli/main.d.ts.map +1 -0
  4. package/build/dts/adapters/cli/run.d.ts +19 -0
  5. package/build/dts/adapters/cli/run.d.ts.map +1 -0
  6. package/build/dts/adapters/cli/source-facts.d.ts +9 -0
  7. package/build/dts/adapters/cli/source-facts.d.ts.map +1 -0
  8. package/build/dts/adapters/cli/source-files.d.ts +2 -0
  9. package/build/dts/adapters/cli/source-files.d.ts.map +1 -0
  10. package/build/dts/adapters/oxlint/config-loader.d.ts +23 -0
  11. package/build/dts/adapters/oxlint/config-loader.d.ts.map +1 -0
  12. package/build/dts/adapters/oxlint/exports-rule.d.ts +4 -0
  13. package/build/dts/adapters/oxlint/exports-rule.d.ts.map +1 -0
  14. package/build/dts/adapters/oxlint/imports-rule.d.ts +4 -0
  15. package/build/dts/adapters/oxlint/imports-rule.d.ts.map +1 -0
  16. package/build/dts/adapters/oxlint/members-rule.d.ts +4 -0
  17. package/build/dts/adapters/oxlint/members-rule.d.ts.map +1 -0
  18. package/build/dts/adapters/oxlint/oxlint-api.d.ts +19 -0
  19. package/build/dts/adapters/oxlint/oxlint-api.d.ts.map +1 -0
  20. package/build/dts/adapters/oxlint/plugin.d.ts +15 -0
  21. package/build/dts/adapters/oxlint/plugin.d.ts.map +1 -0
  22. package/build/dts/adapters/oxlint/structure-rule.d.ts +4 -0
  23. package/build/dts/adapters/oxlint/structure-rule.d.ts.map +1 -0
  24. package/build/dts/core/baseline.d.ts +16 -0
  25. package/build/dts/core/baseline.d.ts.map +1 -0
  26. package/build/dts/core/exports.d.ts +39 -0
  27. package/build/dts/core/exports.d.ts.map +1 -0
  28. package/build/dts/core/imports.d.ts +27 -0
  29. package/build/dts/core/imports.d.ts.map +1 -0
  30. package/build/dts/core/members.d.ts +28 -0
  31. package/build/dts/core/members.d.ts.map +1 -0
  32. package/build/dts/core/patterns.d.ts +19 -0
  33. package/build/dts/core/patterns.d.ts.map +1 -0
  34. package/build/dts/core/structure.d.ts +48 -0
  35. package/build/dts/core/structure.d.ts.map +1 -0
  36. package/build/dts/domain/architecture-config.d.ts +177 -0
  37. package/build/dts/domain/architecture-config.d.ts.map +1 -0
  38. package/build/dts/domain/architecture-error.d.ts +27 -0
  39. package/build/dts/domain/architecture-error.d.ts.map +1 -0
  40. package/build/dts/domain/violation.d.ts +11 -0
  41. package/build/dts/domain/violation.d.ts.map +1 -0
  42. package/build/dts/index.d.ts +10 -0
  43. package/build/dts/index.d.ts.map +1 -0
  44. package/build/dts/infrastructure/file-system-fake.d.ts +3 -0
  45. package/build/dts/infrastructure/file-system-fake.d.ts.map +1 -0
  46. package/build/dts/infrastructure/file-system-live.d.ts +3 -0
  47. package/build/dts/infrastructure/file-system-live.d.ts.map +1 -0
  48. package/build/dts/infrastructure/module-resolver-fake.d.ts +3 -0
  49. package/build/dts/infrastructure/module-resolver-fake.d.ts.map +1 -0
  50. package/build/dts/infrastructure/module-resolver-live.d.ts +4 -0
  51. package/build/dts/infrastructure/module-resolver-live.d.ts.map +1 -0
  52. package/build/dts/manifest/compile.d.ts +16 -0
  53. package/build/dts/manifest/compile.d.ts.map +1 -0
  54. package/build/dts/manifest/glob.d.ts +14 -0
  55. package/build/dts/manifest/glob.d.ts.map +1 -0
  56. package/build/dts/manifest/manifest.d.ts +96 -0
  57. package/build/dts/manifest/manifest.d.ts.map +1 -0
  58. package/build/dts/ports/file-system.d.ts +4 -0
  59. package/build/dts/ports/file-system.d.ts.map +1 -0
  60. package/build/dts/ports/module-resolver.d.ts +11 -0
  61. package/build/dts/ports/module-resolver.d.ts.map +1 -0
  62. package/build/esm/adapters/cli/main.js +17 -0
  63. package/build/esm/adapters/cli/main.js.map +1 -0
  64. package/build/esm/adapters/cli/run.js +188 -0
  65. package/build/esm/adapters/cli/run.js.map +1 -0
  66. package/build/esm/adapters/cli/source-facts.js +117 -0
  67. package/build/esm/adapters/cli/source-facts.js.map +1 -0
  68. package/build/esm/adapters/cli/source-files.js +40 -0
  69. package/build/esm/adapters/cli/source-files.js.map +1 -0
  70. package/build/esm/adapters/oxlint/config-loader.js +81 -0
  71. package/build/esm/adapters/oxlint/config-loader.js.map +1 -0
  72. package/build/esm/adapters/oxlint/exports-rule.js +104 -0
  73. package/build/esm/adapters/oxlint/exports-rule.js.map +1 -0
  74. package/build/esm/adapters/oxlint/imports-rule.js +55 -0
  75. package/build/esm/adapters/oxlint/imports-rule.js.map +1 -0
  76. package/build/esm/adapters/oxlint/members-rule.js +75 -0
  77. package/build/esm/adapters/oxlint/members-rule.js.map +1 -0
  78. package/build/esm/adapters/oxlint/oxlint-api.js +7 -0
  79. package/build/esm/adapters/oxlint/oxlint-api.js.map +1 -0
  80. package/build/esm/adapters/oxlint/plugin.js +23 -0
  81. package/build/esm/adapters/oxlint/plugin.js.map +1 -0
  82. package/build/esm/adapters/oxlint/structure-rule.js +37 -0
  83. package/build/esm/adapters/oxlint/structure-rule.js.map +1 -0
  84. package/build/esm/core/baseline.js +30 -0
  85. package/build/esm/core/baseline.js.map +1 -0
  86. package/build/esm/core/exports.js +96 -0
  87. package/build/esm/core/exports.js.map +1 -0
  88. package/build/esm/core/imports.js +85 -0
  89. package/build/esm/core/imports.js.map +1 -0
  90. package/build/esm/core/members.js +84 -0
  91. package/build/esm/core/members.js.map +1 -0
  92. package/build/esm/core/patterns.js +78 -0
  93. package/build/esm/core/patterns.js.map +1 -0
  94. package/build/esm/core/structure.js +256 -0
  95. package/build/esm/core/structure.js.map +1 -0
  96. package/build/esm/domain/architecture-config.js +172 -0
  97. package/build/esm/domain/architecture-config.js.map +1 -0
  98. package/build/esm/domain/architecture-error.js +29 -0
  99. package/build/esm/domain/architecture-error.js.map +1 -0
  100. package/build/esm/domain/violation.js +8 -0
  101. package/build/esm/domain/violation.js.map +1 -0
  102. package/build/esm/index.js +8 -0
  103. package/build/esm/index.js.map +1 -0
  104. package/build/esm/infrastructure/file-system-fake.js +5 -0
  105. package/build/esm/infrastructure/file-system-fake.js.map +1 -0
  106. package/build/esm/infrastructure/file-system-live.js +19 -0
  107. package/build/esm/infrastructure/file-system-live.js.map +1 -0
  108. package/build/esm/infrastructure/module-resolver-fake.js +23 -0
  109. package/build/esm/infrastructure/module-resolver-fake.js.map +1 -0
  110. package/build/esm/infrastructure/module-resolver-live.js +71 -0
  111. package/build/esm/infrastructure/module-resolver-live.js.map +1 -0
  112. package/build/esm/manifest/compile.js +503 -0
  113. package/build/esm/manifest/compile.js.map +1 -0
  114. package/build/esm/manifest/glob.js +77 -0
  115. package/build/esm/manifest/glob.js.map +1 -0
  116. package/build/esm/manifest/manifest.js +120 -0
  117. package/build/esm/manifest/manifest.js.map +1 -0
  118. package/build/esm/ports/file-system.js +2 -0
  119. package/build/esm/ports/file-system.js.map +1 -0
  120. package/build/esm/ports/module-resolver.js +2 -0
  121. package/build/esm/ports/module-resolver.js.map +1 -0
  122. package/package.json +73 -0
  123. package/src/adapters/cli/main.ts +20 -0
  124. package/src/adapters/cli/run.ts +270 -0
  125. package/src/adapters/cli/source-facts.ts +137 -0
  126. package/src/adapters/cli/source-files.ts +42 -0
  127. package/src/adapters/oxlint/config-loader.ts +136 -0
  128. package/src/adapters/oxlint/exports-rule.ts +144 -0
  129. package/src/adapters/oxlint/imports-rule.ts +67 -0
  130. package/src/adapters/oxlint/members-rule.ts +114 -0
  131. package/src/adapters/oxlint/oxlint-api.ts +25 -0
  132. package/src/adapters/oxlint/plugin.ts +35 -0
  133. package/src/adapters/oxlint/structure-rule.ts +39 -0
  134. package/src/core/baseline.ts +62 -0
  135. package/src/core/exports.ts +164 -0
  136. package/src/core/imports.ts +138 -0
  137. package/src/core/members.ts +131 -0
  138. package/src/core/patterns.ts +122 -0
  139. package/src/core/structure.ts +348 -0
  140. package/src/domain/architecture-config.ts +206 -0
  141. package/src/domain/architecture-error.ts +43 -0
  142. package/src/domain/violation.ts +24 -0
  143. package/src/index.ts +28 -0
  144. package/src/infrastructure/file-system-fake.ts +6 -0
  145. package/src/infrastructure/file-system-live.ts +20 -0
  146. package/src/infrastructure/module-resolver-fake.ts +29 -0
  147. package/src/infrastructure/module-resolver-live.ts +100 -0
  148. package/src/manifest/compile.ts +736 -0
  149. package/src/manifest/glob.ts +108 -0
  150. package/src/manifest/manifest.ts +178 -0
  151. package/src/ports/file-system.ts +5 -0
  152. package/src/ports/module-resolver.ts +23 -0
@@ -0,0 +1,42 @@
1
+ import { readdirSync, statSync } from "node:fs";
2
+ import * as path from "node:path";
3
+
4
+ // Folders no policy is written about and no linter visits.
5
+ const SKIPPED = new Set([
6
+ "node_modules",
7
+ "build",
8
+ "dist",
9
+ ".next",
10
+ ".git",
11
+ "storybook-static",
12
+ "coverage",
13
+ ]);
14
+
15
+ const SOURCE = /\.(ts|tsx|mts|cts)$/;
16
+
17
+ // A declaration file states types, not code; no linter visits one and no policy
18
+ // is written about one.
19
+ const DECLARATION = /\.d\.[cm]?ts$/;
20
+
21
+ export const listSourceFiles = (
22
+ repoRoot: string,
23
+ roots: ReadonlyArray<string>,
24
+ ): ReadonlyArray<string> => {
25
+ const found: Array<string> = [];
26
+ const walk = (absolute: string): void => {
27
+ for (const entry of readdirSync(absolute)) {
28
+ if (SKIPPED.has(entry)) continue;
29
+ const child = path.join(absolute, entry);
30
+ if (statSync(child).isDirectory()) walk(child);
31
+ else if (SOURCE.test(entry) && !DECLARATION.test(entry)) {
32
+ found.push(path.relative(repoRoot, child).replaceAll(path.sep, "/"));
33
+ }
34
+ }
35
+ };
36
+ for (const root of roots) {
37
+ const absolute = path.resolve(repoRoot, root);
38
+ if (statSync(absolute).isDirectory()) walk(absolute);
39
+ else found.push(path.relative(repoRoot, absolute).replaceAll(path.sep, "/"));
40
+ }
41
+ return found.sort();
42
+ };
@@ -0,0 +1,136 @@
1
+ import { readFileSync } from "node:fs";
2
+ import * as path from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+
5
+ import * as Result from "effect/Result";
6
+
7
+ import {
8
+ type BaselineFilter,
9
+ decodeBaseline,
10
+ EMPTY_BASELINE,
11
+ makeBaselineFilter,
12
+ } from "../../core/baseline.js";
13
+ import {
14
+ type CompiledExportRule,
15
+ compileExportRules,
16
+ exportRulesFailingTheirProbe,
17
+ } from "../../core/exports.js";
18
+ import {
19
+ type CompiledImportRule,
20
+ compileImportRules,
21
+ rulesFailingTheirProbe,
22
+ } from "../../core/imports.js";
23
+ import {
24
+ type CompiledMemberRule,
25
+ compileMemberRules,
26
+ memberRulesFailingTheirProbe,
27
+ } from "../../core/members.js";
28
+ import {
29
+ type CompiledStructure,
30
+ compileStructure,
31
+ structureRulesFailingTheirProbe,
32
+ } from "../../core/structure.js";
33
+ import { ConfigInvalid } from "../../domain/architecture-error.js";
34
+ import { makeFileSystemLive } from "../../infrastructure/file-system-live.js";
35
+ import { makeModuleResolverLive } from "../../infrastructure/module-resolver-live.js";
36
+ import { lowerManifest } from "../../manifest/compile.js";
37
+ import { decodeManifest, type Manifest } from "../../manifest/manifest.js";
38
+ import type { FileSystem } from "../../ports/file-system.js";
39
+ import type { ModuleResolver } from "../../ports/module-resolver.js";
40
+
41
+ export type LoadedPolicy = {
42
+ readonly repoRoot: string;
43
+ readonly config: Manifest;
44
+ readonly importRules: ReadonlyArray<CompiledImportRule>;
45
+ readonly exportRules: ReadonlyArray<CompiledExportRule>;
46
+ readonly memberRules: ReadonlyArray<CompiledMemberRule>;
47
+ readonly structure: CompiledStructure;
48
+ readonly fileSystem: FileSystem;
49
+ // Violations this repository is carrying while it adopts the policy. Applied
50
+ // at report time so a baselined finding costs nothing but a line in a file.
51
+ readonly baseline: BaselineFilter;
52
+ readonly resolver: ModuleResolver;
53
+ readonly ignoreUnresolved: ReadonlyArray<RegExp>;
54
+ };
55
+
56
+ const readBaselineAt = (repoRoot: string, at: string) => {
57
+ try {
58
+ return decodeBaseline(JSON.parse(readFileSync(path.resolve(repoRoot, at), "utf8")) as unknown);
59
+ } catch {
60
+ // An absent or unreadable baseline carries nothing, which is the safe
61
+ // direction: every violation reports.
62
+ return EMPTY_BASELINE;
63
+ }
64
+ };
65
+
66
+ export const DEFAULT_CONFIG_FILENAME = "architecture.config.mjs";
67
+
68
+ // Anything wrong with the policy — a missing file, a bad shape, an uncompilable
69
+ // pattern — must stop the lint run. A plugin that loads with an empty policy
70
+ // reports nothing and looks exactly like a clean codebase.
71
+ export const loadPolicy = async (
72
+ repoRoot: string,
73
+ configFilename: string = DEFAULT_CONFIG_FILENAME,
74
+ ): Promise<LoadedPolicy> => {
75
+ const configPath = path.resolve(repoRoot, configFilename);
76
+
77
+ const module: unknown = await import(pathToFileURL(configPath).href).catch((cause: unknown) => {
78
+ throw new ConfigInvalid({ configPath, detail: String(cause) });
79
+ });
80
+
81
+ const exported =
82
+ typeof module === "object" && module !== null && "default" in module ? module.default : module;
83
+
84
+ const decoded = decodeManifest(configPath, exported);
85
+ if (Result.isFailure(decoded)) throw decoded.failure;
86
+ const config = decoded.success;
87
+
88
+ // The manifest is the authoring surface; these flat rules are the machine's.
89
+ const rules = lowerManifest(config);
90
+
91
+ const importRules = compileImportRules(rules.imports);
92
+ if (Result.isFailure(importRules)) throw importRules.failure;
93
+
94
+ const exportRules = compileExportRules(rules.exports);
95
+ if (Result.isFailure(exportRules)) throw exportRules.failure;
96
+
97
+ const memberRules = compileMemberRules(rules.members);
98
+ if (Result.isFailure(memberRules)) throw memberRules.failure;
99
+
100
+ const structure = compileStructure(rules.structure);
101
+ if (Result.isFailure(structure)) throw structure.failure;
102
+
103
+ const vacuous = [
104
+ ...rulesFailingTheirProbe(importRules.success),
105
+ ...exportRulesFailingTheirProbe(exportRules.success),
106
+ ...memberRulesFailingTheirProbe(memberRules.success),
107
+ ]
108
+ .map((rule) => rule.name)
109
+ .concat(structureRulesFailingTheirProbe(structure.success));
110
+ if (vacuous.length > 0) {
111
+ throw new ConfigInvalid({
112
+ configPath,
113
+ detail:
114
+ `these import rules do not report their own probe, so they enforce nothing: ` +
115
+ `${vacuous.join(", ")}. Fix the rule or its probe — ` +
116
+ `a rule that cannot flag a violation it was written for is worse than no rule.`,
117
+ });
118
+ }
119
+
120
+ return {
121
+ repoRoot,
122
+ config,
123
+ importRules: importRules.success,
124
+ exportRules: exportRules.success,
125
+ memberRules: memberRules.success,
126
+ structure: structure.success,
127
+ fileSystem: makeFileSystemLive(repoRoot),
128
+ baseline: makeBaselineFilter(
129
+ config.baseline === undefined ? EMPTY_BASELINE : readBaselineAt(repoRoot, config.baseline),
130
+ ),
131
+ resolver: makeModuleResolverLive(repoRoot, config.resolve),
132
+ ignoreUnresolved: (config.resolve.ignoreUnresolved ?? []).map(
133
+ (pattern: string) => new RegExp(pattern),
134
+ ),
135
+ };
136
+ };
@@ -0,0 +1,144 @@
1
+ import * as Result from "effect/Result";
2
+
3
+ import {
4
+ type Binding,
5
+ evaluateSelectedBindings,
6
+ exportRulesSelecting,
7
+ type SelectedExportRule,
8
+ } from "../../core/exports.js";
9
+ import { formatMessage } from "../../domain/violation.js";
10
+ import type { LoadedPolicy } from "./config-loader.js";
11
+ import {
12
+ type Fixer,
13
+ type OxlintRule,
14
+ type ReportableNode,
15
+ type RuleContext,
16
+ toRepoRelative,
17
+ } from "./oxlint-api.js";
18
+
19
+ type NamedNode = ReportableNode & { readonly name?: unknown; readonly value?: unknown };
20
+
21
+ type SpecifierNode = ReportableNode & {
22
+ readonly type: string;
23
+ readonly imported?: NamedNode | null;
24
+ readonly local?: NamedNode | null;
25
+ };
26
+
27
+ type DeclarationNode = ReportableNode & {
28
+ readonly source?: { readonly value?: unknown } | null;
29
+ readonly specifiers?: ReadonlyArray<SpecifierNode> | null;
30
+ };
31
+
32
+ const nameOf = (node: NamedNode | null | undefined): string | null => {
33
+ if (node === null || node === undefined) return null;
34
+ if (typeof node.name === "string") return node.name;
35
+ // `import { "a-b" as ab }` — a string-literal export name.
36
+ return typeof node.value === "string" ? node.value : null;
37
+ };
38
+
39
+ type Bound = Binding & { readonly node: SpecifierNode; readonly local: string };
40
+
41
+ const boundOf = (specifier: SpecifierNode): Bound | null => {
42
+ const local = nameOf(specifier.local) ?? "";
43
+ switch (specifier.type) {
44
+ case "ImportSpecifier": {
45
+ const symbol = nameOf(specifier.imported);
46
+ return symbol === null ? null : { symbol, kind: "named", node: specifier, local };
47
+ }
48
+ case "ImportDefaultSpecifier":
49
+ return { symbol: "default", kind: "default", node: specifier, local };
50
+ case "ImportNamespaceSpecifier":
51
+ return { symbol: "*", kind: "namespace", node: specifier, local };
52
+ // `export { a } from "…"` — `local` is the name in the source module.
53
+ case "ExportSpecifier": {
54
+ const symbol = nameOf(specifier.local);
55
+ return symbol === null ? null : { symbol, kind: "named", node: specifier, local: symbol };
56
+ }
57
+ default:
58
+ return null;
59
+ }
60
+ };
61
+
62
+ // `import { A, B as C } from "pkg"` becomes `import * as A from "pkg/A"` and
63
+ // `import * as C from "pkg/B"`. Only whole-declaration rewrites are offered: a
64
+ // declaration mixing restricted named imports with a default or namespace one
65
+ // would need comma surgery inside the braces, and a fix that is subtly wrong is
66
+ // worse than a diagnostic the author resolves by hand.
67
+ const subpathNamespaceImport = (specifier: string, bound: ReadonlyArray<Bound>): string =>
68
+ bound
69
+ .map((binding) => `import * as ${binding.local} from "${specifier}/${binding.symbol}";`)
70
+ .join("\n");
71
+
72
+ export const makeExportsRule = (policy: LoadedPolicy): OxlintRule => ({
73
+ meta: {
74
+ type: "problem" as const,
75
+ fixable: "code" as const,
76
+ docs: {
77
+ description:
78
+ "which exported symbols a file may import, for rules a path alone cannot express",
79
+ },
80
+ schema: [],
81
+ },
82
+
83
+ createOnce(context: RuleContext) {
84
+ let importer = "";
85
+ let selected: ReadonlyArray<SelectedExportRule> = [];
86
+
87
+ const check = (node: DeclarationNode): void => {
88
+ const specifierValue = node.source?.value;
89
+ if (typeof specifierValue !== "string") return;
90
+
91
+ const bound = (node.specifiers ?? [])
92
+ .map(boundOf)
93
+ .filter((one): one is Bound => one !== null);
94
+ if (bound.length === 0) return;
95
+
96
+ const outcome = evaluateSelectedBindings(selected, policy.resolver, {
97
+ importer,
98
+ specifier: specifierValue,
99
+ bindings: bound,
100
+ });
101
+
102
+ if (Result.isFailure(outcome)) {
103
+ // `architecture/imports` reports the same unresolved edge, so staying
104
+ // quiet here avoids two diagnostics for one broken specifier.
105
+ return;
106
+ }
107
+
108
+ for (const { bindings, rule, violation } of outcome.success) {
109
+ if (policy.baseline.isBaselined(violation)) continue;
110
+ const offending = bound.filter((one: Bound) =>
111
+ bindings.some((binding) => binding.symbol === one.symbol && binding.kind === one.kind),
112
+ );
113
+ const rewritable =
114
+ rule.fix === "subpath-namespace-import" && offending.length === bound.length;
115
+
116
+ if (rewritable) {
117
+ context.report({
118
+ node,
119
+ message: formatMessage(violation),
120
+ fix: (fixer: Fixer) =>
121
+ fixer.replaceText(node, subpathNamespaceImport(specifierValue, offending)),
122
+ });
123
+ continue;
124
+ }
125
+
126
+ context.report({
127
+ node: offending[0]?.node ?? node,
128
+ message: formatMessage(violation),
129
+ });
130
+ }
131
+ };
132
+
133
+ return {
134
+ before() {
135
+ importer = toRepoRelative(policy.repoRoot, context.filename);
136
+ if (importer.startsWith("..")) return false;
137
+ selected = exportRulesSelecting(policy.exportRules, importer);
138
+ return selected.length > 0;
139
+ },
140
+ ImportDeclaration: check,
141
+ ExportNamedDeclaration: check,
142
+ };
143
+ },
144
+ });
@@ -0,0 +1,67 @@
1
+ import * as Result from "effect/Result";
2
+
3
+ import { evaluateSelectedEdge, rulesSelecting, type SelectedRule } from "../../core/imports.js";
4
+ import { formatMessage } from "../../domain/violation.js";
5
+ import type { LoadedPolicy } from "./config-loader.js";
6
+ import {
7
+ type OxlintRule,
8
+ type RuleContext,
9
+ type SourceNode,
10
+ specifierOf,
11
+ toRepoRelative,
12
+ } from "./oxlint-api.js";
13
+
14
+ const unresolvedMessage = (specifier: string, detail: string): string =>
15
+ `[unresolved-import] "${specifier}" could not be resolved, so every import rule about it ` +
16
+ `enforces nothing. Fix the resolve scope in the architecture config, or list the specifier ` +
17
+ `in resolve.ignoreUnresolved. (${detail})`;
18
+
19
+ export const makeImportsRule = (policy: LoadedPolicy): OxlintRule => ({
20
+ meta: {
21
+ type: "problem" as const,
22
+ docs: {
23
+ description:
24
+ "import boundaries between folders and packages, matched against fully resolved module paths",
25
+ },
26
+ schema: [],
27
+ },
28
+
29
+ // `createOnce` is what makes resolution affordable: the resolver and the
30
+ // compiled rules are built once per lint run, and `before` decides per file
31
+ // whether any rule selects it at all — a file none select is skipped whole.
32
+ createOnce(context: RuleContext) {
33
+ let importer = "";
34
+ let selected: ReadonlyArray<SelectedRule> = [];
35
+
36
+ const check = (node: SourceNode): void => {
37
+ const specifier = specifierOf(node);
38
+ if (specifier === null) return;
39
+
40
+ const outcome = evaluateSelectedEdge(selected, policy.resolver, { importer, specifier });
41
+
42
+ if (Result.isFailure(outcome)) {
43
+ if (policy.config.resolve.unresolved === "off") return;
44
+ if (policy.ignoreUnresolved.some((pattern) => pattern.test(specifier))) return;
45
+ context.report({ node, message: unresolvedMessage(specifier, outcome.failure.detail) });
46
+ return;
47
+ }
48
+
49
+ for (const violation of outcome.success) {
50
+ if (policy.baseline.isBaselined(violation)) continue;
51
+ context.report({ node, message: formatMessage(violation) });
52
+ }
53
+ };
54
+
55
+ return {
56
+ before() {
57
+ importer = toRepoRelative(policy.repoRoot, context.filename);
58
+ if (importer.startsWith("..")) return false;
59
+ selected = rulesSelecting(policy.importRules, importer);
60
+ return selected.length > 0;
61
+ },
62
+ ImportDeclaration: check,
63
+ ExportNamedDeclaration: check,
64
+ ExportAllDeclaration: check,
65
+ };
66
+ },
67
+ });
@@ -0,0 +1,114 @@
1
+ import {
2
+ type CompiledMemberRule,
3
+ evaluateMemberSite,
4
+ memberRulesSelecting,
5
+ } from "../../core/members.js";
6
+ import { formatMessage } from "../../domain/violation.js";
7
+ import type { LoadedPolicy } from "./config-loader.js";
8
+ import {
9
+ type OxlintRule,
10
+ type ReportableNode,
11
+ type RuleContext,
12
+ toRepoRelative,
13
+ } from "./oxlint-api.js";
14
+
15
+ type NamedNode = ReportableNode & {
16
+ readonly type: string;
17
+ readonly name?: unknown;
18
+ readonly value?: unknown;
19
+ };
20
+
21
+ type MemberNode = ReportableNode & {
22
+ readonly type: string;
23
+ readonly key?: NamedNode | null;
24
+ readonly computed?: boolean;
25
+ };
26
+
27
+ type TypeAliasNode = ReportableNode & {
28
+ readonly id?: NamedNode | null;
29
+ readonly typeAnnotation?:
30
+ | (ReportableNode & { readonly type: string; readonly members?: ReadonlyArray<MemberNode> })
31
+ | null;
32
+ };
33
+
34
+ type CallNode = ReportableNode & {
35
+ readonly callee?:
36
+ | (ReportableNode & {
37
+ readonly type: string;
38
+ readonly name?: unknown;
39
+ readonly property?: NamedNode | null;
40
+ })
41
+ | null;
42
+ };
43
+
44
+ const DECLARED_MEMBER_TYPES = new Set(["TSPropertySignature", "TSMethodSignature"]);
45
+
46
+ const nameOf = (node: NamedNode | null | undefined): string | null => {
47
+ if (node === null || node === undefined) return null;
48
+ if (typeof node.name === "string") return node.name;
49
+ return typeof node.value === "string" ? node.value : null;
50
+ };
51
+
52
+ const calleeName = (node: CallNode): string | null => {
53
+ const callee = node.callee;
54
+ if (callee === null || callee === undefined) return null;
55
+ if (typeof callee.name === "string") return callee.name;
56
+ return nameOf(callee.property);
57
+ };
58
+
59
+ export const makeMembersRule = (policy: LoadedPolicy): OxlintRule => ({
60
+ meta: {
61
+ type: "problem" as const,
62
+ docs: {
63
+ description:
64
+ "which names a file may declare or call — a port's method vocabulary, a tier's allowed hooks",
65
+ },
66
+ schema: [],
67
+ },
68
+
69
+ createOnce(context: RuleContext) {
70
+ let file = "";
71
+ let selected: ReadonlyArray<CompiledMemberRule> = [];
72
+
73
+ const report = (node: ReportableNode, name: string, declaration?: string): void => {
74
+ const violations = evaluateMemberSite(selected, {
75
+ file,
76
+ subject: declaration === undefined ? "calls" : "type-members",
77
+ name,
78
+ ...(declaration === undefined ? {} : { in: declaration }),
79
+ });
80
+ for (const violation of violations) {
81
+ if (policy.baseline.isBaselined(violation)) continue;
82
+ context.report({ node, message: formatMessage(violation) });
83
+ }
84
+ };
85
+
86
+ return {
87
+ before() {
88
+ file = toRepoRelative(policy.repoRoot, context.filename);
89
+ if (file.startsWith("..")) return false;
90
+ selected = memberRulesSelecting(policy.memberRules, file);
91
+ return selected.length > 0;
92
+ },
93
+
94
+ TSTypeAliasDeclaration(node: TypeAliasNode) {
95
+ const declaration = nameOf(node.id);
96
+ const annotation = node.typeAnnotation;
97
+ if (declaration === null || annotation?.type !== "TSTypeLiteral") return;
98
+
99
+ for (const member of annotation.members ?? []) {
100
+ // A computed key is not a name a vocabulary rule can speak about, and
101
+ // no port in this codebase declares one.
102
+ if (member.computed === true || !DECLARED_MEMBER_TYPES.has(member.type)) continue;
103
+ const name = nameOf(member.key);
104
+ if (name !== null) report(member.key ?? member, name, declaration);
105
+ }
106
+ },
107
+
108
+ CallExpression(node: CallNode) {
109
+ const name = calleeName(node);
110
+ if (name !== null) report(node, name);
111
+ },
112
+ };
113
+ },
114
+ });
@@ -0,0 +1,25 @@
1
+ import * as path from "node:path";
2
+
3
+ import type { RuleTester } from "oxlint/plugins-dev";
4
+
5
+ // oxlint publishes no plugin types, so the exact `Context`, node and fixer shapes
6
+ // are recovered from `RuleTester`'s own signature — the one public surface that
7
+ // names them. Hand-rolling them drifts silently against an alpha API.
8
+ export type OxlintRule = Parameters<RuleTester["run"]>[1];
9
+ export type RuleContext = Parameters<Extract<OxlintRule, { createOnce: unknown }>["createOnce"]>[0];
10
+
11
+ type Diagnostic = Parameters<RuleContext["report"]>[0];
12
+ export type ReportableNode = Extract<Diagnostic, { node: unknown }>["node"];
13
+ export type Fixer = Parameters<NonNullable<Diagnostic["fix"]>>[0];
14
+
15
+ export type SourceNode = ReportableNode & {
16
+ readonly source?: { readonly value?: unknown } | null;
17
+ };
18
+
19
+ export const toRepoRelative = (repoRoot: string, filename: string): string =>
20
+ path.relative(repoRoot, filename).replaceAll(path.sep, "/");
21
+
22
+ export const specifierOf = (node: SourceNode): string | null => {
23
+ const value = node.source?.value;
24
+ return typeof value === "string" ? value : null;
25
+ };
@@ -0,0 +1,35 @@
1
+ import { DEFAULT_CONFIG_FILENAME, loadPolicy } from "./config-loader.js";
2
+ import { makeExportsRule } from "./exports-rule.js";
3
+ import { makeImportsRule } from "./imports-rule.js";
4
+ import { makeMembersRule } from "./members-rule.js";
5
+ import type { OxlintRule } from "./oxlint-api.js";
6
+ import { makeStructureRule } from "./structure-rule.js";
7
+
8
+ // oxlint imports this module once per lint run and reads `rules` synchronously,
9
+ // so the policy is loaded here rather than inside a rule. A load failure throws
10
+ // out of the import and fails the run — which is the point: a plugin that came
11
+ // up with no policy would report nothing and be indistinguishable from a clean
12
+ // codebase.
13
+ const policy = await loadPolicy(
14
+ process.env.ARCHITECTURE_ROOT ?? process.cwd(),
15
+ process.env.ARCHITECTURE_CONFIG ?? DEFAULT_CONFIG_FILENAME,
16
+ );
17
+
18
+ export const rules: {
19
+ readonly imports: OxlintRule;
20
+ readonly exports: OxlintRule;
21
+ readonly members: OxlintRule;
22
+ readonly structure: OxlintRule;
23
+ } = {
24
+ imports: makeImportsRule(policy),
25
+ exports: makeExportsRule(policy),
26
+ members: makeMembersRule(policy),
27
+ structure: makeStructureRule(policy),
28
+ };
29
+
30
+ const plugin: { readonly meta: { readonly name: string }; readonly rules: typeof rules } = {
31
+ meta: { name: "architecture" },
32
+ rules,
33
+ };
34
+
35
+ export default plugin;
@@ -0,0 +1,39 @@
1
+ import { evaluateStructure } from "../../core/structure.js";
2
+ import { formatMessage } from "../../domain/violation.js";
3
+ import type { LoadedPolicy } from "./config-loader.js";
4
+ import { type OxlintRule, type RuleContext, toRepoRelative } from "./oxlint-api.js";
5
+
6
+ export const makeStructureRule = (policy: LoadedPolicy): OxlintRule => ({
7
+ meta: {
8
+ type: "problem" as const,
9
+ docs: {
10
+ description:
11
+ "the file taxonomy: which file kinds a folder admits, and which siblings a file owes",
12
+ },
13
+ schema: [],
14
+ },
15
+
16
+ createOnce(context: RuleContext) {
17
+ // The taxonomy is a property of the path, not of the syntax, so the whole
18
+ // check runs once in `before` and the visitor exists only because oxlint
19
+ // does not run hooks for a rule with no visitor keys.
20
+ return {
21
+ before() {
22
+ const file = toRepoRelative(policy.repoRoot, context.filename);
23
+ if (file.startsWith("..")) return false;
24
+
25
+ for (const violation of evaluateStructure(policy.structure, policy.fileSystem, file)) {
26
+ if (policy.baseline.isBaselined(violation)) continue;
27
+ context.report({
28
+ message: formatMessage(violation),
29
+ loc: { line: 1, column: 0 },
30
+ });
31
+ }
32
+ return false;
33
+ },
34
+ Program() {
35
+ return undefined;
36
+ },
37
+ };
38
+ },
39
+ });
@@ -0,0 +1,62 @@
1
+ import type { Violation } from "../domain/violation.js";
2
+ import { fingerprintOf } from "../domain/violation.js";
3
+
4
+ // A baseline is the set of violations a repository is choosing to carry while it
5
+ // adopts a policy. It exists so a rule can be turned on before the code is
6
+ // clean — the alternative is not turning it on.
7
+ //
8
+ // Two properties make it a ratchet rather than a suppression list:
9
+ //
10
+ // - Entries are line-independent fingerprints, so an entry survives edits to
11
+ // the file it names. One keyed on a position would go stale on the first
12
+ // reformat and silently re-admit the violation it was meant to record.
13
+ // - An entry that no longer fires is an error, not a shrug. Fixing a violation
14
+ // must remove its entry, or the floor never rises.
15
+ export type Baseline = {
16
+ readonly version: 1;
17
+ readonly entries: ReadonlyArray<string>;
18
+ };
19
+
20
+ export const EMPTY_BASELINE: Baseline = { version: 1, entries: [] };
21
+
22
+ export const baselineOf = (violations: Iterable<Violation>): Baseline => ({
23
+ version: 1,
24
+ entries: [...new Set([...violations].map(fingerprintOf))].sort(),
25
+ });
26
+
27
+ export const decodeBaseline = (raw: unknown): Baseline => {
28
+ if (typeof raw !== "object" || raw === null) return EMPTY_BASELINE;
29
+ const entries = (raw as { entries?: unknown }).entries;
30
+ if (!Array.isArray(entries)) return EMPTY_BASELINE;
31
+ return { version: 1, entries: entries.filter((one): one is string => typeof one === "string") };
32
+ };
33
+
34
+ export const serializeBaseline = (baseline: Baseline): string =>
35
+ `${JSON.stringify(baseline, null, 2)}\n`;
36
+
37
+ export type BaselineFilter = {
38
+ readonly isBaselined: (violation: Violation) => boolean;
39
+ };
40
+
41
+ export const makeBaselineFilter = (baseline: Baseline): BaselineFilter => {
42
+ const entries = new Set(baseline.entries);
43
+ return { isBaselined: (violation) => entries.has(fingerprintOf(violation)) };
44
+ };
45
+
46
+ // The ratchet's teeth: entries the code no longer produces. A baseline that is
47
+ // allowed to keep them stops being a record of debt and becomes a place to hide.
48
+ export const staleEntriesOf = (
49
+ baseline: Baseline,
50
+ violations: Iterable<Violation>,
51
+ ): ReadonlyArray<string> => {
52
+ const current = new Set([...violations].map(fingerprintOf));
53
+ return baseline.entries.filter((entry) => !current.has(entry));
54
+ };
55
+
56
+ export const unbaselined = (
57
+ baseline: Baseline,
58
+ violations: Iterable<Violation>,
59
+ ): ReadonlyArray<Violation> => {
60
+ const { isBaselined } = makeBaselineFilter(baseline);
61
+ return [...violations].filter((violation) => !isBaselined(violation));
62
+ };