@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,27 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
|
|
4
|
+
import type { FileSystem } from "../ports/file-system.js";
|
|
5
|
+
|
|
6
|
+
// One lint run asks about the same sibling many times — every handler in a
|
|
7
|
+
// folder resolves the same `../../infrastructure/...` shape — so the answers are
|
|
8
|
+
// memoised for the life of the run.
|
|
9
|
+
export const makeFileSystemLive = (repoRoot: string): FileSystem => {
|
|
10
|
+
const cache = new Map<string, boolean>();
|
|
11
|
+
return {
|
|
12
|
+
exists: (repoRelativePath) => {
|
|
13
|
+
const cached = cache.get(repoRelativePath);
|
|
14
|
+
if (cached !== undefined) return cached;
|
|
15
|
+
const answer = existsSync(path.resolve(repoRoot, repoRelativePath));
|
|
16
|
+
cache.set(repoRelativePath, answer);
|
|
17
|
+
return answer;
|
|
18
|
+
},
|
|
19
|
+
readText: (repoRelativePath) => {
|
|
20
|
+
try {
|
|
21
|
+
return readFileSync(path.resolve(repoRoot, repoRelativePath), "utf8");
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { pathToFileURL } from "node:url";
|
|
2
|
+
|
|
3
|
+
import { ConfigInvalid } from "../domain/architecture-error.js";
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_CONFIG_FILENAME = "architecture.config.mjs";
|
|
6
|
+
|
|
7
|
+
// The manifest, as the file on disk states it, before any decoding. Today that
|
|
8
|
+
// is a JavaScript module and its default export; a manifest in another format
|
|
9
|
+
// changes this function and nothing behind it.
|
|
10
|
+
export const readManifestFile = async (configPath: string): Promise<unknown> => {
|
|
11
|
+
const module: unknown = await import(pathToFileURL(configPath).href).catch((cause: unknown) => {
|
|
12
|
+
throw new ConfigInvalid({ configPath, detail: String(cause) });
|
|
13
|
+
});
|
|
14
|
+
return typeof module === "object" && module !== null && "default" in module
|
|
15
|
+
? module.default
|
|
16
|
+
: module;
|
|
17
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import * as Result from "effect/Result";
|
|
2
|
+
|
|
3
|
+
import { ImportUnresolved } from "../domain/architecture-error.js";
|
|
4
|
+
import type { ModuleResolver, ResolvedTarget } from "../ports/module-resolver.js";
|
|
5
|
+
|
|
6
|
+
// Keyed by specifier alone: a core-evaluator test states the edges it is about
|
|
7
|
+
// and never the resolution algorithm, which has its own tests against the real
|
|
8
|
+
// resolver.
|
|
9
|
+
//
|
|
10
|
+
// A target may be staged as the full `ResolvedTarget`, or as a path alone — in
|
|
11
|
+
// which case its kind and package are read the way the TypeScript resolver
|
|
12
|
+
// would report them, so a test written in that language's vocabulary stays
|
|
13
|
+
// short. A test about another language stages the whole target.
|
|
14
|
+
export const makeModuleResolverFake = (
|
|
15
|
+
targets: Readonly<Record<string, string | ResolvedTarget>>,
|
|
16
|
+
): ModuleResolver => ({
|
|
17
|
+
resolve: (fromFile, specifier) => {
|
|
18
|
+
const staged = targets[specifier];
|
|
19
|
+
if (staged === undefined) {
|
|
20
|
+
return Result.fail(
|
|
21
|
+
new ImportUnresolved({ fromFile, specifier, detail: "not staged in the fake" }),
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
return Result.succeed(typeof staged === "string" ? targetOfPath(staged) : staged);
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// The npm layout, as the TypeScript pack reads it: the package is the segment
|
|
29
|
+
// after the LAST `node_modules/` (pnpm's store puts a second one in front).
|
|
30
|
+
// Restated here rather than imported, because the core does not depend on any
|
|
31
|
+
// language pack — and a test about another language stages the whole target.
|
|
32
|
+
const LAST_NODE_MODULES = /(?:^|\/)node_modules\/((?:@[^/]+\/)?[^/]+)/g;
|
|
33
|
+
|
|
34
|
+
const targetOfPath = (path: string): ResolvedTarget => {
|
|
35
|
+
if (path.startsWith("node:")) return { path, kind: "builtin" };
|
|
36
|
+
let pkg: string | undefined;
|
|
37
|
+
for (const match of path.matchAll(LAST_NODE_MODULES)) pkg = match[1];
|
|
38
|
+
return pkg === undefined ? { path, kind: "local" } : { path, kind: "external", package: pkg };
|
|
39
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { readdirSync, statSync } from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
|
|
4
|
+
import type { Language } from "../ports/language.js";
|
|
5
|
+
|
|
6
|
+
// Folders no policy is written about and no linter visits. These are the right
|
|
7
|
+
// defaults for any language on this host: a package store, build output, the
|
|
8
|
+
// VCS, and a coverage report are never the architecture.
|
|
9
|
+
const SKIPPED = new Set([
|
|
10
|
+
"node_modules",
|
|
11
|
+
"build",
|
|
12
|
+
"dist",
|
|
13
|
+
".next",
|
|
14
|
+
".git",
|
|
15
|
+
"storybook-static",
|
|
16
|
+
"coverage",
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
// What the walker needs to know about a language: what a source file of it is
|
|
20
|
+
// called, and which of those are not source after all.
|
|
21
|
+
export type WalkedLanguage = Pick<Language, "extensions" | "ignoredFiles">;
|
|
22
|
+
|
|
23
|
+
// The source files under `roots`, repo-relative with forward slashes, sorted.
|
|
24
|
+
// Which files count as source is the languages' to say: the extension set is
|
|
25
|
+
// the union of theirs, and so is the set of files to step over. A root that
|
|
26
|
+
// names a file is that file, whatever its extension — `architecture check
|
|
27
|
+
// <file>` is a reasonable thing to type.
|
|
28
|
+
export const listSourceFiles = (
|
|
29
|
+
repoRoot: string,
|
|
30
|
+
roots: ReadonlyArray<string>,
|
|
31
|
+
languages: ReadonlyArray<WalkedLanguage>,
|
|
32
|
+
): ReadonlyArray<string> => {
|
|
33
|
+
const extensions = new Set(languages.flatMap((language) => language.extensions));
|
|
34
|
+
const ignored = languages.flatMap((language) => language.ignoredFiles);
|
|
35
|
+
const isSource = (entry: string): boolean =>
|
|
36
|
+
extensions.has(path.extname(entry)) && !ignored.some((pattern) => pattern.test(entry));
|
|
37
|
+
|
|
38
|
+
const found: Array<string> = [];
|
|
39
|
+
const walk = (absolute: string): void => {
|
|
40
|
+
for (const entry of readdirSync(absolute)) {
|
|
41
|
+
if (SKIPPED.has(entry)) continue;
|
|
42
|
+
const child = path.join(absolute, entry);
|
|
43
|
+
if (statSync(child).isDirectory()) walk(child);
|
|
44
|
+
else if (isSource(entry))
|
|
45
|
+
found.push(path.relative(repoRoot, child).replaceAll(path.sep, "/"));
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
for (const root of roots) {
|
|
49
|
+
const absolute = path.resolve(repoRoot, root);
|
|
50
|
+
if (statSync(absolute).isDirectory()) walk(absolute);
|
|
51
|
+
else found.push(path.relative(repoRoot, absolute).replaceAll(path.sep, "/"));
|
|
52
|
+
}
|
|
53
|
+
return found.sort();
|
|
54
|
+
};
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import * as Result from "effect/Result";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type Baseline,
|
|
5
|
+
type BaselineFilter,
|
|
6
|
+
decodeBaseline,
|
|
7
|
+
EMPTY_BASELINE,
|
|
8
|
+
makeBaselineFilter,
|
|
9
|
+
} from "../core/baseline.js";
|
|
10
|
+
import {
|
|
11
|
+
type CompiledExportRule,
|
|
12
|
+
compileExportRules,
|
|
13
|
+
exportRulesFailingTheirProbe,
|
|
14
|
+
} from "../core/exports.js";
|
|
15
|
+
import {
|
|
16
|
+
type CompiledGraph,
|
|
17
|
+
compileGraphRules,
|
|
18
|
+
graphRulesFailingTheirProbe,
|
|
19
|
+
} from "../core/graph.js";
|
|
20
|
+
import {
|
|
21
|
+
type CompiledImportRule,
|
|
22
|
+
compileImportRules,
|
|
23
|
+
rulesFailingTheirProbe,
|
|
24
|
+
} from "../core/imports.js";
|
|
25
|
+
import {
|
|
26
|
+
type CompiledMemberRule,
|
|
27
|
+
compileMemberRules,
|
|
28
|
+
memberRulesFailingTheirProbe,
|
|
29
|
+
} from "../core/members.js";
|
|
30
|
+
import {
|
|
31
|
+
type CompiledStructure,
|
|
32
|
+
compileStructure,
|
|
33
|
+
structureRulesFailingTheirProbe,
|
|
34
|
+
} from "../core/structure.js";
|
|
35
|
+
import {
|
|
36
|
+
type CompiledSurfaceRule,
|
|
37
|
+
compileSurfaceRules,
|
|
38
|
+
surfaceRulesFailingTheirProbe,
|
|
39
|
+
} from "../core/surface.js";
|
|
40
|
+
import type { ResolveScope } from "../domain/architecture-config.js";
|
|
41
|
+
import {
|
|
42
|
+
ConfigInvalid,
|
|
43
|
+
ImportUnresolved,
|
|
44
|
+
type PatternInvalid,
|
|
45
|
+
} from "../domain/architecture-error.js";
|
|
46
|
+
import type { SourceFacts } from "../domain/facts.js";
|
|
47
|
+
import { type LoweredRules, lowerManifest } from "../manifest/compile.js";
|
|
48
|
+
import { decodeManifest, type Manifest } from "../manifest/manifest.js";
|
|
49
|
+
import type { FactExtractor } from "../ports/fact-extractor.js";
|
|
50
|
+
import type { FileSystem } from "../ports/file-system.js";
|
|
51
|
+
import type { Language } from "../ports/language.js";
|
|
52
|
+
import type { ModuleResolver } from "../ports/module-resolver.js";
|
|
53
|
+
|
|
54
|
+
// A manifest, read by a host, turned into the policy both adapters evaluate.
|
|
55
|
+
// Decoding, lowering, compiling and probing happen here, once, with whatever
|
|
56
|
+
// languages the host hands in — this tier never names one. The host reads the
|
|
57
|
+
// manifest file, constructs its language packs and the live file system, and
|
|
58
|
+
// hands them over; that is the whole of what a host has to know.
|
|
59
|
+
|
|
60
|
+
export type LoadedPolicy = {
|
|
61
|
+
readonly repoRoot: string;
|
|
62
|
+
readonly config: Manifest;
|
|
63
|
+
readonly importRules: ReadonlyArray<CompiledImportRule>;
|
|
64
|
+
readonly exportRules: ReadonlyArray<CompiledExportRule>;
|
|
65
|
+
readonly memberRules: ReadonlyArray<CompiledMemberRule>;
|
|
66
|
+
readonly surfaceRules: ReadonlyArray<CompiledSurfaceRule>;
|
|
67
|
+
// Evaluated by the CLI only; compiled and probed here so a vacuous one fails
|
|
68
|
+
// the plugin's load as well.
|
|
69
|
+
readonly graph: CompiledGraph;
|
|
70
|
+
readonly adoption: LoweredRules["adoption"];
|
|
71
|
+
readonly structure: CompiledStructure;
|
|
72
|
+
readonly fileSystem: FileSystem;
|
|
73
|
+
// The language packs this policy is evaluated with. The walker takes its
|
|
74
|
+
// extensions from them, and lowering the shape of its probes.
|
|
75
|
+
readonly languages: ReadonlyArray<Language>;
|
|
76
|
+
// Violations this repository is carrying while it adopts the policy. Applied
|
|
77
|
+
// at report time so a baselined finding costs nothing but a line in a file.
|
|
78
|
+
readonly baseline: BaselineFilter;
|
|
79
|
+
// Routes each file to the resolver of the scope that covers it.
|
|
80
|
+
readonly resolver: ModuleResolver;
|
|
81
|
+
// Routes each file to the extractor of the language whose scope covers it.
|
|
82
|
+
// The CLI reads every file through this; the plugin reads oxlint's tree.
|
|
83
|
+
readonly extractor: FactExtractor;
|
|
84
|
+
readonly ignoreUnresolved: ReadonlyArray<RegExp>;
|
|
85
|
+
// Deprecation notices from reading the manifest. The host prints them once.
|
|
86
|
+
readonly notices: ReadonlyArray<string>;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export type LoadPolicyInput = {
|
|
90
|
+
readonly repoRoot: string;
|
|
91
|
+
// Where the manifest came from, for the error that names it.
|
|
92
|
+
readonly configPath: string;
|
|
93
|
+
// The manifest as the host read it — a module's default export, a parsed
|
|
94
|
+
// document — before decoding.
|
|
95
|
+
readonly manifest: unknown;
|
|
96
|
+
readonly languages: ReadonlyArray<Language>;
|
|
97
|
+
readonly fileSystem: FileSystem;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const NOTHING: SourceFacts = {
|
|
101
|
+
specifiers: [],
|
|
102
|
+
bindings: new Map(),
|
|
103
|
+
memberSites: [],
|
|
104
|
+
exportSites: [],
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
type Route = {
|
|
108
|
+
readonly scope: ResolveScope;
|
|
109
|
+
readonly matches: RegExp;
|
|
110
|
+
readonly language: Language;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// Each scope, paired with the language it names. A scope naming a language no
|
|
114
|
+
// pack answers to is refused here: every rule about that scope would otherwise
|
|
115
|
+
// be evaluated against nothing.
|
|
116
|
+
const routesOf = (
|
|
117
|
+
configPath: string,
|
|
118
|
+
scopes: ReadonlyArray<ResolveScope>,
|
|
119
|
+
languages: ReadonlyArray<Language>,
|
|
120
|
+
): Result.Result<ReadonlyArray<Route>, ConfigInvalid> => {
|
|
121
|
+
const routes: Array<Route> = [];
|
|
122
|
+
for (const scope of scopes) {
|
|
123
|
+
const language = languages.find((one) => one.id === scope.language);
|
|
124
|
+
if (language === undefined) {
|
|
125
|
+
return Result.fail(
|
|
126
|
+
new ConfigInvalid({
|
|
127
|
+
configPath,
|
|
128
|
+
detail:
|
|
129
|
+
`resolve scope ${JSON.stringify(scope.files)} names the language ` +
|
|
130
|
+
`"${scope.language}", and no language pack by that name is loaded ` +
|
|
131
|
+
`(loaded: ${languages.length === 0 ? "none" : languages.map((one) => one.id).join(", ")}).`,
|
|
132
|
+
}),
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
routes.push({ scope, matches: new RegExp(scope.files), language });
|
|
136
|
+
}
|
|
137
|
+
return Result.succeed(routes);
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const routeFor = (routes: ReadonlyArray<Route>, file: string): Route | undefined =>
|
|
141
|
+
routes.find((route) => route.matches.test(file));
|
|
142
|
+
|
|
143
|
+
// One resolver per scope, built by the scope's language, behind one port that
|
|
144
|
+
// picks the scope by the importing file.
|
|
145
|
+
const makeRouter = (
|
|
146
|
+
configPath: string,
|
|
147
|
+
repoRoot: string,
|
|
148
|
+
routes: ReadonlyArray<Route>,
|
|
149
|
+
): Result.Result<ModuleResolver, ConfigInvalid> => {
|
|
150
|
+
const resolvers: Array<readonly [Route, ModuleResolver]> = [];
|
|
151
|
+
for (const route of routes) {
|
|
152
|
+
const resolver = route.language.makeResolver(repoRoot, route.scope);
|
|
153
|
+
if (Result.isFailure(resolver)) {
|
|
154
|
+
return Result.fail(new ConfigInvalid({ configPath, detail: resolver.failure.message }));
|
|
155
|
+
}
|
|
156
|
+
resolvers.push([route, resolver.success]);
|
|
157
|
+
}
|
|
158
|
+
return Result.succeed({
|
|
159
|
+
resolve: (fromFile, specifier) => {
|
|
160
|
+
const found = resolvers.find(([route]) => route.matches.test(fromFile));
|
|
161
|
+
if (found === undefined) {
|
|
162
|
+
return Result.fail(
|
|
163
|
+
new ImportUnresolved({
|
|
164
|
+
fromFile,
|
|
165
|
+
specifier,
|
|
166
|
+
detail: "no resolve scope in the architecture config matches this file",
|
|
167
|
+
}),
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
return found[1].resolve(fromFile, specifier);
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
// A file is parsed by the language whose scope covers it. A file no scope
|
|
176
|
+
// covers reads as nothing — which, for a probe, is a failed probe with a
|
|
177
|
+
// message that says so.
|
|
178
|
+
const makeRoutingExtractor = (routes: ReadonlyArray<Route>): FactExtractor => ({
|
|
179
|
+
factsOf: (file, text) =>
|
|
180
|
+
routeFor(routes, file)?.language.extractor.factsOf(file, text) ?? NOTHING,
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// The baseline is read through the port, so this tier touches no file itself.
|
|
184
|
+
// An absent or unreadable one carries nothing, which is the safe direction:
|
|
185
|
+
// every violation reports.
|
|
186
|
+
const readBaseline = (fileSystem: FileSystem, at: string | undefined): Baseline => {
|
|
187
|
+
if (at === undefined) return EMPTY_BASELINE;
|
|
188
|
+
const text = fileSystem.readText(at);
|
|
189
|
+
if (text === null) return EMPTY_BASELINE;
|
|
190
|
+
try {
|
|
191
|
+
return decodeBaseline(JSON.parse(text) as unknown);
|
|
192
|
+
} catch {
|
|
193
|
+
return EMPTY_BASELINE;
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
// Anything wrong with the policy — a bad shape, an uncompilable pattern, a rule
|
|
198
|
+
// that cannot report its own probe — is refused. A policy that loaded with a
|
|
199
|
+
// gap reports nothing there and looks exactly like a clean codebase.
|
|
200
|
+
export const loadPolicy = (
|
|
201
|
+
input: LoadPolicyInput,
|
|
202
|
+
): Result.Result<LoadedPolicy, ConfigInvalid | PatternInvalid> => {
|
|
203
|
+
const { configPath, fileSystem, languages, repoRoot } = input;
|
|
204
|
+
|
|
205
|
+
const decoded = decodeManifest(configPath, input.manifest);
|
|
206
|
+
if (Result.isFailure(decoded)) return Result.fail(decoded.failure);
|
|
207
|
+
const config = decoded.success.manifest;
|
|
208
|
+
|
|
209
|
+
const routes = routesOf(configPath, config.resolve.scopes, languages);
|
|
210
|
+
if (Result.isFailure(routes)) return Result.fail(routes.failure);
|
|
211
|
+
|
|
212
|
+
// The manifest is the authoring surface; these flat rules are the machine's.
|
|
213
|
+
// The languages tell lowering what a source file in each scope is called, so
|
|
214
|
+
// a synthetic probe is a file of the scope's language.
|
|
215
|
+
const rules = lowerManifest(config, languages);
|
|
216
|
+
|
|
217
|
+
// The ceilings. A tier that says "not tightened yet" is a sentence someone
|
|
218
|
+
// wrote; a ceiling on how many may say so is what keeps the backlog from
|
|
219
|
+
// becoming the architecture. Exceeding it is a policy that broke its own
|
|
220
|
+
// promise, and is refused like any other invalid policy.
|
|
221
|
+
const ceilings = [
|
|
222
|
+
["unrestricted", rules.adoption.unrestricted, config.limits?.unrestricted],
|
|
223
|
+
["partial", rules.adoption.partial, config.limits?.partial],
|
|
224
|
+
] as const;
|
|
225
|
+
const exceeded = ceilings.flatMap(([label, nodes, ceiling]) =>
|
|
226
|
+
ceiling !== undefined && nodes.length > ceiling
|
|
227
|
+
? [
|
|
228
|
+
`${label}: ${String(nodes.length)} nodes against a ceiling of ${String(ceiling)} (${nodes.join(", ")})`,
|
|
229
|
+
]
|
|
230
|
+
: [],
|
|
231
|
+
);
|
|
232
|
+
if (exceeded.length > 0) {
|
|
233
|
+
return Result.fail(
|
|
234
|
+
new ConfigInvalid({
|
|
235
|
+
configPath,
|
|
236
|
+
detail:
|
|
237
|
+
`the policy exceeds its own adoption ceiling — ${exceeded.join("; ")}. ` +
|
|
238
|
+
`Tighten a tier, or raise the ceiling in \`limits\` on purpose.`,
|
|
239
|
+
}),
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const importRules = compileImportRules(rules.imports);
|
|
244
|
+
if (Result.isFailure(importRules)) return Result.fail(importRules.failure);
|
|
245
|
+
|
|
246
|
+
const exportRules = compileExportRules(rules.exports);
|
|
247
|
+
if (Result.isFailure(exportRules)) return Result.fail(exportRules.failure);
|
|
248
|
+
|
|
249
|
+
// A fix is a rewrite in one language's module syntax. A rule naming one that
|
|
250
|
+
// no loaded language implements would report as fixable and never fix.
|
|
251
|
+
const unfixable = exportRules.success.filter((rule) => {
|
|
252
|
+
const fix = rule.fix;
|
|
253
|
+
return fix !== null && !languages.some((one) => one.fixes.includes(fix));
|
|
254
|
+
});
|
|
255
|
+
if (unfixable.length > 0) {
|
|
256
|
+
return Result.fail(
|
|
257
|
+
new ConfigInvalid({
|
|
258
|
+
configPath,
|
|
259
|
+
detail:
|
|
260
|
+
`these exports rules name a fix no loaded language implements: ` +
|
|
261
|
+
unfixable.map((rule) => `${rule.name} (${rule.fix ?? ""})`).join(", ") +
|
|
262
|
+
`. Drop the fix, or load a language pack that carries it.`,
|
|
263
|
+
}),
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const memberRules = compileMemberRules(rules.members);
|
|
268
|
+
if (Result.isFailure(memberRules)) return Result.fail(memberRules.failure);
|
|
269
|
+
|
|
270
|
+
const surfaceRules = compileSurfaceRules(rules.surface);
|
|
271
|
+
if (Result.isFailure(surfaceRules)) return Result.fail(surfaceRules.failure);
|
|
272
|
+
|
|
273
|
+
const graph = compileGraphRules(rules.graph);
|
|
274
|
+
if (Result.isFailure(graph)) return Result.fail(graph.failure);
|
|
275
|
+
|
|
276
|
+
const structure = compileStructure(rules.structure);
|
|
277
|
+
if (Result.isFailure(structure)) return Result.fail(structure.failure);
|
|
278
|
+
|
|
279
|
+
// A probe carrying a source snippet is parsed by the extractor of the
|
|
280
|
+
// language whose scope covers the probe's file — the same extractor the CLI
|
|
281
|
+
// reads that file through. The plugin reads through oxlint's tree instead,
|
|
282
|
+
// and the parity suite is what holds that tree to this one.
|
|
283
|
+
const extractor = makeRoutingExtractor(routes.success);
|
|
284
|
+
const parsedBy = (rule: { readonly probe: { readonly from: string } }): string => {
|
|
285
|
+
const route = routeFor(routes.success, rule.probe.from);
|
|
286
|
+
return route === undefined
|
|
287
|
+
? ` — no resolve scope covers its probe ${JSON.stringify(rule.probe.from)}, so no language parsed it`
|
|
288
|
+
: ` — probe parsed by ${route.language.id}, selected by the scope ${JSON.stringify(route.scope.files)}`;
|
|
289
|
+
};
|
|
290
|
+
const vacuous = [
|
|
291
|
+
...rulesFailingTheirProbe(importRules.success).map((rule) => rule.name),
|
|
292
|
+
...exportRulesFailingTheirProbe(exportRules.success, extractor).map(
|
|
293
|
+
(rule) => rule.name + (rule.probe.source === undefined ? "" : parsedBy(rule)),
|
|
294
|
+
),
|
|
295
|
+
...memberRulesFailingTheirProbe(memberRules.success, extractor).map(
|
|
296
|
+
(rule) => rule.name + (rule.probe.source === undefined ? "" : parsedBy(rule)),
|
|
297
|
+
),
|
|
298
|
+
...surfaceRulesFailingTheirProbe(surfaceRules.success, extractor).map(
|
|
299
|
+
(rule) => rule.name + (rule.probe.source === undefined ? "" : parsedBy(rule)),
|
|
300
|
+
),
|
|
301
|
+
...structureRulesFailingTheirProbe(structure.success),
|
|
302
|
+
...graphRulesFailingTheirProbe(graph.success),
|
|
303
|
+
];
|
|
304
|
+
if (vacuous.length > 0) {
|
|
305
|
+
return Result.fail(
|
|
306
|
+
new ConfigInvalid({
|
|
307
|
+
configPath,
|
|
308
|
+
detail:
|
|
309
|
+
`these rules do not report their own probe, so they enforce nothing: ` +
|
|
310
|
+
`${vacuous.join(", ")}. Fix the rule or its probe — ` +
|
|
311
|
+
`a rule that cannot flag a violation it was written for is worse than no rule. ` +
|
|
312
|
+
`A probe with a source snippet fails when the parser reads no site of that name ` +
|
|
313
|
+
`out of it; \`architecture facts\` shows what the parser reads.`,
|
|
314
|
+
}),
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// A scope the language cannot build a resolver from — options it does not
|
|
319
|
+
// understand — is a policy whose rules would be evaluated against the wrong
|
|
320
|
+
// files, and is refused.
|
|
321
|
+
const resolver = makeRouter(configPath, repoRoot, routes.success);
|
|
322
|
+
if (Result.isFailure(resolver)) return Result.fail(resolver.failure);
|
|
323
|
+
|
|
324
|
+
return Result.succeed({
|
|
325
|
+
repoRoot,
|
|
326
|
+
config,
|
|
327
|
+
importRules: importRules.success,
|
|
328
|
+
exportRules: exportRules.success,
|
|
329
|
+
memberRules: memberRules.success,
|
|
330
|
+
surfaceRules: surfaceRules.success,
|
|
331
|
+
graph: graph.success,
|
|
332
|
+
adoption: rules.adoption,
|
|
333
|
+
structure: structure.success,
|
|
334
|
+
fileSystem,
|
|
335
|
+
languages,
|
|
336
|
+
baseline: makeBaselineFilter(readBaseline(fileSystem, config.baseline)),
|
|
337
|
+
resolver: resolver.success,
|
|
338
|
+
extractor,
|
|
339
|
+
ignoreUnresolved: (config.resolve.ignoreUnresolved ?? []).map(
|
|
340
|
+
(pattern: string) => new RegExp(pattern),
|
|
341
|
+
),
|
|
342
|
+
notices: decoded.success.notices,
|
|
343
|
+
});
|
|
344
|
+
};
|