@goodbones/core 0.1.0-beta.3 → 0.1.0-beta.5
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/build/dts/core/coverage.d.ts +14 -0
- package/build/dts/core/coverage.d.ts.map +1 -1
- package/build/dts/core/graph.d.ts +2 -0
- package/build/dts/core/graph.d.ts.map +1 -1
- package/build/dts/core/imports.d.ts +3 -1
- package/build/dts/core/imports.d.ts.map +1 -1
- package/build/dts/core/slack.d.ts +10 -0
- package/build/dts/core/slack.d.ts.map +1 -0
- package/build/dts/domain/architecture-config.d.ts +13 -0
- package/build/dts/domain/architecture-config.d.ts.map +1 -1
- package/build/dts/domain/snapshot.d.ts +121 -0
- package/build/dts/domain/snapshot.d.ts.map +1 -0
- package/build/dts/index.d.ts +8 -5
- package/build/dts/index.d.ts.map +1 -1
- package/build/dts/infrastructure/walk.d.ts +6 -0
- package/build/dts/infrastructure/walk.d.ts.map +1 -1
- package/build/dts/manifest/compile.d.ts.map +1 -1
- package/build/dts/manifest/infer.d.ts +55 -0
- package/build/dts/manifest/infer.d.ts.map +1 -0
- package/build/dts/ports/language.d.ts +2 -0
- package/build/dts/ports/language.d.ts.map +1 -1
- package/build/esm/core/coverage.js +72 -32
- package/build/esm/core/coverage.js.map +1 -1
- package/build/esm/core/graph.js +45 -0
- package/build/esm/core/graph.js.map +1 -1
- package/build/esm/core/imports.js +14 -9
- package/build/esm/core/imports.js.map +1 -1
- package/build/esm/core/slack.js +39 -0
- package/build/esm/core/slack.js.map +1 -0
- package/build/esm/domain/architecture-config.js +19 -0
- package/build/esm/domain/architecture-config.js.map +1 -1
- package/build/esm/domain/snapshot.js +95 -0
- package/build/esm/domain/snapshot.js.map +1 -0
- package/build/esm/index.js +7 -4
- package/build/esm/index.js.map +1 -1
- package/build/esm/infrastructure/walk.js +57 -1
- package/build/esm/infrastructure/walk.js.map +1 -1
- package/build/esm/manifest/compile.js +25 -21
- package/build/esm/manifest/compile.js.map +1 -1
- package/build/esm/manifest/infer.js +455 -0
- package/build/esm/manifest/infer.js.map +1 -0
- package/package.json +3 -1
- package/schema/conformance.schema.json +457 -0
- package/src/core/coverage.ts +111 -34
- package/src/core/graph.ts +48 -0
- package/src/core/imports.ts +29 -13
- package/src/core/slack.ts +62 -0
- package/src/domain/architecture-config.ts +21 -0
- package/src/domain/snapshot.ts +180 -0
- package/src/index.ts +38 -1
- package/src/infrastructure/walk.ts +70 -1
- package/src/manifest/compile.ts +34 -27
- package/src/manifest/infer.ts +643 -0
- package/src/ports/language.ts +11 -0
package/src/core/imports.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import * as Result from "effect/Result";
|
|
2
2
|
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
Allowance,
|
|
5
|
+
ImportProbe,
|
|
6
|
+
ImportProbeTarget,
|
|
7
|
+
ImportRule,
|
|
8
|
+
} from "../domain/architecture-config.js";
|
|
4
9
|
import type { ImportUnresolved, PatternInvalid } from "../domain/architecture-error.js";
|
|
5
10
|
import type { Violation } from "../domain/violation.js";
|
|
6
11
|
import type { DependencyKind, ModuleResolver, ResolvedTarget } from "../ports/module-resolver.js";
|
|
@@ -25,6 +30,9 @@ export type CompiledImportRule = {
|
|
|
25
30
|
// Third-party packages the rule permits, by name. Judged before the path
|
|
26
31
|
// patterns, so where a language keeps its packages never reaches a rule.
|
|
27
32
|
readonly externals: ReadonlySet<string>;
|
|
33
|
+
// Where each `toNot` and external came from, when lowered from a manifest;
|
|
34
|
+
// empty for a hand-written rule. What `slackOf` reads.
|
|
35
|
+
readonly allowances: ReadonlyArray<Allowance>;
|
|
28
36
|
readonly dependencyKind: DependencyKind | null;
|
|
29
37
|
readonly probe: ImportProbe;
|
|
30
38
|
};
|
|
@@ -52,6 +60,7 @@ export const compileImportRule = (
|
|
|
52
60
|
to: sourcesOf(rule.to),
|
|
53
61
|
toNot: sourcesOf(rule.toNot),
|
|
54
62
|
externals: new Set(rule.externals ?? []),
|
|
63
|
+
allowances: rule.allowances ?? [],
|
|
55
64
|
dependencyKind: rule.dependencyKind ?? null,
|
|
56
65
|
});
|
|
57
66
|
};
|
|
@@ -105,17 +114,13 @@ const reports = (rule: CompiledImportRule, captures: RegExpExecArray, target: Re
|
|
|
105
114
|
return targetAllowed(rule, captures, target.path);
|
|
106
115
|
};
|
|
107
116
|
|
|
108
|
-
|
|
117
|
+
// The same judgement over a target the host has already resolved — for a host
|
|
118
|
+
// that resolves each edge once and wants the target for something else too.
|
|
119
|
+
export const evaluateResolvedEdge = (
|
|
109
120
|
selected: ReadonlyArray<SelectedRule>,
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
):
|
|
113
|
-
if (selected.length === 0) return Result.succeed([]);
|
|
114
|
-
|
|
115
|
-
const resolved = resolver.resolve(edge.importer, edge.specifier);
|
|
116
|
-
if (Result.isFailure(resolved)) return Result.fail(resolved.failure);
|
|
117
|
-
const target = resolved.success;
|
|
118
|
-
|
|
121
|
+
importer: string,
|
|
122
|
+
target: ResolvedTarget,
|
|
123
|
+
): ReadonlyArray<Violation> => {
|
|
119
124
|
const violations: Array<Violation> = [];
|
|
120
125
|
for (const [rule, captures] of selected) {
|
|
121
126
|
if (reports(rule, captures, target)) {
|
|
@@ -123,13 +128,24 @@ export const evaluateSelectedEdge = (
|
|
|
123
128
|
kind: "import",
|
|
124
129
|
ruleName: rule.name,
|
|
125
130
|
message: rule.message,
|
|
126
|
-
file:
|
|
131
|
+
file: importer,
|
|
127
132
|
subject: target.path,
|
|
128
133
|
});
|
|
129
134
|
}
|
|
130
135
|
}
|
|
136
|
+
return violations;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
export const evaluateSelectedEdge = (
|
|
140
|
+
selected: ReadonlyArray<SelectedRule>,
|
|
141
|
+
resolver: ModuleResolver,
|
|
142
|
+
edge: ImportEdge,
|
|
143
|
+
): Result.Result<ReadonlyArray<Violation>, ImportUnresolved> => {
|
|
144
|
+
if (selected.length === 0) return Result.succeed([]);
|
|
131
145
|
|
|
132
|
-
|
|
146
|
+
const resolved = resolver.resolve(edge.importer, edge.specifier);
|
|
147
|
+
if (Result.isFailure(resolved)) return Result.fail(resolved.failure);
|
|
148
|
+
return Result.succeed(evaluateResolvedEdge(selected, edge.importer, resolved.success));
|
|
133
149
|
};
|
|
134
150
|
|
|
135
151
|
export const evaluateImportEdge = (
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { Allowance } from "../domain/architecture-config.js";
|
|
2
|
+
import type { ResolvedTarget } from "../ports/module-resolver.js";
|
|
3
|
+
import type { CompiledImportRule } from "./imports.js";
|
|
4
|
+
import { firstFromMatch, matchesAny } from "./patterns.js";
|
|
5
|
+
|
|
6
|
+
// Coverage says how many files an allowlist reaches. This is the other
|
|
7
|
+
// question about an allowlist: how much of it is used. A manifest inferred
|
|
8
|
+
// from today's edges reaches every file and constrains nothing, so coverage
|
|
9
|
+
// alone reads as complete while the policy is pure permission; slack is the
|
|
10
|
+
// number that tells the two apart. It is also the signature of an allowlist
|
|
11
|
+
// widened to make a build green — an entry nothing imports through.
|
|
12
|
+
|
|
13
|
+
// An edge the host resolved: the importer, and what the specifier became.
|
|
14
|
+
export type ObservedEdge = {
|
|
15
|
+
readonly importer: string;
|
|
16
|
+
readonly target: ResolvedTarget;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// An allowance no observed edge uses. The same shape the lowering recorded,
|
|
20
|
+
// minus the compiled pattern nobody wrote.
|
|
21
|
+
export type Slack = Pick<Allowance, "node" | "kind" | "entry">;
|
|
22
|
+
|
|
23
|
+
const keyOf = (one: Slack): string => `${one.node} ${one.kind} ${one.entry}`;
|
|
24
|
+
|
|
25
|
+
// Whether one edge, from a file this rule selects, passes through this entry.
|
|
26
|
+
const uses = (allowance: Allowance, captures: RegExpExecArray, target: ResolvedTarget): boolean => {
|
|
27
|
+
if (allowance.kind === "external") {
|
|
28
|
+
return target.kind === "external" && target.package === allowance.entry;
|
|
29
|
+
}
|
|
30
|
+
return allowance.pattern !== undefined && matchesAny([allowance.pattern], captures, target.path);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// Every allowance the rules carry that no edge uses, in the order the manifest
|
|
34
|
+
// declared them. An entry is inherited by every descendant's rule and written
|
|
35
|
+
// once, so it is keyed by where it was written: an import anywhere under the
|
|
36
|
+
// declaring node is a use.
|
|
37
|
+
export const slackOf = (
|
|
38
|
+
rules: ReadonlyArray<CompiledImportRule>,
|
|
39
|
+
edges: ReadonlyArray<ObservedEdge>,
|
|
40
|
+
): ReadonlyArray<Slack> => {
|
|
41
|
+
const declared = new Map<string, Slack>();
|
|
42
|
+
for (const rule of rules) {
|
|
43
|
+
for (const { entry, kind, node } of rule.allowances) {
|
|
44
|
+
const one = { node, kind, entry };
|
|
45
|
+
if (!declared.has(keyOf(one))) declared.set(keyOf(one), one);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const used = new Set<string>();
|
|
50
|
+
for (const edge of edges) {
|
|
51
|
+
for (const rule of rules) {
|
|
52
|
+
if (rule.allowances.length === 0) continue;
|
|
53
|
+
const captures = firstFromMatch(rule, edge.importer);
|
|
54
|
+
if (captures === null) continue;
|
|
55
|
+
for (const allowance of rule.allowances) {
|
|
56
|
+
if (uses(allowance, captures, edge.target)) used.add(keyOf(allowance));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return [...declared.values()].filter((one) => !used.has(keyOf(one)));
|
|
62
|
+
};
|
|
@@ -27,6 +27,23 @@ const ImportProbe = Schema.Struct({
|
|
|
27
27
|
to: ImportProbeTarget,
|
|
28
28
|
});
|
|
29
29
|
|
|
30
|
+
// One entry of an `imports` allowlist, as the author wrote it and where. An
|
|
31
|
+
// allowlist rule compiles its entries into `toNot` patterns and `externals`
|
|
32
|
+
// names, which is all evaluation needs; this is the other direction — from a
|
|
33
|
+
// pattern back to the line in the manifest that put it there — so a report
|
|
34
|
+
// can say which line no import uses. Slack is the signature of an allowlist
|
|
35
|
+
// widened to make a build green, and it is only visible with the entry kept.
|
|
36
|
+
export const Allowance = Schema.Struct({
|
|
37
|
+
// The manifest node that declared the entry, as rule names name nodes.
|
|
38
|
+
node: Schema.String,
|
|
39
|
+
// `allow` is a path glob; `external` is a package name.
|
|
40
|
+
kind: Schema.Literals(["allow", "external"]),
|
|
41
|
+
// The entry as written, after alias expansion.
|
|
42
|
+
entry: Schema.String,
|
|
43
|
+
// For an `allow`: the compiled target pattern, as `toNot` carries it.
|
|
44
|
+
pattern: Schema.optionalKey(Schema.String),
|
|
45
|
+
});
|
|
46
|
+
|
|
30
47
|
export const ImportRule = Schema.Struct({
|
|
31
48
|
name: Schema.String,
|
|
32
49
|
message: Schema.String,
|
|
@@ -35,6 +52,9 @@ export const ImportRule = Schema.Struct({
|
|
|
35
52
|
fromNot: Schema.optionalKey(PatternList),
|
|
36
53
|
to: Schema.optionalKey(PatternList),
|
|
37
54
|
toNot: Schema.optionalKey(PatternList),
|
|
55
|
+
// Where each `toNot` pattern and `externals` name came from, for an
|
|
56
|
+
// allowlist lowered from a manifest. A hand-written rule carries none.
|
|
57
|
+
allowances: Schema.optionalKey(Schema.Array(Allowance)),
|
|
38
58
|
// Third-party packages this rule permits, by package name. An external
|
|
39
59
|
// target is judged by its package, never by where the language's resolver
|
|
40
60
|
// happened to find it on disk — `to`/`toNot` patterns are for the
|
|
@@ -336,6 +356,7 @@ const StructureConfig = Schema.Struct({
|
|
|
336
356
|
naming: Schema.optionalKey(Schema.Array(StructureNaming)),
|
|
337
357
|
});
|
|
338
358
|
|
|
359
|
+
export type Allowance = (typeof Allowance)["Type"];
|
|
339
360
|
export type ImportRule = (typeof ImportRule)["Type"];
|
|
340
361
|
export type ResolveConfig = (typeof ResolveConfig)["Type"];
|
|
341
362
|
export type ResolveScope = (typeof ResolveScope)["Type"];
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import * as Schema from "effect/Schema";
|
|
2
|
+
|
|
3
|
+
import type { ViolationKind } from "./violation.js";
|
|
4
|
+
|
|
5
|
+
// The conformance snapshot: one JSON document per run saying how far the
|
|
6
|
+
// tree is from the manifest. `architecture conformance --json` emits it, a
|
|
7
|
+
// pull-request check compares two of them, an agent reads one before it
|
|
8
|
+
// edits, and a service that keeps history stores them — so it is a wire
|
|
9
|
+
// format, defined here with a schema before anything consumes it, and
|
|
10
|
+
// versioned so a document that grows it can say which one it grew.
|
|
11
|
+
//
|
|
12
|
+
// Three things it says that `check` does not: the residue (what no family
|
|
13
|
+
// reaches), the slack (what the allowlists permit and nothing uses), and the
|
|
14
|
+
// violations ordered by how much of the graph each fix drags along. Every
|
|
15
|
+
// entry that names a violation names it by its line-independent fingerprint,
|
|
16
|
+
// which is what lets one be tracked across commits.
|
|
17
|
+
|
|
18
|
+
export const SNAPSHOT_SCHEMA_ID =
|
|
19
|
+
"https://dataquail.github.io/goodbones/schema/conformance.schema.json";
|
|
20
|
+
|
|
21
|
+
export const SNAPSHOT_VERSION = 1;
|
|
22
|
+
|
|
23
|
+
const describe = <S extends Schema.Top>(schema: S, description: string) =>
|
|
24
|
+
schema.annotate({ description });
|
|
25
|
+
|
|
26
|
+
const COVERAGE_FAMILIES = ["imports", "structure", "members", "surface", "graph"] as const;
|
|
27
|
+
|
|
28
|
+
// The kinds `Violation` carries, spelled here as a schema; the `satisfies`
|
|
29
|
+
// below keeps the two lists one.
|
|
30
|
+
const VIOLATION_KINDS = [
|
|
31
|
+
"import",
|
|
32
|
+
"export",
|
|
33
|
+
"structure",
|
|
34
|
+
"member",
|
|
35
|
+
"surface",
|
|
36
|
+
"graph",
|
|
37
|
+
] as const satisfies ReadonlyArray<ViolationKind>;
|
|
38
|
+
|
|
39
|
+
const Path = describe(Schema.String, "Repo-relative, with forward slashes.");
|
|
40
|
+
|
|
41
|
+
const FamilyCoverage = Schema.Struct({
|
|
42
|
+
covered: describe(Schema.Finite, "Files this family reaches."),
|
|
43
|
+
total: describe(Schema.Finite, "Files walked."),
|
|
44
|
+
floor: Schema.optionalKey(
|
|
45
|
+
describe(
|
|
46
|
+
Schema.Finite,
|
|
47
|
+
"The fraction the manifest's `limits.coverage` states for this family, when it states one.",
|
|
48
|
+
),
|
|
49
|
+
),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
export const SnapshotViolation = Schema.Struct({
|
|
53
|
+
fingerprint: describe(
|
|
54
|
+
Schema.String,
|
|
55
|
+
"kind|rule|file|subject — line-independent, so it survives edits to the file it names; the baseline's key.",
|
|
56
|
+
),
|
|
57
|
+
kind: Schema.Literals(VIOLATION_KINDS),
|
|
58
|
+
ruleName: describe(Schema.String, "The manifest node path the rule was lowered from."),
|
|
59
|
+
file: Path,
|
|
60
|
+
subject: describe(
|
|
61
|
+
Schema.NullOr(Schema.String),
|
|
62
|
+
"The other end of the violated relationship — the resolved target, the restricted symbol, the missing sibling — or null when the file alone is the violation.",
|
|
63
|
+
),
|
|
64
|
+
message: Schema.String,
|
|
65
|
+
baselined: describe(Schema.Boolean, "Carried by the baseline, so `check` does not fail on it."),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
export const SnapshotSlack = Schema.Struct({
|
|
69
|
+
node: describe(Schema.String, "The manifest node that wrote the entry."),
|
|
70
|
+
kind: describe(
|
|
71
|
+
Schema.Literals(["allow", "external"]),
|
|
72
|
+
"`allow` is a path glob under `imports.allow`; `external` a package name under `imports.external`.",
|
|
73
|
+
),
|
|
74
|
+
entry: describe(Schema.String, "The entry as written, after alias expansion."),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
export const Snapshot = Schema.Struct({
|
|
78
|
+
version: describe(Schema.Literal(SNAPSHOT_VERSION), "The shape of this document."),
|
|
79
|
+
manifest: describe(
|
|
80
|
+
Schema.Struct({
|
|
81
|
+
path: describe(
|
|
82
|
+
Path,
|
|
83
|
+
"The file the policy was read from — the root file, when split with `include`.",
|
|
84
|
+
),
|
|
85
|
+
sha256: describe(
|
|
86
|
+
Schema.String,
|
|
87
|
+
"A hash of that file's bytes, so two snapshots can say whether the policy changed between them.",
|
|
88
|
+
),
|
|
89
|
+
}),
|
|
90
|
+
"The policy this snapshot was taken against — the repository's own, or the one `--against` named.",
|
|
91
|
+
),
|
|
92
|
+
roots: describe(Schema.Array(Path), "The directories walked."),
|
|
93
|
+
files: describe(Schema.Finite, "Files walked."),
|
|
94
|
+
ok: describe(
|
|
95
|
+
Schema.Boolean,
|
|
96
|
+
"What `check` would exit with: true when no reportable violation, unresolved import, stale baseline entry or coverage shortfall exists.",
|
|
97
|
+
),
|
|
98
|
+
coverage: describe(
|
|
99
|
+
Schema.Struct(
|
|
100
|
+
Object.fromEntries(COVERAGE_FAMILIES.map((family) => [family, FamilyCoverage])) as Record<
|
|
101
|
+
(typeof COVERAGE_FAMILIES)[number],
|
|
102
|
+
typeof FamilyCoverage
|
|
103
|
+
>,
|
|
104
|
+
),
|
|
105
|
+
"Per family, how many walked files it reaches. Structure counts enumerated folders only.",
|
|
106
|
+
),
|
|
107
|
+
residue: describe(
|
|
108
|
+
Schema.Struct({
|
|
109
|
+
files: describe(Schema.Array(Path), "Files no family reaches, sorted."),
|
|
110
|
+
folders: describe(
|
|
111
|
+
Schema.Array(Path),
|
|
112
|
+
"Folders every walked file of which is residue, each the topmost such folder.",
|
|
113
|
+
),
|
|
114
|
+
}),
|
|
115
|
+
"What the policy has nothing to say about. A file in an open folder under no allowlist is claimed, not policed, and counts.",
|
|
116
|
+
),
|
|
117
|
+
violations: describe(
|
|
118
|
+
Schema.Array(SnapshotViolation),
|
|
119
|
+
"Every finding, baselined ones included, ordered so the ones cheapest to fix come first: by the height of the violated target in the import graph, leaves first.",
|
|
120
|
+
),
|
|
121
|
+
unresolved: describe(
|
|
122
|
+
Schema.Array(Schema.Struct({ file: Path, specifier: Schema.String, detail: Schema.String })),
|
|
123
|
+
"Imports the resolver could not turn into a file. Every rule about one enforces nothing.",
|
|
124
|
+
),
|
|
125
|
+
stale: describe(Schema.Array(Schema.String), "Baseline entries the code no longer produces."),
|
|
126
|
+
baseline: describe(
|
|
127
|
+
Schema.Struct({
|
|
128
|
+
size: describe(Schema.Finite, "Entries in the baseline file."),
|
|
129
|
+
}),
|
|
130
|
+
"The debt the policy is carrying. The ratchet: it may only shrink.",
|
|
131
|
+
),
|
|
132
|
+
cycles: describe(
|
|
133
|
+
Schema.Finite,
|
|
134
|
+
"Strongly connected components of more than one file, or a file importing itself, anywhere in the walked graph — in a cycles rule's scope or not.",
|
|
135
|
+
),
|
|
136
|
+
slack: describe(
|
|
137
|
+
Schema.Array(SnapshotSlack),
|
|
138
|
+
"Allowances no observed import uses, in manifest order. A manifest inferred from the tree has none on the day it is written; every entry here is permission nothing needs.",
|
|
139
|
+
),
|
|
140
|
+
adoption: describe(
|
|
141
|
+
Schema.Struct({
|
|
142
|
+
unrestricted: describe(Schema.Array(Schema.String), "Nodes that say `unrestricted: true`."),
|
|
143
|
+
partial: describe(Schema.Array(Schema.String), "Nodes that say `partial: true`."),
|
|
144
|
+
}),
|
|
145
|
+
'The tiers that said "not tightened yet", by name; `limits` caps how many may.',
|
|
146
|
+
),
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
export type Snapshot = typeof Snapshot.Type;
|
|
150
|
+
export type SnapshotViolation = typeof SnapshotViolation.Type;
|
|
151
|
+
export type SnapshotSlack = typeof SnapshotSlack.Type;
|
|
152
|
+
|
|
153
|
+
// Decodes a document some other run wrote — the base of a pull request, a
|
|
154
|
+
// stored one — refusing a key the shape does not declare, so a consumer never
|
|
155
|
+
// reads a field that a later version renamed.
|
|
156
|
+
export const decodeSnapshot = Schema.decodeUnknownResult(Snapshot, {
|
|
157
|
+
errors: "all",
|
|
158
|
+
onExcessProperty: "error",
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
type JsonValue = string | number | boolean | null | JsonObject | ReadonlyArray<JsonValue>;
|
|
162
|
+
type JsonObject = { readonly [key: string]: JsonValue };
|
|
163
|
+
|
|
164
|
+
// The document's shape as a JSON Schema, generated from the same codec, so
|
|
165
|
+
// the two cannot disagree. Published beside the manifest's.
|
|
166
|
+
export const snapshotJsonSchema = (): JsonObject => {
|
|
167
|
+
const generated = Schema.toJsonSchemaDocument(Snapshot) as unknown as {
|
|
168
|
+
readonly schema: JsonObject;
|
|
169
|
+
readonly definitions: JsonObject;
|
|
170
|
+
};
|
|
171
|
+
return {
|
|
172
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
173
|
+
$id: SNAPSHOT_SCHEMA_ID,
|
|
174
|
+
title: "Conformance snapshot",
|
|
175
|
+
description:
|
|
176
|
+
"How far a repository's tree is from its architecture manifest, as `architecture conformance --json` reports it. See https://dataquail.github.io/goodbones/architecture-rules/enforcement/conformance/.",
|
|
177
|
+
...generated.schema,
|
|
178
|
+
...(Object.keys(generated.definitions).length === 0 ? {} : { $defs: generated.definitions }),
|
|
179
|
+
};
|
|
180
|
+
};
|
package/src/index.ts
CHANGED
|
@@ -21,6 +21,10 @@ export {
|
|
|
21
21
|
coverageOf,
|
|
22
22
|
coverageShortfalls,
|
|
23
23
|
fractionsOf,
|
|
24
|
+
type Reach,
|
|
25
|
+
reachOf,
|
|
26
|
+
type Residue,
|
|
27
|
+
residueOf,
|
|
24
28
|
} from "./core/coverage.js";
|
|
25
29
|
export {
|
|
26
30
|
type BindingEdge,
|
|
@@ -37,17 +41,20 @@ export {
|
|
|
37
41
|
export {
|
|
38
42
|
type CompiledGraph,
|
|
39
43
|
compileGraphRules,
|
|
44
|
+
cyclesIn,
|
|
40
45
|
EMPTY_GRAPH_RULES,
|
|
41
46
|
evaluateGraph,
|
|
42
47
|
type Graph,
|
|
43
48
|
graphRulesFailingTheirProbe,
|
|
44
49
|
hasGraphRules,
|
|
50
|
+
heightOf,
|
|
45
51
|
} from "./core/graph.js";
|
|
46
52
|
export {
|
|
47
53
|
type CompiledImportRule,
|
|
48
54
|
compileImportRule,
|
|
49
55
|
compileImportRules,
|
|
50
56
|
evaluateImportEdge,
|
|
57
|
+
evaluateResolvedEdge,
|
|
51
58
|
evaluateSelectedEdge,
|
|
52
59
|
type ImportEdge,
|
|
53
60
|
probeTargetOf,
|
|
@@ -62,6 +69,7 @@ export {
|
|
|
62
69
|
memberRulesFailingTheirProbe,
|
|
63
70
|
memberRulesSelecting,
|
|
64
71
|
} from "./core/members.js";
|
|
72
|
+
export { type ObservedEdge, type Slack, slackOf } from "./core/slack.js";
|
|
65
73
|
export {
|
|
66
74
|
type CompiledStructure,
|
|
67
75
|
compileStructure,
|
|
@@ -78,6 +86,7 @@ export {
|
|
|
78
86
|
surfaceRulesSelecting,
|
|
79
87
|
} from "./core/surface.js";
|
|
80
88
|
export {
|
|
89
|
+
type Allowance,
|
|
81
90
|
type BindingKind,
|
|
82
91
|
type DeclarationKind,
|
|
83
92
|
type ExportFix,
|
|
@@ -114,6 +123,16 @@ export {
|
|
|
114
123
|
type ManifestPosition,
|
|
115
124
|
renderManifestPath,
|
|
116
125
|
} from "./domain/manifest-location.js";
|
|
126
|
+
export {
|
|
127
|
+
decodeSnapshot,
|
|
128
|
+
type Snapshot,
|
|
129
|
+
SNAPSHOT_SCHEMA_ID,
|
|
130
|
+
SNAPSHOT_VERSION,
|
|
131
|
+
snapshotJsonSchema,
|
|
132
|
+
Snapshot as SnapshotSchema,
|
|
133
|
+
type SnapshotSlack,
|
|
134
|
+
type SnapshotViolation,
|
|
135
|
+
} from "./domain/snapshot.js";
|
|
117
136
|
export {
|
|
118
137
|
fingerprintOf,
|
|
119
138
|
formatMessage,
|
|
@@ -134,7 +153,13 @@ export {
|
|
|
134
153
|
type IncludeReader,
|
|
135
154
|
type SourceDocument,
|
|
136
155
|
} from "./infrastructure/manifest-include.js";
|
|
137
|
-
export {
|
|
156
|
+
export {
|
|
157
|
+
listPackageRoots,
|
|
158
|
+
listSourceFiles,
|
|
159
|
+
type PackagedLanguage,
|
|
160
|
+
type PackageRoot,
|
|
161
|
+
type WalkedLanguage,
|
|
162
|
+
} from "./infrastructure/walk.js";
|
|
138
163
|
export { type LoadedPolicy, loadPolicy, type LoadPolicyInput } from "./load/policy.js";
|
|
139
164
|
export { type LoweredRules, lowerManifest, type ProbeLanguage } from "./manifest/compile.js";
|
|
140
165
|
export {
|
|
@@ -145,6 +170,18 @@ export {
|
|
|
145
170
|
originOf,
|
|
146
171
|
type Substitution,
|
|
147
172
|
} from "./manifest/expand.js";
|
|
173
|
+
export {
|
|
174
|
+
type Candidate,
|
|
175
|
+
candidatesOf,
|
|
176
|
+
type Generalization,
|
|
177
|
+
type InferInput,
|
|
178
|
+
inferManifest,
|
|
179
|
+
type InferOptions,
|
|
180
|
+
type InferPackage,
|
|
181
|
+
type Inferred,
|
|
182
|
+
type InferredTarget,
|
|
183
|
+
singularOf,
|
|
184
|
+
} from "./manifest/infer.js";
|
|
148
185
|
export {
|
|
149
186
|
MANIFEST_NODE_SCHEMA_ID,
|
|
150
187
|
MANIFEST_SCHEMA_ID,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readdirSync, statSync } from "node:fs";
|
|
1
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
|
|
4
4
|
import type { Language } from "../ports/language.js";
|
|
@@ -52,3 +52,72 @@ export const listSourceFiles = (
|
|
|
52
52
|
}
|
|
53
53
|
return found.sort();
|
|
54
54
|
};
|
|
55
|
+
|
|
56
|
+
// What `infer` needs to know about a language beyond the walker's two: what
|
|
57
|
+
// marks a package of it, and where a package keeps its source.
|
|
58
|
+
export type PackagedLanguage = Pick<Language, "packageMarkers" | "sourceRoots">;
|
|
59
|
+
|
|
60
|
+
export type PackageRoot = {
|
|
61
|
+
// Repo-relative folder with forward slashes; `""` is the repository itself.
|
|
62
|
+
readonly root: string;
|
|
63
|
+
// The package's source folder, repo-relative, when one of the language's
|
|
64
|
+
// `sourceRoots` exists under it; otherwise `null`, and source sits at the root.
|
|
65
|
+
readonly source: string | null;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// The packages under (and above) `roots`: every folder holding one of the
|
|
69
|
+
// languages' marker files, the walk roots' own ancestors included, so a walk
|
|
70
|
+
// rooted at `src/` still finds the `package.json` beside it. Sorted by path.
|
|
71
|
+
export const listPackageRoots = (
|
|
72
|
+
repoRoot: string,
|
|
73
|
+
roots: ReadonlyArray<string>,
|
|
74
|
+
languages: ReadonlyArray<PackagedLanguage>,
|
|
75
|
+
): ReadonlyArray<PackageRoot> => {
|
|
76
|
+
const markers = languages.flatMap((language) => language.packageMarkers);
|
|
77
|
+
const sourceRoots = languages.flatMap((language) => language.sourceRoots);
|
|
78
|
+
if (markers.length === 0) return [];
|
|
79
|
+
|
|
80
|
+
const isDirectory = (absolute: string): boolean => {
|
|
81
|
+
try {
|
|
82
|
+
return statSync(absolute).isDirectory();
|
|
83
|
+
} catch {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
const relative = (absolute: string): string =>
|
|
88
|
+
path.relative(repoRoot, absolute).replaceAll(path.sep, "/");
|
|
89
|
+
|
|
90
|
+
const found = new Map<string, PackageRoot>();
|
|
91
|
+
const consider = (absolute: string): void => {
|
|
92
|
+
const key = relative(absolute);
|
|
93
|
+
if (found.has(key)) return;
|
|
94
|
+
if (!markers.some((marker) => existsSync(path.join(absolute, marker)))) return;
|
|
95
|
+
const source = sourceRoots.find((name) => isDirectory(path.join(absolute, name)));
|
|
96
|
+
found.set(key, {
|
|
97
|
+
root: key,
|
|
98
|
+
source: source === undefined ? null : relative(path.join(absolute, source)),
|
|
99
|
+
});
|
|
100
|
+
};
|
|
101
|
+
const walk = (absolute: string): void => {
|
|
102
|
+
consider(absolute);
|
|
103
|
+
for (const entry of readdirSync(absolute)) {
|
|
104
|
+
if (SKIPPED.has(entry)) continue;
|
|
105
|
+
const child = path.join(absolute, entry);
|
|
106
|
+
if (isDirectory(child)) walk(child);
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const top = path.resolve(repoRoot);
|
|
111
|
+
for (const root of roots) {
|
|
112
|
+
let absolute = path.resolve(repoRoot, root);
|
|
113
|
+
if (!isDirectory(absolute)) absolute = path.dirname(absolute);
|
|
114
|
+
walk(absolute);
|
|
115
|
+
// The ancestors, up to the repository: a package is as often above the
|
|
116
|
+
// walk root as inside it.
|
|
117
|
+
for (let above = absolute; above.startsWith(top); above = path.dirname(above)) {
|
|
118
|
+
consider(above);
|
|
119
|
+
if (above === top) break;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return [...found.values()].sort((a, b) => a.root.localeCompare(b.root));
|
|
123
|
+
};
|
package/src/manifest/compile.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
type Allowance,
|
|
2
3
|
type ExportRule,
|
|
3
4
|
type GraphConfig,
|
|
4
5
|
type ImportRule,
|
|
@@ -84,12 +85,11 @@ type Frame = {
|
|
|
84
85
|
readonly pathGlob: string;
|
|
85
86
|
readonly captures: CaptureIndex;
|
|
86
87
|
readonly nextGroup: number;
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
|
|
92
|
-
readonly externals: ReadonlyArray<string>;
|
|
88
|
+
// The allowlist in force, accumulated down the tree; `reset` is the only
|
|
89
|
+
// thing that clears it. Each entry remembers the node that wrote it. A path
|
|
90
|
+
// glob is compiled to a target pattern; a package is judged by its name and
|
|
91
|
+
// never by where the language's resolver found it, so it carries none.
|
|
92
|
+
readonly allowances: ReadonlyArray<Allowance>;
|
|
93
93
|
readonly importsMessage: string;
|
|
94
94
|
// Inherited like the allowlist: a tier states its naming convention once.
|
|
95
95
|
readonly naming: NamingSpec | undefined;
|
|
@@ -231,16 +231,12 @@ const mergeImports = (
|
|
|
231
231
|
aliases: Readonly<Record<string, string>>,
|
|
232
232
|
captures: CaptureIndex,
|
|
233
233
|
nextGroup: number,
|
|
234
|
-
|
|
234
|
+
node: string,
|
|
235
|
+
): Pick<Frame, "allowances" | "importsMessage"> & {
|
|
235
236
|
readonly deny: ReadonlyArray<Denial>;
|
|
236
237
|
} => {
|
|
237
238
|
if (spec === undefined) {
|
|
238
|
-
return {
|
|
239
|
-
allow: frame.allow,
|
|
240
|
-
externals: frame.externals,
|
|
241
|
-
deny: [],
|
|
242
|
-
importsMessage: frame.importsMessage,
|
|
243
|
-
};
|
|
239
|
+
return { allowances: frame.allowances, deny: [], importsMessage: frame.importsMessage };
|
|
244
240
|
}
|
|
245
241
|
|
|
246
242
|
const compileAllow = (glob: string): string =>
|
|
@@ -249,8 +245,15 @@ const mergeImports = (
|
|
|
249
245
|
.source,
|
|
250
246
|
);
|
|
251
247
|
|
|
252
|
-
const own =
|
|
253
|
-
|
|
248
|
+
const own: ReadonlyArray<Allowance> = [
|
|
249
|
+
...globsOf(spec.allow ?? []).map((glob) => ({
|
|
250
|
+
node,
|
|
251
|
+
kind: "allow" as const,
|
|
252
|
+
entry: expandAliases(glob, aliases),
|
|
253
|
+
pattern: compileAllow(glob),
|
|
254
|
+
})),
|
|
255
|
+
...(spec.external ?? []).map((name) => ({ node, kind: "external" as const, entry: name })),
|
|
256
|
+
];
|
|
254
257
|
const deny = (spec.deny ?? []).flatMap((entry) =>
|
|
255
258
|
globsOf(entry.match).map((glob) => ({
|
|
256
259
|
match: compileAllow(glob),
|
|
@@ -266,8 +269,7 @@ const mergeImports = (
|
|
|
266
269
|
// a mistake here would be dangerous in.
|
|
267
270
|
const dropping = spec.reset === true || spec.unrestricted === true;
|
|
268
271
|
return {
|
|
269
|
-
|
|
270
|
-
externals: dropping ? external : [...frame.externals, ...external],
|
|
272
|
+
allowances: dropping ? own : [...frame.allowances, ...own],
|
|
271
273
|
// Only what this node declares. A prohibition is emitted once, over its whole
|
|
272
274
|
// subtree, so descendants neither re-emit it nor can escape it — which is
|
|
273
275
|
// what makes `reset` structurally unable to make a subtree quieter.
|
|
@@ -392,15 +394,14 @@ export const lowerManifest = (
|
|
|
392
394
|
parent.nextGroup +
|
|
393
395
|
(Object.keys(compiled.captures).length - Object.keys(parent.captures).length);
|
|
394
396
|
|
|
395
|
-
const merged = mergeImports(parent, node.imports, aliases, compiled.captures, nextGroup);
|
|
397
|
+
const merged = mergeImports(parent, node.imports, aliases, compiled.captures, nextGroup, name);
|
|
396
398
|
const ownDenials = merged.deny;
|
|
397
399
|
const frame: Frame = {
|
|
398
400
|
pathSource,
|
|
399
401
|
pathGlob: joinedGlob,
|
|
400
402
|
captures: compiled.captures,
|
|
401
403
|
nextGroup,
|
|
402
|
-
|
|
403
|
-
externals: merged.externals,
|
|
404
|
+
allowances: merged.allowances,
|
|
404
405
|
importsMessage: merged.importsMessage,
|
|
405
406
|
naming: node.name ?? parent.naming,
|
|
406
407
|
};
|
|
@@ -608,9 +609,14 @@ export const lowerManifest = (
|
|
|
608
609
|
? probePathOf(joinedGlob, "")
|
|
609
610
|
: probePathOf(joinedGlob, "").replace(/\/[^/]*$/, "");
|
|
610
611
|
|
|
611
|
-
const
|
|
612
|
-
|
|
613
|
-
|
|
612
|
+
const allow = frame.allowances.flatMap((one) =>
|
|
613
|
+
one.pattern === undefined ? [] : [one.pattern],
|
|
614
|
+
);
|
|
615
|
+
const externals = frame.allowances
|
|
616
|
+
.filter((one) => one.kind === "external")
|
|
617
|
+
.map((one) => one.entry);
|
|
618
|
+
const admitsEverything = allow.some((pattern) => pattern === "^.*" || pattern === "^");
|
|
619
|
+
const hasAllowlist = frame.allowances.length > 0 && !admitsEverything;
|
|
614
620
|
|
|
615
621
|
if (emitsOwnImports && node.imports?.unrestricted !== true && !hasAllowlist) {
|
|
616
622
|
throw new Error(
|
|
@@ -630,8 +636,9 @@ export const lowerManifest = (
|
|
|
630
636
|
probe: { from: scopeProbe, to: probeOutside("nowhere", ownFolder) },
|
|
631
637
|
from: scope,
|
|
632
638
|
...exemptions,
|
|
633
|
-
toNot:
|
|
634
|
-
...(
|
|
639
|
+
toNot: allow,
|
|
640
|
+
...(externals.length > 0 ? { externals } : {}),
|
|
641
|
+
allowances: frame.allowances,
|
|
635
642
|
});
|
|
636
643
|
}
|
|
637
644
|
}
|
|
@@ -841,8 +848,7 @@ export const lowerManifest = (
|
|
|
841
848
|
pathGlob: "",
|
|
842
849
|
captures: {},
|
|
843
850
|
nextGroup: 1,
|
|
844
|
-
|
|
845
|
-
externals: [],
|
|
851
|
+
allowances: [],
|
|
846
852
|
importsMessage: "This import is not on this folder's allowlist.",
|
|
847
853
|
naming: undefined,
|
|
848
854
|
};
|
|
@@ -856,6 +862,7 @@ export const lowerManifest = (
|
|
|
856
862
|
aliases,
|
|
857
863
|
{},
|
|
858
864
|
1,
|
|
865
|
+
"repo",
|
|
859
866
|
).deny.entries()) {
|
|
860
867
|
imports.push({
|
|
861
868
|
name: `repo/deny-${String(index)}`,
|