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.
- package/LICENSE +21 -0
- package/build/dts/adapters/cli/main.d.ts +3 -0
- package/build/dts/adapters/cli/main.d.ts.map +1 -0
- package/build/dts/adapters/cli/run.d.ts +19 -0
- package/build/dts/adapters/cli/run.d.ts.map +1 -0
- package/build/dts/adapters/cli/source-facts.d.ts +9 -0
- package/build/dts/adapters/cli/source-facts.d.ts.map +1 -0
- package/build/dts/adapters/cli/source-files.d.ts +2 -0
- package/build/dts/adapters/cli/source-files.d.ts.map +1 -0
- package/build/dts/adapters/oxlint/config-loader.d.ts +23 -0
- package/build/dts/adapters/oxlint/config-loader.d.ts.map +1 -0
- package/build/dts/adapters/oxlint/exports-rule.d.ts +4 -0
- package/build/dts/adapters/oxlint/exports-rule.d.ts.map +1 -0
- package/build/dts/adapters/oxlint/imports-rule.d.ts +4 -0
- package/build/dts/adapters/oxlint/imports-rule.d.ts.map +1 -0
- package/build/dts/adapters/oxlint/members-rule.d.ts +4 -0
- package/build/dts/adapters/oxlint/members-rule.d.ts.map +1 -0
- package/build/dts/adapters/oxlint/oxlint-api.d.ts +19 -0
- package/build/dts/adapters/oxlint/oxlint-api.d.ts.map +1 -0
- package/build/dts/adapters/oxlint/plugin.d.ts +15 -0
- package/build/dts/adapters/oxlint/plugin.d.ts.map +1 -0
- package/build/dts/adapters/oxlint/structure-rule.d.ts +4 -0
- package/build/dts/adapters/oxlint/structure-rule.d.ts.map +1 -0
- package/build/dts/core/baseline.d.ts +16 -0
- package/build/dts/core/baseline.d.ts.map +1 -0
- package/build/dts/core/exports.d.ts +39 -0
- package/build/dts/core/exports.d.ts.map +1 -0
- package/build/dts/core/imports.d.ts +27 -0
- package/build/dts/core/imports.d.ts.map +1 -0
- package/build/dts/core/members.d.ts +28 -0
- package/build/dts/core/members.d.ts.map +1 -0
- package/build/dts/core/patterns.d.ts +19 -0
- package/build/dts/core/patterns.d.ts.map +1 -0
- package/build/dts/core/structure.d.ts +48 -0
- package/build/dts/core/structure.d.ts.map +1 -0
- package/build/dts/domain/architecture-config.d.ts +177 -0
- package/build/dts/domain/architecture-config.d.ts.map +1 -0
- package/build/dts/domain/architecture-error.d.ts +27 -0
- package/build/dts/domain/architecture-error.d.ts.map +1 -0
- package/build/dts/domain/violation.d.ts +11 -0
- package/build/dts/domain/violation.d.ts.map +1 -0
- package/build/dts/index.d.ts +10 -0
- package/build/dts/index.d.ts.map +1 -0
- package/build/dts/infrastructure/file-system-fake.d.ts +3 -0
- package/build/dts/infrastructure/file-system-fake.d.ts.map +1 -0
- package/build/dts/infrastructure/file-system-live.d.ts +3 -0
- package/build/dts/infrastructure/file-system-live.d.ts.map +1 -0
- package/build/dts/infrastructure/module-resolver-fake.d.ts +3 -0
- package/build/dts/infrastructure/module-resolver-fake.d.ts.map +1 -0
- package/build/dts/infrastructure/module-resolver-live.d.ts +4 -0
- package/build/dts/infrastructure/module-resolver-live.d.ts.map +1 -0
- package/build/dts/manifest/compile.d.ts +16 -0
- package/build/dts/manifest/compile.d.ts.map +1 -0
- package/build/dts/manifest/glob.d.ts +14 -0
- package/build/dts/manifest/glob.d.ts.map +1 -0
- package/build/dts/manifest/manifest.d.ts +96 -0
- package/build/dts/manifest/manifest.d.ts.map +1 -0
- package/build/dts/ports/file-system.d.ts +4 -0
- package/build/dts/ports/file-system.d.ts.map +1 -0
- package/build/dts/ports/module-resolver.d.ts +11 -0
- package/build/dts/ports/module-resolver.d.ts.map +1 -0
- package/build/esm/adapters/cli/main.js +17 -0
- package/build/esm/adapters/cli/main.js.map +1 -0
- package/build/esm/adapters/cli/run.js +188 -0
- package/build/esm/adapters/cli/run.js.map +1 -0
- package/build/esm/adapters/cli/source-facts.js +117 -0
- package/build/esm/adapters/cli/source-facts.js.map +1 -0
- package/build/esm/adapters/cli/source-files.js +40 -0
- package/build/esm/adapters/cli/source-files.js.map +1 -0
- package/build/esm/adapters/oxlint/config-loader.js +81 -0
- package/build/esm/adapters/oxlint/config-loader.js.map +1 -0
- package/build/esm/adapters/oxlint/exports-rule.js +104 -0
- package/build/esm/adapters/oxlint/exports-rule.js.map +1 -0
- package/build/esm/adapters/oxlint/imports-rule.js +55 -0
- package/build/esm/adapters/oxlint/imports-rule.js.map +1 -0
- package/build/esm/adapters/oxlint/members-rule.js +75 -0
- package/build/esm/adapters/oxlint/members-rule.js.map +1 -0
- package/build/esm/adapters/oxlint/oxlint-api.js +7 -0
- package/build/esm/adapters/oxlint/oxlint-api.js.map +1 -0
- package/build/esm/adapters/oxlint/plugin.js +23 -0
- package/build/esm/adapters/oxlint/plugin.js.map +1 -0
- package/build/esm/adapters/oxlint/structure-rule.js +37 -0
- package/build/esm/adapters/oxlint/structure-rule.js.map +1 -0
- package/build/esm/core/baseline.js +30 -0
- package/build/esm/core/baseline.js.map +1 -0
- package/build/esm/core/exports.js +96 -0
- package/build/esm/core/exports.js.map +1 -0
- package/build/esm/core/imports.js +85 -0
- package/build/esm/core/imports.js.map +1 -0
- package/build/esm/core/members.js +84 -0
- package/build/esm/core/members.js.map +1 -0
- package/build/esm/core/patterns.js +78 -0
- package/build/esm/core/patterns.js.map +1 -0
- package/build/esm/core/structure.js +256 -0
- package/build/esm/core/structure.js.map +1 -0
- package/build/esm/domain/architecture-config.js +172 -0
- package/build/esm/domain/architecture-config.js.map +1 -0
- package/build/esm/domain/architecture-error.js +29 -0
- package/build/esm/domain/architecture-error.js.map +1 -0
- package/build/esm/domain/violation.js +8 -0
- package/build/esm/domain/violation.js.map +1 -0
- package/build/esm/index.js +8 -0
- package/build/esm/index.js.map +1 -0
- package/build/esm/infrastructure/file-system-fake.js +5 -0
- package/build/esm/infrastructure/file-system-fake.js.map +1 -0
- package/build/esm/infrastructure/file-system-live.js +19 -0
- package/build/esm/infrastructure/file-system-live.js.map +1 -0
- package/build/esm/infrastructure/module-resolver-fake.js +23 -0
- package/build/esm/infrastructure/module-resolver-fake.js.map +1 -0
- package/build/esm/infrastructure/module-resolver-live.js +71 -0
- package/build/esm/infrastructure/module-resolver-live.js.map +1 -0
- package/build/esm/manifest/compile.js +503 -0
- package/build/esm/manifest/compile.js.map +1 -0
- package/build/esm/manifest/glob.js +77 -0
- package/build/esm/manifest/glob.js.map +1 -0
- package/build/esm/manifest/manifest.js +120 -0
- package/build/esm/manifest/manifest.js.map +1 -0
- package/build/esm/ports/file-system.js +2 -0
- package/build/esm/ports/file-system.js.map +1 -0
- package/build/esm/ports/module-resolver.js +2 -0
- package/build/esm/ports/module-resolver.js.map +1 -0
- package/package.json +73 -0
- package/src/adapters/cli/main.ts +20 -0
- package/src/adapters/cli/run.ts +270 -0
- package/src/adapters/cli/source-facts.ts +137 -0
- package/src/adapters/cli/source-files.ts +42 -0
- package/src/adapters/oxlint/config-loader.ts +136 -0
- package/src/adapters/oxlint/exports-rule.ts +144 -0
- package/src/adapters/oxlint/imports-rule.ts +67 -0
- package/src/adapters/oxlint/members-rule.ts +114 -0
- package/src/adapters/oxlint/oxlint-api.ts +25 -0
- package/src/adapters/oxlint/plugin.ts +35 -0
- package/src/adapters/oxlint/structure-rule.ts +39 -0
- package/src/core/baseline.ts +62 -0
- package/src/core/exports.ts +164 -0
- package/src/core/imports.ts +138 -0
- package/src/core/members.ts +131 -0
- package/src/core/patterns.ts +122 -0
- package/src/core/structure.ts +348 -0
- package/src/domain/architecture-config.ts +206 -0
- package/src/domain/architecture-error.ts +43 -0
- package/src/domain/violation.ts +24 -0
- package/src/index.ts +28 -0
- package/src/infrastructure/file-system-fake.ts +6 -0
- package/src/infrastructure/file-system-live.ts +20 -0
- package/src/infrastructure/module-resolver-fake.ts +29 -0
- package/src/infrastructure/module-resolver-live.ts +100 -0
- package/src/manifest/compile.ts +736 -0
- package/src/manifest/glob.ts +108 -0
- package/src/manifest/manifest.ts +178 -0
- package/src/ports/file-system.ts +5 -0
- package/src/ports/module-resolver.ts +23 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import * as Result from "effect/Result";
|
|
2
|
+
import * as Schema from "effect/Schema";
|
|
3
|
+
import { ResolveConfig } from "../domain/architecture-config.js";
|
|
4
|
+
import { ConfigInvalid } from "../domain/architecture-error.js";
|
|
5
|
+
// A manifest is a tree of nodes keyed by path pattern, where everything the
|
|
6
|
+
// architecture says about a part of the tree is written at that part of the tree.
|
|
7
|
+
// A node is a folder (it has `children` and/or `files`) or a file (its key names
|
|
8
|
+
// a filename pattern).
|
|
9
|
+
//
|
|
10
|
+
// The default is tight: a folder admits only the children it lists, and a node
|
|
11
|
+
// may import only what it or an ancestor allows. Laxity is opted into, by name,
|
|
12
|
+
// at the node that wants it.
|
|
13
|
+
const Globs = Schema.Union([Schema.String, Schema.Array(Schema.String)]);
|
|
14
|
+
// A prohibition. `match`/`matchNot` describe the target; `except` names the
|
|
15
|
+
// importers it does not apply to. The exemption is declared by the author of the
|
|
16
|
+
// prohibition, in the same breath — a descendant still cannot opt itself out.
|
|
17
|
+
const Denial = Schema.Struct({
|
|
18
|
+
match: Globs,
|
|
19
|
+
matchNot: Schema.optionalKey(Globs),
|
|
20
|
+
except: Schema.optionalKey(Globs),
|
|
21
|
+
message: Schema.String,
|
|
22
|
+
});
|
|
23
|
+
// Outbound: what this part of the tree may reach.
|
|
24
|
+
const Imports = Schema.Struct({
|
|
25
|
+
message: Schema.optionalKey(Schema.String),
|
|
26
|
+
// Repo-relative globs, after alias expansion. A target matching none of these
|
|
27
|
+
// is the violation — so widening is something you do by name, in the open.
|
|
28
|
+
allow: Schema.optionalKey(Globs),
|
|
29
|
+
// npm package names. Omit to inherit; `[]` to forbid every external.
|
|
30
|
+
external: Schema.optionalKey(Schema.Array(Schema.String)),
|
|
31
|
+
// Checked before `allow`, and wins over it. This is how a rule keeps a
|
|
32
|
+
// specific, better message for a mistake the allowlist would also catch.
|
|
33
|
+
deny: Schema.optionalKey(Schema.Array(Denial)),
|
|
34
|
+
// The one widening mechanism: stop inheriting ancestors' allowances. Inherited
|
|
35
|
+
// `deny` entries are unaffected — a prohibition only ever accumulates.
|
|
36
|
+
reset: Schema.optionalKey(Schema.Boolean),
|
|
37
|
+
// "This tier has no allowlist yet; only the prohibitions below apply." Implies
|
|
38
|
+
// `reset`. Required whenever a node states `imports` without an `allow`, so
|
|
39
|
+
// that an untightened tier is a sentence someone wrote rather than a gap in
|
|
40
|
+
// the config — and so `grep unrestricted` is the adoption backlog.
|
|
41
|
+
unrestricted: Schema.optionalKey(Schema.Boolean),
|
|
42
|
+
});
|
|
43
|
+
// Inbound: who may reach this part of the tree. Four of the rules governing
|
|
44
|
+
// `domain/` are of this shape — "*.root-ops.ts is private to command handlers" —
|
|
45
|
+
// and stating them here is what stops them being a distant rule with a growing
|
|
46
|
+
// exclusion list on its `from` side.
|
|
47
|
+
const ImportedBy = Schema.Struct({
|
|
48
|
+
message: Schema.String,
|
|
49
|
+
allow: Globs,
|
|
50
|
+
// Targets inside this node's subtree the restriction does not cover — the
|
|
51
|
+
// module barrel is the obvious one: a module is private except through it.
|
|
52
|
+
matchNot: Schema.optionalKey(Globs),
|
|
53
|
+
});
|
|
54
|
+
// What shape the variable part of a name must have. A folder's `children` keys
|
|
55
|
+
// already say which stereotypes it admits; this says what the concept name in
|
|
56
|
+
// front of the stereotype may look like, which is the degree of freedom a
|
|
57
|
+
// taxonomy alone leaves open.
|
|
58
|
+
//
|
|
59
|
+
// `like` names an ancestor capture the name must equal — a subdomain folder's
|
|
60
|
+
// root is named after the folder, and nothing else could say so.
|
|
61
|
+
const Naming = Schema.Union([
|
|
62
|
+
Schema.Literals(["kebab-case", "camelCase", "PascalCase", "snake_case"]),
|
|
63
|
+
Schema.Struct({ regex: Schema.String, message: Schema.optionalKey(Schema.String) }),
|
|
64
|
+
Schema.Struct({ like: Schema.String, message: Schema.optionalKey(Schema.String) }),
|
|
65
|
+
]);
|
|
66
|
+
const Members = Schema.Struct({
|
|
67
|
+
message: Schema.String,
|
|
68
|
+
subject: Schema.Literals(["type-members", "calls"]),
|
|
69
|
+
in: Schema.optionalKey(Globs),
|
|
70
|
+
match: Schema.optionalKey(Globs),
|
|
71
|
+
matchNot: Schema.optionalKey(Globs),
|
|
72
|
+
allow: Schema.optionalKey(Globs),
|
|
73
|
+
});
|
|
74
|
+
// Which importers may name a given exported symbol. A path rule cannot say
|
|
75
|
+
// this: every importer of a barrel resolves to the same file, so only the
|
|
76
|
+
// imported name separates a bus factory from the Tag beside it.
|
|
77
|
+
const ExportRestriction = Schema.Struct({
|
|
78
|
+
name: Schema.String,
|
|
79
|
+
message: Schema.String,
|
|
80
|
+
// The module the symbols come from, matched against its RESOLVED path.
|
|
81
|
+
module: Globs,
|
|
82
|
+
// Exact exported names. Omit to mean "any named import from that module",
|
|
83
|
+
// which is how a rule bans a binding form rather than a name.
|
|
84
|
+
symbols: Schema.optionalKey(Schema.Array(Schema.String)),
|
|
85
|
+
except: Schema.optionalKey(Globs),
|
|
86
|
+
fix: Schema.optionalKey(Schema.Literal("subpath-namespace-import")),
|
|
87
|
+
});
|
|
88
|
+
const ManifestNodeSchema = Schema.suspend(() => Schema.Struct({
|
|
89
|
+
message: Schema.optionalKey(Schema.String),
|
|
90
|
+
partial: Schema.optionalKey(Schema.Boolean),
|
|
91
|
+
layout: Schema.optionalKey(Schema.Literal("open")),
|
|
92
|
+
name: Schema.optionalKey(Naming),
|
|
93
|
+
imports: Schema.optionalKey(Imports),
|
|
94
|
+
importedBy: Schema.optionalKey(ImportedBy),
|
|
95
|
+
members: Schema.optionalKey(Schema.Array(Members)),
|
|
96
|
+
requires: Schema.optionalKey(Schema.Array(Schema.String)),
|
|
97
|
+
requiresNot: Schema.optionalKey(Schema.Array(Schema.String)),
|
|
98
|
+
children: Schema.optionalKey(Schema.Record(Schema.String, ManifestNodeSchema)),
|
|
99
|
+
}));
|
|
100
|
+
export const Manifest = Schema.Struct({
|
|
101
|
+
// How an import specifier becomes a file. Every pattern below is matched
|
|
102
|
+
// against a resolved path, so this is what makes the rest of the file mean
|
|
103
|
+
// anything.
|
|
104
|
+
resolve: ResolveConfig,
|
|
105
|
+
baseline: Schema.optionalKey(Schema.String),
|
|
106
|
+
// Prohibitions that hold everywhere, declared once. `not-to-spec` and the
|
|
107
|
+
// "this driver lives in one package" rules are statements about the whole
|
|
108
|
+
// repo, not about a tier — putting them on every tree root would be six
|
|
109
|
+
// copies and a seventh forgotten.
|
|
110
|
+
deny: Schema.optionalKey(Schema.Array(Denial)),
|
|
111
|
+
exports: Schema.optionalKey(Schema.Array(ExportRestriction)),
|
|
112
|
+
// Shorthands expanded in every glob, so a pattern reads the way the repo's own
|
|
113
|
+
// imports do rather than repeating `packages/server/src` on every line.
|
|
114
|
+
aliases: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)),
|
|
115
|
+
tree: Schema.Record(Schema.String, ManifestNodeSchema),
|
|
116
|
+
});
|
|
117
|
+
export const globsOf = (globs) => typeof globs === "string" ? [globs] : globs;
|
|
118
|
+
const decode = Schema.decodeUnknownResult(Manifest);
|
|
119
|
+
export const decodeManifest = (configPath, input) => Result.mapError(decode(input), (issue) => new ConfigInvalid({ configPath, detail: String(issue) }));
|
|
120
|
+
//# sourceMappingURL=manifest.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"manifest.js","sourceRoot":"","sources":["../../../src/manifest/manifest.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"file-system.js","sourceRoot":"","sources":["../../../src/ports/file-system.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"module-resolver.js","sourceRoot":"","sources":["../../../src/ports/module-resolver.ts"],"names":[],"mappings":""}
|
package/package.json
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "oxlint-architecture-rules",
|
|
3
|
+
"version": "0.1.0-beta.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"description": "Architecture policy as configuration, enforced as one oxlint plugin: import boundaries over resolved module paths, restricted export sites, folder taxonomy with sibling parity, and declared-member allowlists.",
|
|
7
|
+
"author": "zacharyweidenbach",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"oxlint",
|
|
10
|
+
"oxlint-plugin",
|
|
11
|
+
"architecture",
|
|
12
|
+
"lint",
|
|
13
|
+
"import-boundaries",
|
|
14
|
+
"hexagonal",
|
|
15
|
+
"monorepo",
|
|
16
|
+
"typescript"
|
|
17
|
+
],
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public"
|
|
20
|
+
},
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/dataquail/oxlint-utils.git",
|
|
24
|
+
"directory": "packages/oxlint-architecture-rules"
|
|
25
|
+
},
|
|
26
|
+
"homepage": "https://dataquail.github.io/oxlint-utils",
|
|
27
|
+
"bugs": {
|
|
28
|
+
"url": "https://github.com/dataquail/oxlint-utils/issues"
|
|
29
|
+
},
|
|
30
|
+
"exports": {
|
|
31
|
+
".": {
|
|
32
|
+
"types": "./build/dts/index.d.ts",
|
|
33
|
+
"default": "./build/esm/index.js"
|
|
34
|
+
},
|
|
35
|
+
"./plugin": {
|
|
36
|
+
"types": "./build/dts/adapters/oxlint/plugin.d.ts",
|
|
37
|
+
"default": "./build/esm/adapters/oxlint/plugin.js"
|
|
38
|
+
},
|
|
39
|
+
"./package.json": "./package.json"
|
|
40
|
+
},
|
|
41
|
+
"types": "./build/dts/index.d.ts",
|
|
42
|
+
"bin": {
|
|
43
|
+
"architecture": "./build/esm/adapters/cli/main.js"
|
|
44
|
+
},
|
|
45
|
+
"files": [
|
|
46
|
+
"build/dts",
|
|
47
|
+
"build/esm",
|
|
48
|
+
"src",
|
|
49
|
+
"!src/**/*.test.ts"
|
|
50
|
+
],
|
|
51
|
+
"dependencies": {
|
|
52
|
+
"effect": "4.0.0-beta.94",
|
|
53
|
+
"typescript": "^5.9.3",
|
|
54
|
+
"unrs-resolver": "^1.12.2"
|
|
55
|
+
},
|
|
56
|
+
"peerDependencies": {
|
|
57
|
+
"oxlint": ">=1.77.0"
|
|
58
|
+
},
|
|
59
|
+
"devDependencies": {
|
|
60
|
+
"@types/node": "^25.9.4",
|
|
61
|
+
"oxlint": "1.77.0"
|
|
62
|
+
},
|
|
63
|
+
"nx": {
|
|
64
|
+
"includedScripts": []
|
|
65
|
+
},
|
|
66
|
+
"scripts": {
|
|
67
|
+
"build": "tsc -b tsconfig.build.json",
|
|
68
|
+
"check": "tsc -b tsconfig.json",
|
|
69
|
+
"clean": "rm -rf build .tsbuildinfo",
|
|
70
|
+
"test": "vitest run",
|
|
71
|
+
"coverage": "vitest run --coverage"
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import * as Cause from "effect/Cause";
|
|
3
|
+
import * as Effect from "effect/Effect";
|
|
4
|
+
import * as Exit from "effect/Exit";
|
|
5
|
+
|
|
6
|
+
import { run } from "./run.js";
|
|
7
|
+
|
|
8
|
+
const exit = await Effect.runPromiseExit(run(process.cwd(), process.argv.slice(2)));
|
|
9
|
+
|
|
10
|
+
if (Exit.isFailure(exit)) {
|
|
11
|
+
// The expected failures carry their own sentence; anything else is a defect
|
|
12
|
+
// and deserves the full cause.
|
|
13
|
+
const squashed = Cause.squash(exit.cause);
|
|
14
|
+
const message =
|
|
15
|
+
typeof squashed === "object" && squashed !== null && "message" in squashed
|
|
16
|
+
? String(squashed.message)
|
|
17
|
+
: Cause.pretty(exit.cause);
|
|
18
|
+
process.stderr.write(`\n${message}\n`);
|
|
19
|
+
process.exitCode = 1;
|
|
20
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
|
|
4
|
+
import * as Effect from "effect/Effect";
|
|
5
|
+
import * as Result from "effect/Result";
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
type Baseline,
|
|
9
|
+
baselineOf,
|
|
10
|
+
decodeBaseline,
|
|
11
|
+
EMPTY_BASELINE,
|
|
12
|
+
serializeBaseline,
|
|
13
|
+
staleEntriesOf,
|
|
14
|
+
unbaselined,
|
|
15
|
+
} from "../../core/baseline.js";
|
|
16
|
+
import { evaluateSelectedBindings, exportRulesSelecting } from "../../core/exports.js";
|
|
17
|
+
import { evaluateSelectedEdge, rulesSelecting } from "../../core/imports.js";
|
|
18
|
+
import { evaluateMemberSite, memberRulesSelecting } from "../../core/members.js";
|
|
19
|
+
import { evaluateStructure, requiredSiblingsOf } from "../../core/structure.js";
|
|
20
|
+
import { fingerprintOf, formatMessage, type Violation } from "../../domain/violation.js";
|
|
21
|
+
import { type LoadedPolicy, loadPolicy } from "../oxlint/config-loader.js";
|
|
22
|
+
import { sourceFactsOf } from "./source-facts.js";
|
|
23
|
+
import { listSourceFiles } from "./source-files.js";
|
|
24
|
+
|
|
25
|
+
// The policy, run with no linter in the loop.
|
|
26
|
+
//
|
|
27
|
+
// oxlint's JS plugin API is alpha, and a policy that can only be evaluated by
|
|
28
|
+
// one alpha host is a policy with a single point of failure. This adapter is the
|
|
29
|
+
// second way to ask the same question — and the only way to write a baseline,
|
|
30
|
+
// since that needs every finding at once rather than one file at a time.
|
|
31
|
+
//
|
|
32
|
+
// It covers all four families. The two that need a syntax tree read TypeScript's
|
|
33
|
+
// rather than oxlint's; both adapters meet at the same vocabulary — a specifier,
|
|
34
|
+
// a binding, a member site — so they answer to the same core rather than to each
|
|
35
|
+
// other.
|
|
36
|
+
|
|
37
|
+
export type CliFailure = { readonly _tag: "CliFailure"; readonly message: string };
|
|
38
|
+
|
|
39
|
+
const fail = (message: string): CliFailure => ({ _tag: "CliFailure", message });
|
|
40
|
+
|
|
41
|
+
export type Findings = {
|
|
42
|
+
readonly violations: ReadonlyArray<Violation>;
|
|
43
|
+
readonly unresolved: ReadonlyArray<string>;
|
|
44
|
+
readonly files: number;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export const collectFindings = (policy: LoadedPolicy, roots: ReadonlyArray<string>): Findings => {
|
|
48
|
+
const files = listSourceFiles(policy.repoRoot, roots);
|
|
49
|
+
const violations: Array<Violation> = [];
|
|
50
|
+
const unresolved: Array<string> = [];
|
|
51
|
+
|
|
52
|
+
for (const file of files) {
|
|
53
|
+
for (const violation of evaluateStructure(policy.structure, policy.fileSystem, file)) {
|
|
54
|
+
violations.push(violation);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const selectedImports = rulesSelecting(policy.importRules, file);
|
|
58
|
+
const selectedExports = exportRulesSelecting(policy.exportRules, file);
|
|
59
|
+
const selectedMembers = memberRulesSelecting(policy.memberRules, file);
|
|
60
|
+
if (selectedImports.length + selectedExports.length + selectedMembers.length === 0) continue;
|
|
61
|
+
|
|
62
|
+
const facts = sourceFactsOf(policy.repoRoot, file);
|
|
63
|
+
|
|
64
|
+
for (const site of facts.memberSites) {
|
|
65
|
+
for (const violation of evaluateMemberSite(selectedMembers, site)) violations.push(violation);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
for (const specifier of facts.specifiers) {
|
|
69
|
+
const edge = { importer: file, specifier };
|
|
70
|
+
|
|
71
|
+
const imported = evaluateSelectedEdge(selectedImports, policy.resolver, edge);
|
|
72
|
+
if (Result.isFailure(imported)) {
|
|
73
|
+
if (policy.config.resolve.unresolved === "off") continue;
|
|
74
|
+
if (policy.ignoreUnresolved.some((pattern) => pattern.test(specifier))) continue;
|
|
75
|
+
unresolved.push(`${file} → ${specifier} (${imported.failure.detail})`);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
for (const violation of imported.success) violations.push(violation);
|
|
79
|
+
|
|
80
|
+
const bound = facts.bindings.get(specifier) ?? [];
|
|
81
|
+
const exported = evaluateSelectedBindings(selectedExports, policy.resolver, {
|
|
82
|
+
...edge,
|
|
83
|
+
bindings: bound,
|
|
84
|
+
});
|
|
85
|
+
if (!Result.isFailure(exported)) {
|
|
86
|
+
for (const { violation } of exported.success) violations.push(violation);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return { violations, unresolved, files: files.length };
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const baselinePathOf = (policy: LoadedPolicy): string | null =>
|
|
95
|
+
policy.config.baseline === undefined
|
|
96
|
+
? null
|
|
97
|
+
: path.resolve(policy.repoRoot, policy.config.baseline);
|
|
98
|
+
|
|
99
|
+
const readBaseline = (policy: LoadedPolicy): Baseline => {
|
|
100
|
+
const at = baselinePathOf(policy);
|
|
101
|
+
if (at === null) return EMPTY_BASELINE;
|
|
102
|
+
try {
|
|
103
|
+
return decodeBaseline(JSON.parse(readFileSync(at, "utf8")) as unknown);
|
|
104
|
+
} catch {
|
|
105
|
+
return EMPTY_BASELINE;
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const report = (lines: ReadonlyArray<string>): Effect.Effect<void> =>
|
|
110
|
+
Effect.sync(() => {
|
|
111
|
+
for (const line of lines) process.stdout.write(`${line}\n`);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const describe = (violation: Violation): string =>
|
|
115
|
+
` ${violation.file}\n ${formatMessage(violation)}`;
|
|
116
|
+
|
|
117
|
+
export const check = (
|
|
118
|
+
policy: LoadedPolicy,
|
|
119
|
+
roots: ReadonlyArray<string>,
|
|
120
|
+
): Effect.Effect<void, CliFailure> =>
|
|
121
|
+
Effect.gen(function* () {
|
|
122
|
+
const findings = collectFindings(policy, roots);
|
|
123
|
+
const baseline = readBaseline(policy);
|
|
124
|
+
const reportable = unbaselined(baseline, findings.violations);
|
|
125
|
+
const stale = staleEntriesOf(baseline, findings.violations);
|
|
126
|
+
|
|
127
|
+
yield* report(reportable.map(describe));
|
|
128
|
+
yield* report(findings.unresolved.map((one) => ` unresolved: ${one}`));
|
|
129
|
+
|
|
130
|
+
const carried = findings.violations.length - reportable.length;
|
|
131
|
+
yield* report([
|
|
132
|
+
"",
|
|
133
|
+
`${String(findings.files)} files, ${String(reportable.length)} violations` +
|
|
134
|
+
(carried > 0 ? `, ${String(carried)} carried by the baseline` : ""),
|
|
135
|
+
]);
|
|
136
|
+
|
|
137
|
+
if (stale.length > 0) {
|
|
138
|
+
// The ratchet: a fixed violation must leave the baseline, or the floor
|
|
139
|
+
// never rises and the file stops describing anything real.
|
|
140
|
+
yield* report([
|
|
141
|
+
"",
|
|
142
|
+
`${String(stale.length)} baseline entries no longer fire. The code was fixed; prune them:`,
|
|
143
|
+
...stale.map((entry) => ` ${entry}`),
|
|
144
|
+
"",
|
|
145
|
+
" architecture baseline # rewrites the file from what still fires",
|
|
146
|
+
]);
|
|
147
|
+
return yield* Effect.fail(fail("stale baseline entries"));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (reportable.length > 0 || findings.unresolved.length > 0) {
|
|
151
|
+
return yield* Effect.fail(fail("architecture violations"));
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
export const writeBaseline = (
|
|
156
|
+
policy: LoadedPolicy,
|
|
157
|
+
roots: ReadonlyArray<string>,
|
|
158
|
+
): Effect.Effect<void, CliFailure> =>
|
|
159
|
+
Effect.gen(function* () {
|
|
160
|
+
const at = baselinePathOf(policy);
|
|
161
|
+
if (at === null) {
|
|
162
|
+
return yield* Effect.fail(
|
|
163
|
+
fail("this policy declares no `baseline` path, so there is nowhere to write one"),
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const findings = collectFindings(policy, roots);
|
|
168
|
+
const baseline = baselineOf(findings.violations);
|
|
169
|
+
yield* Effect.sync(() => {
|
|
170
|
+
writeFileSync(at, serializeBaseline(baseline));
|
|
171
|
+
});
|
|
172
|
+
yield* report([
|
|
173
|
+
`${String(baseline.entries.length)} violations recorded in ${path.relative(policy.repoRoot, at)}.`,
|
|
174
|
+
"Each one is debt the policy is carrying. Fixing one means deleting its line.",
|
|
175
|
+
]);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// The question a tree config makes harder to answer than a flat one: given a
|
|
179
|
+
// file, what governs it? A flat config you grep; a tree you have to walk.
|
|
180
|
+
export const explain = (policy: LoadedPolicy, file: string): Effect.Effect<void, CliFailure> =>
|
|
181
|
+
Effect.gen(function* () {
|
|
182
|
+
const relative = path.relative(policy.repoRoot, path.resolve(policy.repoRoot, file));
|
|
183
|
+
const selected = rulesSelecting(policy.importRules, relative);
|
|
184
|
+
|
|
185
|
+
// An allowlist rule names no `to` — it fires when the target matches none of
|
|
186
|
+
// its patterns. A prohibition names one.
|
|
187
|
+
const allowlists = selected.filter(([rule]) => rule.to.length === 0 && rule.toNot.length > 0);
|
|
188
|
+
const prohibitions = selected.filter(([rule]) => rule.to.length > 0);
|
|
189
|
+
|
|
190
|
+
const owed = policy.structure.parity
|
|
191
|
+
.filter(
|
|
192
|
+
(rule) =>
|
|
193
|
+
rule.file.some((pattern) => pattern.test(relative)) &&
|
|
194
|
+
!rule.fileNot.some((pattern) => pattern.test(relative)),
|
|
195
|
+
)
|
|
196
|
+
.flatMap((rule) => requiredSiblingsOf(rule, relative));
|
|
197
|
+
|
|
198
|
+
const governing = policy.structure.folders.filter((rule) =>
|
|
199
|
+
rule.folder.some((pattern) => pattern.test(path.dirname(relative))),
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
const naming = policy.structure.naming.filter(
|
|
203
|
+
(rule) =>
|
|
204
|
+
rule.file.some((pattern) => pattern.test(relative)) &&
|
|
205
|
+
!rule.fileNot.some((pattern) => pattern.test(relative)),
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
const firstSentence = (message: string) => `${message.split(". ")[0] ?? message}.`;
|
|
209
|
+
|
|
210
|
+
yield* report([
|
|
211
|
+
relative,
|
|
212
|
+
"",
|
|
213
|
+
allowlists.length === 0
|
|
214
|
+
? " may import: anything (no tier above this file states an allowlist)"
|
|
215
|
+
: " may import:",
|
|
216
|
+
...allowlists.flatMap(([rule]) => [
|
|
217
|
+
` — ${rule.name}`,
|
|
218
|
+
...rule.toNot.map((pattern) => ` ${pattern}`),
|
|
219
|
+
]),
|
|
220
|
+
"",
|
|
221
|
+
" may not import:",
|
|
222
|
+
...(prohibitions.length === 0
|
|
223
|
+
? [" (nothing beyond the allowlist above)"]
|
|
224
|
+
: prohibitions.map(([rule]) => ` ${rule.name} — ${firstSentence(rule.message)}`)),
|
|
225
|
+
"",
|
|
226
|
+
` lives in: ${governing.length === 0 ? "a folder no rule governs" : governing.map((rule) => rule.name).join(", ")}`,
|
|
227
|
+
...(naming.length === 0
|
|
228
|
+
? []
|
|
229
|
+
: [
|
|
230
|
+
" is named by:",
|
|
231
|
+
...naming.map(
|
|
232
|
+
(rule) =>
|
|
233
|
+
` ${rule.name} — ${rule.sameAs !== null ? "its folder's own name" : (rule.convention?.source ?? "")}`,
|
|
234
|
+
),
|
|
235
|
+
]),
|
|
236
|
+
...(owed.length === 0 ? [] : [" owes:", ...owed.map((one) => ` ${one}`)]),
|
|
237
|
+
]);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
export const run = (
|
|
241
|
+
repoRoot: string,
|
|
242
|
+
argv: ReadonlyArray<string>,
|
|
243
|
+
): Effect.Effect<void, CliFailure> =>
|
|
244
|
+
Effect.gen(function* () {
|
|
245
|
+
const [command = "check", ...rest] = argv;
|
|
246
|
+
const policy = yield* Effect.tryPromise({
|
|
247
|
+
try: () => loadPolicy(repoRoot),
|
|
248
|
+
catch: (cause) => fail(String(cause)),
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
const roots = rest.length > 0 ? rest : ["packages"];
|
|
252
|
+
|
|
253
|
+
switch (command) {
|
|
254
|
+
case "check":
|
|
255
|
+
return yield* check(policy, roots);
|
|
256
|
+
case "baseline":
|
|
257
|
+
return yield* writeBaseline(policy, roots);
|
|
258
|
+
case "explain": {
|
|
259
|
+
const [file] = rest;
|
|
260
|
+
if (file === undefined) return yield* Effect.fail(fail("explain needs a file path"));
|
|
261
|
+
return yield* explain(policy, file);
|
|
262
|
+
}
|
|
263
|
+
default:
|
|
264
|
+
return yield* Effect.fail(
|
|
265
|
+
fail(`unknown command "${command}". Try: check | baseline | explain <file>`),
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
export const fingerprint = fingerprintOf;
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
|
|
4
|
+
import ts from "typescript";
|
|
5
|
+
|
|
6
|
+
import type { Binding } from "../../core/exports.js";
|
|
7
|
+
import type { MemberSite } from "../../core/members.js";
|
|
8
|
+
|
|
9
|
+
// Everything the policy needs to know about one file, read once.
|
|
10
|
+
//
|
|
11
|
+
// The oxlint adapter gets these facts from oxlint's AST; this one reads them
|
|
12
|
+
// from TypeScript's. They meet at the same vocabulary — a specifier, a binding,
|
|
13
|
+
// a member site — which is what keeps the two adapters answerable to the same
|
|
14
|
+
// core rather than to each other.
|
|
15
|
+
export type SourceFacts = {
|
|
16
|
+
// One entry per import edge, in source order. An edge appears once even if it
|
|
17
|
+
// carries several bindings.
|
|
18
|
+
readonly specifiers: ReadonlyArray<string>;
|
|
19
|
+
// The names pulled across each edge, keyed by specifier.
|
|
20
|
+
readonly bindings: ReadonlyMap<string, ReadonlyArray<Binding>>;
|
|
21
|
+
readonly memberSites: ReadonlyArray<MemberSite>;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const scriptKindOf = (file: string): ts.ScriptKind =>
|
|
25
|
+
file.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
|
|
26
|
+
|
|
27
|
+
const nameOf = (node: ts.PropertyName | ts.ModuleExportName | undefined): string | null => {
|
|
28
|
+
if (node === undefined) return null;
|
|
29
|
+
if (ts.isIdentifier(node)) return node.text;
|
|
30
|
+
if (ts.isStringLiteral(node)) return node.text;
|
|
31
|
+
return null;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const bindingsOfImportClause = (clause: ts.ImportClause | undefined): ReadonlyArray<Binding> => {
|
|
35
|
+
if (clause === undefined) return [];
|
|
36
|
+
const found: Array<Binding> = [];
|
|
37
|
+
if (clause.name !== undefined) found.push({ symbol: "default", kind: "default" });
|
|
38
|
+
const bindings = clause.namedBindings;
|
|
39
|
+
if (bindings !== undefined) {
|
|
40
|
+
if (ts.isNamespaceImport(bindings)) found.push({ symbol: "*", kind: "namespace" });
|
|
41
|
+
else {
|
|
42
|
+
for (const element of bindings.elements) {
|
|
43
|
+
const symbol = nameOf(element.propertyName ?? element.name);
|
|
44
|
+
if (symbol !== null) found.push({ symbol, kind: "named" });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return found;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// `export { a } from "m"` — `propertyName` is the name in the source module when
|
|
52
|
+
// the export is renamed, so it is the one the policy is about.
|
|
53
|
+
const bindingsOfExportClause = (
|
|
54
|
+
clause: ts.NamedExportBindings | undefined,
|
|
55
|
+
): ReadonlyArray<Binding> => {
|
|
56
|
+
if (clause === undefined || ts.isNamespaceExport(clause)) return [];
|
|
57
|
+
const found: Array<Binding> = [];
|
|
58
|
+
for (const element of clause.elements) {
|
|
59
|
+
const symbol = nameOf(element.propertyName ?? element.name);
|
|
60
|
+
if (symbol !== null) found.push({ symbol, kind: "named" });
|
|
61
|
+
}
|
|
62
|
+
return found;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const calleeNameOf = (expression: ts.LeftHandSideExpression): string | null => {
|
|
66
|
+
if (ts.isIdentifier(expression)) return expression.text;
|
|
67
|
+
if (ts.isPropertyAccessExpression(expression) && ts.isIdentifier(expression.name)) {
|
|
68
|
+
return expression.name.text;
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export const sourceFactsOf = (repoRoot: string, file: string): SourceFacts => {
|
|
74
|
+
const text = readFileSync(path.join(repoRoot, file), "utf8");
|
|
75
|
+
const parsed = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, scriptKindOf(file));
|
|
76
|
+
|
|
77
|
+
const specifiers: Array<string> = [];
|
|
78
|
+
const bindings = new Map<string, Array<Binding>>();
|
|
79
|
+
const memberSites: Array<MemberSite> = [];
|
|
80
|
+
|
|
81
|
+
const record = (specifier: string, found: ReadonlyArray<Binding>): void => {
|
|
82
|
+
if (!bindings.has(specifier)) {
|
|
83
|
+
specifiers.push(specifier);
|
|
84
|
+
bindings.set(specifier, []);
|
|
85
|
+
}
|
|
86
|
+
const existing = bindings.get(specifier);
|
|
87
|
+
if (existing !== undefined) for (const binding of found) existing.push(binding);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const visit = (node: ts.Node): void => {
|
|
91
|
+
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
|
|
92
|
+
// A side-effect import carries no bindings but is still an edge — the
|
|
93
|
+
// `import "server-only"` form a regex cannot see.
|
|
94
|
+
record(node.moduleSpecifier.text, bindingsOfImportClause(node.importClause));
|
|
95
|
+
} else if (
|
|
96
|
+
ts.isExportDeclaration(node) &&
|
|
97
|
+
node.moduleSpecifier !== undefined &&
|
|
98
|
+
ts.isStringLiteral(node.moduleSpecifier)
|
|
99
|
+
) {
|
|
100
|
+
record(node.moduleSpecifier.text, bindingsOfExportClause(node.exportClause));
|
|
101
|
+
} else if (
|
|
102
|
+
ts.isImportEqualsDeclaration(node) &&
|
|
103
|
+
ts.isExternalModuleReference(node.moduleReference) &&
|
|
104
|
+
ts.isStringLiteral(node.moduleReference.expression)
|
|
105
|
+
) {
|
|
106
|
+
record(node.moduleReference.expression.text, []);
|
|
107
|
+
} else if (ts.isCallExpression(node)) {
|
|
108
|
+
const [first] = node.arguments;
|
|
109
|
+
const isModuleCall =
|
|
110
|
+
node.expression.kind === ts.SyntaxKind.ImportKeyword ||
|
|
111
|
+
(ts.isIdentifier(node.expression) && node.expression.text === "require");
|
|
112
|
+
if (isModuleCall && first !== undefined && ts.isStringLiteral(first)) {
|
|
113
|
+
record(first.text, []);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const callee = calleeNameOf(node.expression);
|
|
117
|
+
if (callee !== null) memberSites.push({ file, subject: "calls", name: callee });
|
|
118
|
+
} else if (ts.isTypeAliasDeclaration(node) && ts.isTypeLiteralNode(node.type)) {
|
|
119
|
+
const declaration = node.name.text;
|
|
120
|
+
for (const member of node.type.members) {
|
|
121
|
+
// A computed key is not a name a vocabulary rule can speak about.
|
|
122
|
+
if (!ts.isPropertySignature(member) && !ts.isMethodSignature(member)) continue;
|
|
123
|
+
if (ts.isComputedPropertyName(member.name)) continue;
|
|
124
|
+
const name = nameOf(member.name);
|
|
125
|
+
if (name !== null) {
|
|
126
|
+
memberSites.push({ file, subject: "type-members", name, in: declaration });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
ts.forEachChild(node, visit);
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
visit(parsed);
|
|
135
|
+
|
|
136
|
+
return { specifiers, bindings, memberSites };
|
|
137
|
+
};
|