@goodbones/core 0.1.0-beta.1
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/core/baseline.d.ts +16 -0
- package/build/dts/core/baseline.d.ts.map +1 -0
- package/build/dts/core/coverage.d.ts +47 -0
- package/build/dts/core/coverage.d.ts.map +1 -0
- package/build/dts/core/exports.d.ts +38 -0
- package/build/dts/core/exports.d.ts.map +1 -0
- package/build/dts/core/graph.d.ts +40 -0
- package/build/dts/core/graph.d.ts.map +1 -0
- package/build/dts/core/imports.d.ts +29 -0
- package/build/dts/core/imports.d.ts.map +1 -0
- package/build/dts/core/members.d.ts +26 -0
- package/build/dts/core/members.d.ts.map +1 -0
- package/build/dts/core/patterns.d.ts +17 -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/core/surface.d.ts +39 -0
- package/build/dts/core/surface.d.ts.map +1 -0
- package/build/dts/domain/architecture-config.d.ts +322 -0
- package/build/dts/domain/architecture-config.d.ts.map +1 -0
- package/build/dts/domain/architecture-error.d.ts +35 -0
- package/build/dts/domain/architecture-error.d.ts.map +1 -0
- package/build/dts/domain/facts.d.ts +26 -0
- package/build/dts/domain/facts.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 +23 -0
- package/build/dts/index.d.ts.map +1 -0
- package/build/dts/infrastructure/fact-extractor-fake.d.ts +4 -0
- package/build/dts/infrastructure/fact-extractor-fake.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/manifest-file.d.ts +3 -0
- package/build/dts/infrastructure/manifest-file.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/walk.d.ts +4 -0
- package/build/dts/infrastructure/walk.d.ts.map +1 -0
- package/build/dts/load/policy.d.ts +42 -0
- package/build/dts/load/policy.d.ts.map +1 -0
- package/build/dts/manifest/compile.d.ts +26 -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 +208 -0
- package/build/dts/manifest/manifest.d.ts.map +1 -0
- package/build/dts/ports/fact-extractor.d.ts +5 -0
- package/build/dts/ports/fact-extractor.d.ts.map +1 -0
- package/build/dts/ports/file-system.d.ts +5 -0
- package/build/dts/ports/file-system.d.ts.map +1 -0
- package/build/dts/ports/language.d.ts +14 -0
- package/build/dts/ports/language.d.ts.map +1 -0
- package/build/dts/ports/module-resolver.d.ts +12 -0
- package/build/dts/ports/module-resolver.d.ts.map +1 -0
- package/build/dts/testing.d.ts +4 -0
- package/build/dts/testing.d.ts.map +1 -0
- package/build/esm/core/baseline.js +30 -0
- package/build/esm/core/baseline.js.map +1 -0
- package/build/esm/core/coverage.js +75 -0
- package/build/esm/core/coverage.js.map +1 -0
- package/build/esm/core/exports.js +102 -0
- package/build/esm/core/exports.js.map +1 -0
- package/build/esm/core/graph.js +259 -0
- package/build/esm/core/graph.js.map +1 -0
- package/build/esm/core/imports.js +103 -0
- package/build/esm/core/imports.js.map +1 -0
- package/build/esm/core/members.js +106 -0
- package/build/esm/core/members.js.map +1 -0
- package/build/esm/core/patterns.js +75 -0
- package/build/esm/core/patterns.js.map +1 -0
- package/build/esm/core/structure.js +257 -0
- package/build/esm/core/structure.js.map +1 -0
- package/build/esm/core/surface.js +137 -0
- package/build/esm/core/surface.js.map +1 -0
- package/build/esm/domain/architecture-config.js +312 -0
- package/build/esm/domain/architecture-config.js.map +1 -0
- package/build/esm/domain/architecture-error.js +41 -0
- package/build/esm/domain/architecture-error.js.map +1 -0
- package/build/esm/domain/facts.js +2 -0
- package/build/esm/domain/facts.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 +22 -0
- package/build/esm/index.js.map +1 -0
- package/build/esm/infrastructure/fact-extractor-fake.js +24 -0
- package/build/esm/infrastructure/fact-extractor-fake.js.map +1 -0
- package/build/esm/infrastructure/file-system-fake.js +10 -0
- package/build/esm/infrastructure/file-system-fake.js.map +1 -0
- package/build/esm/infrastructure/file-system-live.js +27 -0
- package/build/esm/infrastructure/file-system-live.js.map +1 -0
- package/build/esm/infrastructure/manifest-file.js +15 -0
- package/build/esm/infrastructure/manifest-file.js.map +1 -0
- package/build/esm/infrastructure/module-resolver-fake.js +33 -0
- package/build/esm/infrastructure/module-resolver-fake.js.map +1 -0
- package/build/esm/infrastructure/walk.js +45 -0
- package/build/esm/infrastructure/walk.js.map +1 -0
- package/build/esm/load/policy.js +207 -0
- package/build/esm/load/policy.js.map +1 -0
- package/build/esm/manifest/compile.js +706 -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 +303 -0
- package/build/esm/manifest/manifest.js.map +1 -0
- package/build/esm/ports/fact-extractor.js +2 -0
- package/build/esm/ports/fact-extractor.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/language.js +2 -0
- package/build/esm/ports/language.js.map +1 -0
- package/build/esm/ports/module-resolver.js +2 -0
- package/build/esm/ports/module-resolver.js.map +1 -0
- package/build/esm/testing.js +7 -0
- package/build/esm/testing.js.map +1 -0
- package/package.json +61 -0
- package/src/core/baseline.ts +62 -0
- package/src/core/coverage.ts +154 -0
- package/src/core/exports.ts +171 -0
- package/src/core/graph.ts +331 -0
- package/src/core/imports.ts +160 -0
- package/src/core/members.ts +161 -0
- package/src/core/patterns.ts +116 -0
- package/src/core/structure.ts +349 -0
- package/src/core/surface.ts +181 -0
- package/src/domain/architecture-config.ts +370 -0
- package/src/domain/architecture-error.ts +59 -0
- package/src/domain/facts.ts +47 -0
- package/src/domain/violation.ts +24 -0
- package/src/index.ts +136 -0
- package/src/infrastructure/fact-extractor-fake.ts +28 -0
- package/src/infrastructure/file-system-fake.ts +14 -0
- package/src/infrastructure/file-system-live.ts +27 -0
- package/src/infrastructure/manifest-file.ts +17 -0
- package/src/infrastructure/module-resolver-fake.ts +39 -0
- package/src/infrastructure/walk.ts +54 -0
- package/src/load/policy.ts +344 -0
- package/src/manifest/compile.ts +1011 -0
- package/src/manifest/glob.ts +108 -0
- package/src/manifest/manifest.ts +408 -0
- package/src/ports/fact-extractor.ts +12 -0
- package/src/ports/file-system.ts +8 -0
- package/src/ports/language.ts +39 -0
- package/src/ports/module-resolver.ts +28 -0
- package/src/testing.ts +6 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// The manifest is meant to read like a filesystem, so its patterns are globs
|
|
2
|
+
// rather than the regexes the rule IR speaks. This is the one translation.
|
|
3
|
+
//
|
|
4
|
+
// * one path segment, or part of one
|
|
5
|
+
// ** any number of segments
|
|
6
|
+
// /** "…or nothing" — `a/**` matches `a` itself as well as `a/b/c`
|
|
7
|
+
// {name} a named capture of one segment when it appears in a tree key;
|
|
8
|
+
// elsewhere, a back-reference to the ancestor that declared it
|
|
9
|
+
// [A-Z] a character class, passed through as written — the one place a
|
|
10
|
+
// pattern needs to say something about a single character
|
|
11
|
+
//
|
|
12
|
+
// Everything else is literal, including the dots that make up a stereotype.
|
|
13
|
+
|
|
14
|
+
const ESCAPE = /[.+^$()|[\]\\]/g;
|
|
15
|
+
|
|
16
|
+
// Split on the tokens first so `**` is not read as two `*`s, `{name}`'s braces
|
|
17
|
+
// are not escaped, and a character class keeps its brackets.
|
|
18
|
+
const TOKEN_SOURCE = "\\[\\^?[^\\]]+\\]|\\{[a-zA-Z][a-zA-Z0-9]*\\}|\\*\\*|\\*|\\?";
|
|
19
|
+
|
|
20
|
+
export type CaptureIndex = Readonly<Record<string, number>>;
|
|
21
|
+
|
|
22
|
+
const escapeLiteral = (value: string): string => value.replace(ESCAPE, "\\$&");
|
|
23
|
+
|
|
24
|
+
const TOKENS = new RegExp(`(${TOKEN_SOURCE})`, "g");
|
|
25
|
+
|
|
26
|
+
export type GlobCompilation = {
|
|
27
|
+
readonly source: string;
|
|
28
|
+
readonly captures: CaptureIndex;
|
|
29
|
+
// Group indices for the `*`s, in source order, when `capturing` asked for
|
|
30
|
+
// them. A naming rule judges what one of these matched.
|
|
31
|
+
readonly wildcards: ReadonlyArray<number>;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// `declaring` compiles `{name}` to a new capture group and records its position;
|
|
35
|
+
// otherwise `{name}` becomes a `$n` back-reference to the group an ancestor
|
|
36
|
+
// declared, which is what the rule IR substitutes at match time.
|
|
37
|
+
export const globToRegexSource = (
|
|
38
|
+
glob: string,
|
|
39
|
+
captures: CaptureIndex,
|
|
40
|
+
options: {
|
|
41
|
+
readonly declaring: boolean;
|
|
42
|
+
readonly nextGroup: number;
|
|
43
|
+
// Compile `*` to a capture rather than to `[^/]*`, so a caller can ask what
|
|
44
|
+
// the variable part of a name actually was. Off everywhere else: a stray
|
|
45
|
+
// group would renumber the back-references `{capture}` compiles to.
|
|
46
|
+
readonly capturing?: boolean;
|
|
47
|
+
},
|
|
48
|
+
): GlobCompilation => {
|
|
49
|
+
const declared: Record<string, number> = { ...captures };
|
|
50
|
+
const wildcards: Array<number> = [];
|
|
51
|
+
let group = options.nextGroup;
|
|
52
|
+
let out = "";
|
|
53
|
+
|
|
54
|
+
for (const part of glob.split(TOKENS)) {
|
|
55
|
+
if (part === "") continue;
|
|
56
|
+
|
|
57
|
+
if (part === "**") {
|
|
58
|
+
out += ".*";
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (part === "*") {
|
|
62
|
+
if (options.capturing === true) {
|
|
63
|
+
wildcards.push(group);
|
|
64
|
+
out += "([^/]*)";
|
|
65
|
+
group += 1;
|
|
66
|
+
} else out += "[^/]*";
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (part === "?") {
|
|
70
|
+
out += "[^/]";
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// A character class is the one construct that passes through as written.
|
|
75
|
+
if (part.startsWith("[") && part.endsWith("]")) {
|
|
76
|
+
out += part;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const capture = /^\{([a-zA-Z][a-zA-Z0-9]*)\}$/.exec(part);
|
|
81
|
+
if (capture !== null) {
|
|
82
|
+
const name = capture[1] ?? "";
|
|
83
|
+
if (options.declaring && declared[name] === undefined) {
|
|
84
|
+
declared[name] = group;
|
|
85
|
+
out += "([^/]+)";
|
|
86
|
+
group += 1;
|
|
87
|
+
} else {
|
|
88
|
+
const index = declared[name];
|
|
89
|
+
if (index === undefined) {
|
|
90
|
+
throw new Error(
|
|
91
|
+
`pattern "${glob}" references {${name}}, which no ancestor path declares`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
out += `$${String(index)}`;
|
|
95
|
+
}
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
out += escapeLiteral(part);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// `a/**` should match `a` itself, not just its descendants.
|
|
103
|
+
return { source: out.replace(/\/\.\*$/, "(/.*)?"), captures: declared, wildcards };
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
export const anchored = (source: string): string => `^${source}$`;
|
|
107
|
+
|
|
108
|
+
export const prefixed = (source: string): string => `^${source}`;
|
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
import * as Result from "effect/Result";
|
|
2
|
+
import * as Schema from "effect/Schema";
|
|
3
|
+
|
|
4
|
+
import { DeclarationKind, ResolveConfig } from "../domain/architecture-config.js";
|
|
5
|
+
import { ConfigInvalid } from "../domain/architecture-error.js";
|
|
6
|
+
|
|
7
|
+
// A manifest is a tree of nodes keyed by path pattern, where everything the
|
|
8
|
+
// architecture says about a part of the tree is written at that part of the tree.
|
|
9
|
+
// A node is a folder (it has `children` and/or `files`) or a file (its key names
|
|
10
|
+
// a filename pattern).
|
|
11
|
+
//
|
|
12
|
+
// The default is tight: a folder admits only the children it lists, and a node
|
|
13
|
+
// may import only what it or an ancestor allows. Laxity is opted into, by name,
|
|
14
|
+
// at the node that wants it.
|
|
15
|
+
|
|
16
|
+
const Globs = Schema.Union([Schema.String, Schema.Array(Schema.String)]);
|
|
17
|
+
|
|
18
|
+
// A prohibition. `match`/`matchNot` describe the target; `except` names the
|
|
19
|
+
// importers it does not apply to. The exemption is declared by the author of the
|
|
20
|
+
// prohibition, in the same breath — a descendant still cannot opt itself out.
|
|
21
|
+
const Denial = Schema.Struct({
|
|
22
|
+
match: Globs,
|
|
23
|
+
matchNot: Schema.optionalKey(Globs),
|
|
24
|
+
except: Schema.optionalKey(Globs),
|
|
25
|
+
message: Schema.String,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// Outbound: what this part of the tree may reach.
|
|
29
|
+
const Imports = Schema.Struct({
|
|
30
|
+
message: Schema.optionalKey(Schema.String),
|
|
31
|
+
// Repo-relative globs, after alias expansion. A target matching none of these
|
|
32
|
+
// is the violation — so widening is something you do by name, in the open.
|
|
33
|
+
allow: Schema.optionalKey(Globs),
|
|
34
|
+
// npm package names. Omit to inherit; `[]` to forbid every external.
|
|
35
|
+
external: Schema.optionalKey(Schema.Array(Schema.String)),
|
|
36
|
+
// Checked before `allow`, and wins over it. This is how a rule keeps a
|
|
37
|
+
// specific, better message for a mistake the allowlist would also catch.
|
|
38
|
+
deny: Schema.optionalKey(Schema.Array(Denial)),
|
|
39
|
+
// The one widening mechanism: stop inheriting ancestors' allowances. Inherited
|
|
40
|
+
// `deny` entries are unaffected — a prohibition only ever accumulates.
|
|
41
|
+
reset: Schema.optionalKey(Schema.Boolean),
|
|
42
|
+
// "This tier has no allowlist yet; only the prohibitions below apply." Implies
|
|
43
|
+
// `reset`. Required whenever a node states `imports` without an `allow`, so
|
|
44
|
+
// that an untightened tier is a sentence someone wrote rather than a gap in
|
|
45
|
+
// the config — and so `grep unrestricted` is the adoption backlog.
|
|
46
|
+
unrestricted: Schema.optionalKey(Schema.Boolean),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// Inbound: who may reach this part of the tree. Four of the rules governing
|
|
50
|
+
// `domain/` are of this shape — "a root-ops file is private to command handlers" —
|
|
51
|
+
// and stating them here is what stops them being a distant rule with a growing
|
|
52
|
+
// exclusion list on its `from` side.
|
|
53
|
+
const ImportedBy = Schema.Struct({
|
|
54
|
+
message: Schema.String,
|
|
55
|
+
allow: Globs,
|
|
56
|
+
// Targets inside this node's subtree the restriction does not cover — the
|
|
57
|
+
// module barrel is the obvious one: a module is private except through it.
|
|
58
|
+
matchNot: Schema.optionalKey(Globs),
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// What shape the variable part of a name must have. A folder's `children` keys
|
|
62
|
+
// already say which stereotypes it admits; this says what the concept name in
|
|
63
|
+
// front of the stereotype may look like, which is the degree of freedom a
|
|
64
|
+
// taxonomy alone leaves open.
|
|
65
|
+
//
|
|
66
|
+
// `like` names an ancestor capture the name must equal — a subdomain folder's
|
|
67
|
+
// root is named after the folder, and nothing else could say so.
|
|
68
|
+
const Naming = Schema.Union([
|
|
69
|
+
Schema.Literals(["kebab-case", "camelCase", "PascalCase", "snake_case"]),
|
|
70
|
+
Schema.Struct({ regex: Schema.String, message: Schema.optionalKey(Schema.String) }),
|
|
71
|
+
Schema.Struct({ like: Schema.String, message: Schema.optionalKey(Schema.String) }),
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
// A probe the author writes, in place of the synthetic one lowering would
|
|
75
|
+
// generate. `source` is parsed by the adapter at load, and the rule must report
|
|
76
|
+
// the named site out of what the parser read — which is the only way to state
|
|
77
|
+
// "this rule fires on an intersection-typed port" and have it checked, since a
|
|
78
|
+
// synthetic probe never meets a parser.
|
|
79
|
+
const MemberProbe = Schema.Struct({
|
|
80
|
+
source: Schema.String,
|
|
81
|
+
name: Schema.String,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const Members = Schema.Struct({
|
|
85
|
+
message: Schema.String,
|
|
86
|
+
subject: Schema.Literals(["members", "calls"]),
|
|
87
|
+
in: Schema.optionalKey(Globs),
|
|
88
|
+
// `members` only: which declarations are read — `type`, `interface`,
|
|
89
|
+
// `class`. Omit for every kind.
|
|
90
|
+
declares: Schema.optionalKey(Schema.Array(DeclarationKind)),
|
|
91
|
+
match: Schema.optionalKey(Globs),
|
|
92
|
+
matchNot: Schema.optionalKey(Globs),
|
|
93
|
+
allow: Schema.optionalKey(Globs),
|
|
94
|
+
probe: Schema.optionalKey(MemberProbe),
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// What a file may export. The selectors say which export sites the sentence
|
|
98
|
+
// is about; exactly one demand says what is required of them, and none means
|
|
99
|
+
// `forbid` — a selected site is the violation. Stated on a folder it covers
|
|
100
|
+
// the subtree, like `members`.
|
|
101
|
+
const SurfaceConvention = Schema.Union([
|
|
102
|
+
Schema.Literals(["kebab-case", "camelCase", "PascalCase", "snake_case"]),
|
|
103
|
+
Schema.Struct({ regex: Schema.String }),
|
|
104
|
+
]);
|
|
105
|
+
|
|
106
|
+
const Surface = Schema.Struct({
|
|
107
|
+
message: Schema.String,
|
|
108
|
+
// `named`, `default`, `namespace` — the last is `export *` and `export * as`.
|
|
109
|
+
kinds: Schema.optionalKey(Schema.Array(Schema.Literals(["named", "default", "namespace"]))),
|
|
110
|
+
// What the site was declared as, for a site declared in the file.
|
|
111
|
+
declares: Schema.optionalKey(Schema.Array(DeclarationKind)),
|
|
112
|
+
// `true` speaks only to `export … from "m"`; `false` only to what the file
|
|
113
|
+
// declares itself.
|
|
114
|
+
reexport: Schema.optionalKey(Schema.Boolean),
|
|
115
|
+
match: Schema.optionalKey(Globs),
|
|
116
|
+
matchNot: Schema.optionalKey(Globs),
|
|
117
|
+
// The demand. `forbid: true` is the default made explicit.
|
|
118
|
+
forbid: Schema.optionalKey(Schema.Boolean),
|
|
119
|
+
allow: Schema.optionalKey(Globs),
|
|
120
|
+
convention: Schema.optionalKey(SurfaceConvention),
|
|
121
|
+
count: Schema.optionalKey(
|
|
122
|
+
Schema.Struct({
|
|
123
|
+
min: Schema.optionalKey(Schema.Finite),
|
|
124
|
+
max: Schema.optionalKey(Schema.Finite),
|
|
125
|
+
}),
|
|
126
|
+
),
|
|
127
|
+
// Files under this node the rule does not apply to.
|
|
128
|
+
except: Schema.optionalKey(Globs),
|
|
129
|
+
// A source the rule must report something out of, parsed at load.
|
|
130
|
+
probe: Schema.optionalKey(Schema.Struct({ source: Schema.String })),
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// Which importers may name a given exported symbol. A path rule cannot say
|
|
134
|
+
// this: every importer of a barrel resolves to the same file, so only the
|
|
135
|
+
// imported name separates a bus factory from the Tag beside it.
|
|
136
|
+
const ExportRestriction = Schema.Struct({
|
|
137
|
+
name: Schema.String,
|
|
138
|
+
message: Schema.String,
|
|
139
|
+
// The module the symbols come from, matched against its RESOLVED path.
|
|
140
|
+
module: Globs,
|
|
141
|
+
// Exact exported names. Omit to mean "any named import from that module",
|
|
142
|
+
// which is how a rule bans a binding form rather than a name.
|
|
143
|
+
symbols: Schema.optionalKey(Schema.Array(Schema.String)),
|
|
144
|
+
// Which binding forms the rule speaks to. Defaults to `["named"]` — so a rule
|
|
145
|
+
// about a factory function says nothing about `import makeBus from "m"` until
|
|
146
|
+
// it lists `"default"`, and nothing about `import * as m`, `export * from
|
|
147
|
+
// "m"`, `import("m")` or `require("m")` until it lists `"namespace"`. A
|
|
148
|
+
// namespace binding's only name is `*`, so `symbols` cannot select one.
|
|
149
|
+
kinds: Schema.optionalKey(Schema.Array(Schema.Literals(["named", "default", "namespace"]))),
|
|
150
|
+
except: Schema.optionalKey(Globs),
|
|
151
|
+
fix: Schema.optionalKey(Schema.Literal("subpath-namespace-import")),
|
|
152
|
+
// As on `members`: a snippet the adapter parses at load, every edge of which
|
|
153
|
+
// is taken to reach this module, and a binding out of it the rule must cover.
|
|
154
|
+
// `symbol` is `"default"` for a default import and `"*"` for a namespace one.
|
|
155
|
+
probe: Schema.optionalKey(Schema.Struct({ source: Schema.String, symbol: Schema.String })),
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
export type ManifestNode = {
|
|
159
|
+
readonly message?: string;
|
|
160
|
+
// Carry policy for the subtree without claiming to enumerate this folder's
|
|
161
|
+
// contents. What makes incremental adoption possible — and what lets a
|
|
162
|
+
// prototype cover one branch without rejecting every sibling.
|
|
163
|
+
readonly partial?: boolean;
|
|
164
|
+
// "This folder admits any source file; only its subfolders are enumerated."
|
|
165
|
+
// A tier that is deliberately permissive about file names — the component
|
|
166
|
+
// library is, by design — says so, rather than carrying a layout rule that
|
|
167
|
+
// could never reject anything.
|
|
168
|
+
readonly layout?: "open";
|
|
169
|
+
// Inherited by the subtree, like `imports`, so a tier states its convention
|
|
170
|
+
// once rather than on every stereotype it admits.
|
|
171
|
+
readonly name?: typeof Naming.Type;
|
|
172
|
+
readonly imports?: typeof Imports.Type;
|
|
173
|
+
readonly importedBy?: typeof ImportedBy.Type;
|
|
174
|
+
readonly members?: ReadonlyArray<typeof Members.Type>;
|
|
175
|
+
readonly surface?: ReadonlyArray<typeof Surface.Type>;
|
|
176
|
+
// Files this node must have beside it. `{base}` is this file's name minus its
|
|
177
|
+
// final extension; `../` resolves against the node's own folder.
|
|
178
|
+
readonly requires?: ReadonlyArray<string>;
|
|
179
|
+
// Filenames this node's `requires` does not apply to — the exemption lives on
|
|
180
|
+
// the obligation it exempts, rather than as a separate more-specific key that
|
|
181
|
+
// has to win a precedence contest.
|
|
182
|
+
readonly requiresNot?: ReadonlyArray<string>;
|
|
183
|
+
// Folder nodes only. Deny-by-default: a child matching no key is a violation.
|
|
184
|
+
readonly children?: Readonly<Record<string, ManifestNode>>;
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const ManifestNodeSchema: Schema.Codec<ManifestNode> = Schema.suspend(() =>
|
|
188
|
+
Schema.Struct({
|
|
189
|
+
message: Schema.optionalKey(Schema.String),
|
|
190
|
+
partial: Schema.optionalKey(Schema.Boolean),
|
|
191
|
+
layout: Schema.optionalKey(Schema.Literal("open")),
|
|
192
|
+
name: Schema.optionalKey(Naming),
|
|
193
|
+
imports: Schema.optionalKey(Imports),
|
|
194
|
+
importedBy: Schema.optionalKey(ImportedBy),
|
|
195
|
+
members: Schema.optionalKey(Schema.Array(Members)),
|
|
196
|
+
surface: Schema.optionalKey(Schema.Array(Surface)),
|
|
197
|
+
requires: Schema.optionalKey(Schema.Array(Schema.String)),
|
|
198
|
+
requiresNot: Schema.optionalKey(Schema.Array(Schema.String)),
|
|
199
|
+
children: Schema.optionalKey(Schema.Record(Schema.String, ManifestNodeSchema)),
|
|
200
|
+
}),
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
// Rules about the shape of the whole import graph. Globs, like everything
|
|
204
|
+
// else here, expanded through `aliases`. Evaluated by the CLI only: the plugin
|
|
205
|
+
// sees one file at a time and cannot answer "does anything import this?".
|
|
206
|
+
const GraphCycles = Schema.Struct({
|
|
207
|
+
name: Schema.String,
|
|
208
|
+
message: Schema.String,
|
|
209
|
+
within: Globs,
|
|
210
|
+
withinNot: Schema.optionalKey(Globs),
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
const GraphOrphans = Schema.Struct({
|
|
214
|
+
name: Schema.String,
|
|
215
|
+
message: Schema.String,
|
|
216
|
+
within: Globs,
|
|
217
|
+
withinNot: Schema.optionalKey(Globs),
|
|
218
|
+
entry: Globs,
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
const GraphReach = Schema.Struct({
|
|
222
|
+
name: Schema.String,
|
|
223
|
+
message: Schema.String,
|
|
224
|
+
from: Globs,
|
|
225
|
+
fromNot: Schema.optionalKey(Globs),
|
|
226
|
+
to: Globs,
|
|
227
|
+
toNot: Schema.optionalKey(Globs),
|
|
228
|
+
via: Schema.optionalKey(Globs),
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const Graph = Schema.Struct({
|
|
232
|
+
cycles: Schema.optionalKey(Schema.Array(GraphCycles)),
|
|
233
|
+
orphans: Schema.optionalKey(Schema.Array(GraphOrphans)),
|
|
234
|
+
reach: Schema.optionalKey(Schema.Array(GraphReach)),
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// Ratchets on the policy itself. `unrestricted` and `partial` are the two
|
|
238
|
+
// ways a tier says "not tightened yet"; a ceiling on how many may say so is
|
|
239
|
+
// what stops the backlog growing. `coverage` is a floor, per family, on the
|
|
240
|
+
// fraction of walked files a rule actually reaches — probes prove a rule can
|
|
241
|
+
// fire; this proves the tree reaches the files.
|
|
242
|
+
const CoverageFloors = Schema.Struct({
|
|
243
|
+
imports: Schema.optionalKey(Schema.Finite),
|
|
244
|
+
structure: Schema.optionalKey(Schema.Finite),
|
|
245
|
+
members: Schema.optionalKey(Schema.Finite),
|
|
246
|
+
surface: Schema.optionalKey(Schema.Finite),
|
|
247
|
+
graph: Schema.optionalKey(Schema.Finite),
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
const Limits = Schema.Struct({
|
|
251
|
+
unrestricted: Schema.optionalKey(Schema.Finite),
|
|
252
|
+
partial: Schema.optionalKey(Schema.Finite),
|
|
253
|
+
coverage: Schema.optionalKey(CoverageFloors),
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
export const Manifest = Schema.Struct({
|
|
257
|
+
// How an import specifier becomes a file. Every pattern below is matched
|
|
258
|
+
// against a resolved path, so this is what makes the rest of the file mean
|
|
259
|
+
// anything.
|
|
260
|
+
resolve: ResolveConfig,
|
|
261
|
+
baseline: Schema.optionalKey(Schema.String),
|
|
262
|
+
// Prohibitions that hold everywhere, declared once. `not-to-spec` and the
|
|
263
|
+
// "this driver lives in one package" rules are statements about the whole
|
|
264
|
+
// repo, not about a tier — putting them on every tree root would be six
|
|
265
|
+
// copies and a seventh forgotten.
|
|
266
|
+
deny: Schema.optionalKey(Schema.Array(Denial)),
|
|
267
|
+
exports: Schema.optionalKey(Schema.Array(ExportRestriction)),
|
|
268
|
+
graph: Schema.optionalKey(Graph),
|
|
269
|
+
limits: Schema.optionalKey(Limits),
|
|
270
|
+
// Shorthands expanded in every glob, so a pattern reads the way the repo's own
|
|
271
|
+
// imports do rather than repeating `packages/server/src` on every line.
|
|
272
|
+
aliases: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)),
|
|
273
|
+
tree: Schema.Record(Schema.String, ManifestNodeSchema),
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
export type Manifest = typeof Manifest.Type;
|
|
277
|
+
export type ImportsSpec = typeof Imports.Type;
|
|
278
|
+
export type ImportedBySpec = typeof ImportedBy.Type;
|
|
279
|
+
export type MembersSpec = typeof Members.Type;
|
|
280
|
+
export type SurfaceSpec = typeof Surface.Type;
|
|
281
|
+
export type GraphSpec = typeof Graph.Type;
|
|
282
|
+
export type LimitsSpec = typeof Limits.Type;
|
|
283
|
+
export type NamingSpec = typeof Naming.Type;
|
|
284
|
+
export type ExportRestriction = typeof ExportRestriction.Type;
|
|
285
|
+
|
|
286
|
+
export const globsOf = (globs: string | ReadonlyArray<string>): ReadonlyArray<string> =>
|
|
287
|
+
typeof globs === "string" ? [globs] : globs;
|
|
288
|
+
|
|
289
|
+
const decode = Schema.decodeUnknownResult(Manifest);
|
|
290
|
+
|
|
291
|
+
export type DecodedManifest = {
|
|
292
|
+
readonly manifest: Manifest;
|
|
293
|
+
// Things the manifest said in a form that still loads but is on its way out.
|
|
294
|
+
// The host prints them; nothing else acts on them.
|
|
295
|
+
readonly notices: ReadonlyArray<string>;
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
299
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
300
|
+
|
|
301
|
+
const isList = (value: unknown): value is ReadonlyArray<unknown> => Array.isArray(value);
|
|
302
|
+
|
|
303
|
+
// The three resolver options that used to sit on `resolve` itself, when every
|
|
304
|
+
// scope was a TypeScript scope and there was nowhere else to put them.
|
|
305
|
+
const LEGACY_RESOLVER_OPTIONS = ["extensions", "conditionNames", "mainFields"] as const;
|
|
306
|
+
|
|
307
|
+
// Until this beta ends, a scope written as `{ files, tsconfig }` is read as a
|
|
308
|
+
// TypeScript scope with `options: { tsconfig }`, and resolver options on
|
|
309
|
+
// `resolve` itself are folded into every TypeScript scope. Rewritten here, on
|
|
310
|
+
// the raw input, so the schema itself never has to know the old shape.
|
|
311
|
+
const normalizeLegacyResolve = (
|
|
312
|
+
input: unknown,
|
|
313
|
+
): { readonly input: unknown; readonly notices: ReadonlyArray<string> } => {
|
|
314
|
+
if (!isRecord(input) || !isRecord(input.resolve) || !isList(input.resolve.scopes)) {
|
|
315
|
+
return { input, notices: [] };
|
|
316
|
+
}
|
|
317
|
+
const resolve = input.resolve;
|
|
318
|
+
const rawScopes = input.resolve.scopes;
|
|
319
|
+
const notices: Array<string> = [];
|
|
320
|
+
|
|
321
|
+
const hoisted = Object.fromEntries(
|
|
322
|
+
LEGACY_RESOLVER_OPTIONS.flatMap((key) => (key in resolve ? [[key, resolve[key]]] : [])),
|
|
323
|
+
);
|
|
324
|
+
if (Object.keys(hoisted).length > 0) {
|
|
325
|
+
notices.push(
|
|
326
|
+
`resolve.${Object.keys(hoisted).join(", resolve.")} on \`resolve\` itself is deprecated: ` +
|
|
327
|
+
`these are TypeScript resolver options, and belong in a TypeScript scope's \`options\`.`,
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const scopes = rawScopes.map((scope, index) => {
|
|
332
|
+
if (!isRecord(scope)) return scope;
|
|
333
|
+
const { tsconfig, ...rest } = scope;
|
|
334
|
+
if (tsconfig === undefined || "language" in scope) {
|
|
335
|
+
return scope.language === "typescript" &&
|
|
336
|
+
Object.keys(hoisted).length > 0 &&
|
|
337
|
+
isRecord(scope.options)
|
|
338
|
+
? { ...scope, options: { ...hoisted, ...scope.options } }
|
|
339
|
+
: scope;
|
|
340
|
+
}
|
|
341
|
+
notices.push(
|
|
342
|
+
`resolve.scopes[${String(index)}] names a \`tsconfig\` with no \`language\`. That shape is ` +
|
|
343
|
+
`deprecated: write { files, language: "typescript", options: { tsconfig } }.`,
|
|
344
|
+
);
|
|
345
|
+
return { ...rest, language: "typescript", options: { ...hoisted, tsconfig } };
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
const { conditionNames: _c, extensions: _e, mainFields: _m, ...restOfResolve } = resolve;
|
|
349
|
+
return { input: { ...input, resolve: { ...restOfResolve, scopes } }, notices };
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
// Until this beta ends, `subject: "type-members"` reads as `subject: "members"`
|
|
353
|
+
// with `declares: ["type", "interface"]` — the TypeScript split between types
|
|
354
|
+
// and values, which the vocabulary no longer carries. Rewritten on the raw
|
|
355
|
+
// tree, so the schema itself never has to know the old name.
|
|
356
|
+
const normalizeLegacyMembers = (
|
|
357
|
+
input: unknown,
|
|
358
|
+
): { readonly input: unknown; readonly notices: ReadonlyArray<string> } => {
|
|
359
|
+
if (!isRecord(input) || !isRecord(input.tree)) return { input, notices: [] };
|
|
360
|
+
const notices: Array<string> = [];
|
|
361
|
+
|
|
362
|
+
const node = (key: string, value: unknown): unknown => {
|
|
363
|
+
if (!isRecord(value)) return value;
|
|
364
|
+
const members = isList(value.members)
|
|
365
|
+
? value.members.map((spec) => {
|
|
366
|
+
if (!isRecord(spec) || spec.subject !== "type-members") return spec;
|
|
367
|
+
notices.push(
|
|
368
|
+
`"${key}" has a members rule with \`subject: "type-members"\`. That name is ` +
|
|
369
|
+
`deprecated: write \`subject: "members", declares: ["type", "interface"]\`.`,
|
|
370
|
+
);
|
|
371
|
+
return { ...spec, subject: "members", declares: spec.declares ?? ["type", "interface"] };
|
|
372
|
+
})
|
|
373
|
+
: value.members;
|
|
374
|
+
const children = isRecord(value.children)
|
|
375
|
+
? Object.fromEntries(
|
|
376
|
+
Object.entries(value.children).map(([childKey, child]) => [
|
|
377
|
+
childKey,
|
|
378
|
+
node(childKey, child),
|
|
379
|
+
]),
|
|
380
|
+
)
|
|
381
|
+
: value.children;
|
|
382
|
+
return {
|
|
383
|
+
...value,
|
|
384
|
+
...(members === undefined ? {} : { members }),
|
|
385
|
+
...(children === undefined ? {} : { children }),
|
|
386
|
+
};
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
const tree = Object.fromEntries(
|
|
390
|
+
Object.entries(input.tree).map(([key, value]) => [key, node(key, value)]),
|
|
391
|
+
);
|
|
392
|
+
return { input: { ...input, tree }, notices };
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
export const decodeManifest = (
|
|
396
|
+
configPath: string,
|
|
397
|
+
input: unknown,
|
|
398
|
+
): Result.Result<DecodedManifest, ConfigInvalid> => {
|
|
399
|
+
const resolve = normalizeLegacyResolve(input);
|
|
400
|
+
const members = normalizeLegacyMembers(resolve.input);
|
|
401
|
+
return Result.map(
|
|
402
|
+
Result.mapError(
|
|
403
|
+
decode(members.input),
|
|
404
|
+
(issue) => new ConfigInvalid({ configPath, detail: String(issue) }),
|
|
405
|
+
),
|
|
406
|
+
(manifest) => ({ manifest, notices: [...resolve.notices, ...members.notices] }),
|
|
407
|
+
);
|
|
408
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { SourceFacts } from "../domain/facts.js";
|
|
2
|
+
|
|
3
|
+
// Reads the facts out of one source text. `file` is the repo-relative path the
|
|
4
|
+
// facts are attributed to; it also decides the grammar (`.tsx` or not).
|
|
5
|
+
//
|
|
6
|
+
// The CLI reads every file through this. The plugin reads through oxlint's own
|
|
7
|
+
// visitor instead — but a probe that carries a source snippet is checked
|
|
8
|
+
// through this port at load time, whichever adapter is loading, so "the rule
|
|
9
|
+
// fires on this declaration" is a fact about a parser and not about a pattern.
|
|
10
|
+
export type FactExtractor = {
|
|
11
|
+
readonly factsOf: (file: string, text: string) => SourceFacts;
|
|
12
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Sibling-parity rules ask a question no AST can answer: does this other file
|
|
2
|
+
// exist? And the loader reads the baseline the manifest names. A port keeps both
|
|
3
|
+
// testable without a fixture tree on disk.
|
|
4
|
+
export type FileSystem = {
|
|
5
|
+
readonly exists: (repoRelativePath: string) => boolean;
|
|
6
|
+
// The file's text, or `null` when it is absent or unreadable.
|
|
7
|
+
readonly readText: (repoRelativePath: string) => string | null;
|
|
8
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type * as Result from "effect/Result";
|
|
2
|
+
|
|
3
|
+
import type { ExportFix, ResolveScope } from "../domain/architecture-config.js";
|
|
4
|
+
import type { ScopeInvalid } from "../domain/architecture-error.js";
|
|
5
|
+
import type { FactExtractor } from "./fact-extractor.js";
|
|
6
|
+
import type { ModuleResolver } from "./module-resolver.js";
|
|
7
|
+
|
|
8
|
+
// Everything the policy needs from one programming language, behind one port.
|
|
9
|
+
//
|
|
10
|
+
// The manifest vocabulary — a file, an import edge, a resolved target, a
|
|
11
|
+
// declared member — is not TypeScript's. What is TypeScript's is how a
|
|
12
|
+
// specifier becomes a file, which extensions a source file carries, and how the
|
|
13
|
+
// facts are read out of one. A language pack answers those three questions; the
|
|
14
|
+
// core and the manifest ask nothing else of it, and can be tested with a pack
|
|
15
|
+
// that answers from a table.
|
|
16
|
+
export type Language = {
|
|
17
|
+
// The name a `resolve.scopes` entry selects the pack by.
|
|
18
|
+
readonly id: string;
|
|
19
|
+
// The extensions of a source file the walker should visit, with the dot.
|
|
20
|
+
readonly extensions: ReadonlyArray<string>;
|
|
21
|
+
// Files carrying one of those extensions that are not source — for
|
|
22
|
+
// TypeScript, a declaration file states types and no linter visits one.
|
|
23
|
+
readonly ignoredFiles: ReadonlyArray<RegExp>;
|
|
24
|
+
// Reads the facts out of one source text. The CLI reads every file through
|
|
25
|
+
// it, and a probe carrying a `source` snippet is parsed by it at load.
|
|
26
|
+
readonly extractor: FactExtractor;
|
|
27
|
+
// The autofix strategies an `exports` rule may name that this language can
|
|
28
|
+
// carry out. A rewrite is written in one module syntax; a rule naming one no
|
|
29
|
+
// loaded language implements is refused at load.
|
|
30
|
+
readonly fixes: ReadonlyArray<ExportFix>;
|
|
31
|
+
// A resolver for the files one scope covers. Built once per scope per run;
|
|
32
|
+
// resolution is the expensive half of linting an architecture. The scope's
|
|
33
|
+
// `options` are this language's to read, and anything it does not
|
|
34
|
+
// understand is refused here, at load — never resolved on defaults.
|
|
35
|
+
readonly makeResolver: (
|
|
36
|
+
repoRoot: string,
|
|
37
|
+
scope: ResolveScope,
|
|
38
|
+
) => Result.Result<ModuleResolver, ScopeInvalid>;
|
|
39
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type * as Result from "effect/Result";
|
|
2
|
+
|
|
3
|
+
import type { ImportUnresolved } from "../domain/architecture-error.js";
|
|
4
|
+
|
|
5
|
+
export type DependencyKind = "local" | "external" | "builtin";
|
|
6
|
+
|
|
7
|
+
export type ResolvedTarget = {
|
|
8
|
+
// Repo-relative with forward slashes for a file on disk (including one under
|
|
9
|
+
// a package store such as `node_modules/`), or the runtime's own name for a
|
|
10
|
+
// builtin (`node:fs`). This is the exact vocabulary dependency-cruiser reports
|
|
11
|
+
// in, so ported `to` patterns — including `/node_modules/effect/` against a
|
|
12
|
+
// pnpm `.pnpm/…` path — match unchanged.
|
|
13
|
+
readonly path: string;
|
|
14
|
+
// `builtin` is its own kind rather than a flavour of external, because a rule
|
|
15
|
+
// that fences off third-party dependencies is not talking about `node:crypto`.
|
|
16
|
+
readonly kind: DependencyKind;
|
|
17
|
+
// For an `external`, the package the target belongs to, as an `imports.external`
|
|
18
|
+
// entry names it (`effect`, `@scope/name`). The resolver knows where its language
|
|
19
|
+
// keeps packages; the policy only knows their names.
|
|
20
|
+
readonly package?: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type ModuleResolver = {
|
|
24
|
+
readonly resolve: (
|
|
25
|
+
fromFile: string,
|
|
26
|
+
specifier: string,
|
|
27
|
+
) => Result.Result<ResolvedTarget, ImportUnresolved>;
|
|
28
|
+
};
|
package/src/testing.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// One fake per port, for tests that state facts and never read a file, run a
|
|
2
|
+
// parser or resolve a specifier. A language pack's tests and a host's tests
|
|
3
|
+
// both drive these; so do the core's own.
|
|
4
|
+
export { makeFactExtractorFake } from "./infrastructure/fact-extractor-fake.js";
|
|
5
|
+
export { makeFileSystemFake } from "./infrastructure/file-system-fake.js";
|
|
6
|
+
export { makeModuleResolverFake } from "./infrastructure/module-resolver-fake.js";
|