@goodbones/core 0.1.0-beta.1 → 0.1.0-beta.10
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/campaigns.d.ts +141 -0
- package/build/dts/core/campaigns.d.ts.map +1 -0
- package/build/dts/core/coverage.d.ts +20 -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/ledger.d.ts +61 -0
- package/build/dts/core/ledger.d.ts.map +1 -0
- package/build/dts/core/slack.d.ts +22 -0
- package/build/dts/core/slack.d.ts.map +1 -0
- package/build/dts/core/structure.d.ts +1 -0
- package/build/dts/core/structure.d.ts.map +1 -1
- package/build/dts/domain/architecture-config.d.ts +324 -1
- package/build/dts/domain/architecture-config.d.ts.map +1 -1
- package/build/dts/domain/architecture-error.d.ts +8 -0
- package/build/dts/domain/architecture-error.d.ts.map +1 -1
- package/build/dts/domain/facts.d.ts +1 -0
- package/build/dts/domain/facts.d.ts.map +1 -1
- package/build/dts/domain/manifest-location.d.ts +9 -0
- package/build/dts/domain/manifest-location.d.ts.map +1 -0
- package/build/dts/domain/report.d.ts +16 -0
- package/build/dts/domain/report.d.ts.map +1 -0
- package/build/dts/domain/snapshot.d.ts +224 -0
- package/build/dts/domain/snapshot.d.ts.map +1 -0
- package/build/dts/domain/violation.d.ts +1 -1
- package/build/dts/domain/violation.d.ts.map +1 -1
- package/build/dts/index.d.ts +24 -9
- package/build/dts/index.d.ts.map +1 -1
- package/build/dts/infrastructure/campaign-functions.d.ts +7 -0
- package/build/dts/infrastructure/campaign-functions.d.ts.map +1 -0
- package/build/dts/infrastructure/manifest-file.d.ts +11 -2
- package/build/dts/infrastructure/manifest-file.d.ts.map +1 -1
- package/build/dts/infrastructure/manifest-include.d.ts +16 -0
- package/build/dts/infrastructure/manifest-include.d.ts.map +1 -0
- package/build/dts/infrastructure/report-source-fake.d.ts +4 -0
- package/build/dts/infrastructure/report-source-fake.d.ts.map +1 -0
- package/build/dts/infrastructure/report-source-live.d.ts +3 -0
- package/build/dts/infrastructure/report-source-live.d.ts.map +1 -0
- package/build/dts/infrastructure/syntax-matcher-fake.d.ts +10 -0
- package/build/dts/infrastructure/syntax-matcher-fake.d.ts.map +1 -0
- package/build/dts/infrastructure/walk.d.ts +6 -0
- package/build/dts/infrastructure/walk.d.ts.map +1 -1
- package/build/dts/load/policy.d.ts +16 -0
- package/build/dts/load/policy.d.ts.map +1 -1
- package/build/dts/manifest/compile.d.ts +7 -2
- package/build/dts/manifest/compile.d.ts.map +1 -1
- package/build/dts/manifest/expand.d.ts +27 -0
- package/build/dts/manifest/expand.d.ts.map +1 -0
- package/build/dts/manifest/infer.d.ts +55 -0
- package/build/dts/manifest/infer.d.ts.map +1 -0
- package/build/dts/manifest/json-schema.d.ts +10 -0
- package/build/dts/manifest/json-schema.d.ts.map +1 -0
- package/build/dts/manifest/manifest.d.ts +381 -1
- package/build/dts/manifest/manifest.d.ts.map +1 -1
- package/build/dts/ports/campaign-predicate.d.ts +24 -0
- package/build/dts/ports/campaign-predicate.d.ts.map +1 -0
- package/build/dts/ports/language.d.ts +4 -0
- package/build/dts/ports/language.d.ts.map +1 -1
- package/build/dts/ports/report-source.d.ts +14 -0
- package/build/dts/ports/report-source.d.ts.map +1 -0
- package/build/dts/ports/syntax-matcher.d.ts +21 -0
- package/build/dts/ports/syntax-matcher.d.ts.map +1 -0
- package/build/dts/testing.d.ts +2 -0
- package/build/dts/testing.d.ts.map +1 -1
- package/build/esm/core/campaigns.js +778 -0
- package/build/esm/core/campaigns.js.map +1 -0
- package/build/esm/core/coverage.js +107 -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/ledger.js +171 -0
- package/build/esm/core/ledger.js.map +1 -0
- package/build/esm/core/slack.js +76 -0
- package/build/esm/core/slack.js.map +1 -0
- package/build/esm/core/structure.js +5 -2
- package/build/esm/core/structure.js.map +1 -1
- package/build/esm/domain/architecture-config.js +158 -1
- package/build/esm/domain/architecture-config.js.map +1 -1
- package/build/esm/domain/architecture-error.js +27 -0
- package/build/esm/domain/architecture-error.js.map +1 -1
- package/build/esm/domain/manifest-location.js +21 -0
- package/build/esm/domain/manifest-location.js.map +1 -0
- package/build/esm/domain/report.js +168 -0
- package/build/esm/domain/report.js.map +1 -0
- package/build/esm/domain/snapshot.js +141 -0
- package/build/esm/domain/snapshot.js.map +1 -0
- package/build/esm/domain/violation.js.map +1 -1
- package/build/esm/index.js +21 -8
- package/build/esm/index.js.map +1 -1
- package/build/esm/infrastructure/campaign-functions.js +67 -0
- package/build/esm/infrastructure/campaign-functions.js.map +1 -0
- package/build/esm/infrastructure/manifest-file.js +158 -7
- package/build/esm/infrastructure/manifest-file.js.map +1 -1
- package/build/esm/infrastructure/manifest-include.js +187 -0
- package/build/esm/infrastructure/manifest-include.js.map +1 -0
- package/build/esm/infrastructure/report-source-fake.js +6 -0
- package/build/esm/infrastructure/report-source-fake.js.map +1 -0
- package/build/esm/infrastructure/report-source-live.js +165 -0
- package/build/esm/infrastructure/report-source-live.js.map +1 -0
- package/build/esm/infrastructure/syntax-matcher-fake.js +33 -0
- package/build/esm/infrastructure/syntax-matcher-fake.js.map +1 -0
- package/build/esm/infrastructure/walk.js +57 -1
- package/build/esm/infrastructure/walk.js.map +1 -1
- package/build/esm/load/policy.js +123 -3
- package/build/esm/load/policy.js.map +1 -1
- package/build/esm/manifest/compile.js +228 -26
- package/build/esm/manifest/compile.js.map +1 -1
- package/build/esm/manifest/expand.js +116 -0
- package/build/esm/manifest/expand.js.map +1 -0
- package/build/esm/manifest/infer.js +455 -0
- package/build/esm/manifest/infer.js.map +1 -0
- package/build/esm/manifest/json-schema.js +135 -0
- package/build/esm/manifest/json-schema.js.map +1 -0
- package/build/esm/manifest/manifest.js +243 -5
- package/build/esm/manifest/manifest.js.map +1 -1
- package/build/esm/ports/campaign-predicate.js +2 -0
- package/build/esm/ports/campaign-predicate.js.map +1 -0
- package/build/esm/ports/report-source.js +7 -0
- package/build/esm/ports/report-source.js.map +1 -0
- package/build/esm/ports/syntax-matcher.js +12 -0
- package/build/esm/ports/syntax-matcher.js.map +1 -0
- package/build/esm/testing.js +2 -0
- package/build/esm/testing.js.map +1 -1
- package/package.json +9 -3
- package/schema/architecture-node.schema.json +2405 -0
- package/schema/architecture.schema.json +2825 -0
- package/schema/conformance.schema.json +775 -0
- package/src/core/campaigns.ts +1056 -0
- package/src/core/coverage.ts +164 -34
- package/src/core/graph.ts +48 -0
- package/src/core/imports.ts +29 -13
- package/src/core/ledger.ts +242 -0
- package/src/core/slack.ts +135 -0
- package/src/core/structure.ts +10 -5
- package/src/domain/architecture-config.ts +205 -1
- package/src/domain/architecture-error.ts +30 -0
- package/src/domain/facts.ts +9 -4
- package/src/domain/manifest-location.ts +41 -0
- package/src/domain/report.ts +203 -0
- package/src/domain/snapshot.ts +302 -0
- package/src/domain/violation.ts +4 -1
- package/src/index.ts +172 -3
- package/src/infrastructure/campaign-functions.ts +98 -0
- package/src/infrastructure/manifest-file.ts +204 -8
- package/src/infrastructure/manifest-include.ts +318 -0
- package/src/infrastructure/report-source-fake.ts +10 -0
- package/src/infrastructure/report-source-live.ts +192 -0
- package/src/infrastructure/syntax-matcher-fake.ts +51 -0
- package/src/infrastructure/walk.ts +70 -1
- package/src/load/policy.ts +193 -3
- package/src/manifest/compile.ts +290 -28
- package/src/manifest/expand.ts +183 -0
- package/src/manifest/infer.ts +643 -0
- package/src/manifest/json-schema.ts +168 -0
- package/src/manifest/manifest.ts +339 -11
- package/src/ports/campaign-predicate.ts +30 -0
- package/src/ports/language.ts +17 -0
- package/src/ports/report-source.ts +35 -0
- package/src/ports/syntax-matcher.ts +42 -0
- package/src/testing.ts +2 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import type { Allowance } from "../domain/architecture-config.js";
|
|
2
|
+
import type { ResolvedTarget } from "../ports/module-resolver.js";
|
|
3
|
+
import { vacantNodesOf } from "./coverage.js";
|
|
4
|
+
import type { CompiledImportRule } from "./imports.js";
|
|
5
|
+
import { firstFromMatch, matchesAny } from "./patterns.js";
|
|
6
|
+
|
|
7
|
+
// Coverage says how many files an allowlist reaches. This is the other
|
|
8
|
+
// question about an allowlist: how much of it is used. A manifest inferred
|
|
9
|
+
// from today's edges reaches every file and constrains nothing, so coverage
|
|
10
|
+
// alone reads as complete while the policy is pure permission; slack is the
|
|
11
|
+
// number that tells the two apart. It is also the signature of an allowlist
|
|
12
|
+
// widened to make a build green — an entry nothing imports through.
|
|
13
|
+
|
|
14
|
+
// An edge the host resolved: the importer, and what the specifier became.
|
|
15
|
+
export type ObservedEdge = {
|
|
16
|
+
readonly importer: string;
|
|
17
|
+
readonly target: ResolvedTarget;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// An allowance no observed edge uses. The same shape the lowering recorded,
|
|
21
|
+
// minus the compiled pattern nobody wrote. When the entry arrived through
|
|
22
|
+
// `use`, `node` is the fragment's name — the line to delete is in `defs` —
|
|
23
|
+
// and `of` says how many nodes wrote the reference that carried it.
|
|
24
|
+
export type Slack = Pick<Allowance, "node" | "kind" | "entry"> & {
|
|
25
|
+
readonly fragment?: string;
|
|
26
|
+
readonly of?: number;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// A fragment entry used at some of the nodes it was granted to and not the
|
|
30
|
+
// rest. At the fragment level it is not slack — there is no line nobody
|
|
31
|
+
// needs — but it is a per-file permission written as a many-node allowance,
|
|
32
|
+
// which is what an allowlist widened to make one build green looks like.
|
|
33
|
+
export type Concentration = Pick<Allowance, "kind" | "entry"> & {
|
|
34
|
+
readonly fragment: string;
|
|
35
|
+
readonly usedAt: number;
|
|
36
|
+
readonly of: number;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type SlackReport = {
|
|
40
|
+
readonly slack: ReadonlyArray<Slack>;
|
|
41
|
+
readonly concentration: ReadonlyArray<Concentration>;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const keyOf = (one: Pick<Allowance, "node" | "kind" | "entry">): string =>
|
|
45
|
+
`${one.node} ${one.kind} ${one.entry}`;
|
|
46
|
+
|
|
47
|
+
// Whether one edge, from a file this rule selects, passes through this entry.
|
|
48
|
+
const uses = (allowance: Allowance, captures: RegExpExecArray, target: ResolvedTarget): boolean => {
|
|
49
|
+
if (allowance.kind === "external") {
|
|
50
|
+
return target.kind === "external" && target.package === allowance.entry;
|
|
51
|
+
}
|
|
52
|
+
return allowance.pattern !== undefined && matchesAny([allowance.pattern], captures, target.path);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// One entry as one place wrote it: a node that wrote the line, or a fragment
|
|
56
|
+
// that N nodes pulled in with `use`. Keyed by that place, so a fragment's
|
|
57
|
+
// entry is reported once however many nodes reference it.
|
|
58
|
+
type Declared = {
|
|
59
|
+
readonly node: string;
|
|
60
|
+
readonly kind: Allowance["kind"];
|
|
61
|
+
readonly entry: string;
|
|
62
|
+
readonly fragment: string | undefined;
|
|
63
|
+
// The nodes that declared it, vacant ones excluded — every allowance on a
|
|
64
|
+
// vacant node is unused by construction, and is vacancy, not slack.
|
|
65
|
+
readonly at: Set<string>;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const groupKeyOf = (one: Allowance): string =>
|
|
69
|
+
one.fragment === undefined
|
|
70
|
+
? `node ${one.node} ${one.kind} ${one.entry}`
|
|
71
|
+
: `use ${one.fragment} ${one.kind} ${one.entry}`;
|
|
72
|
+
|
|
73
|
+
// Every allowance the rules carry that no edge uses, in the order the manifest
|
|
74
|
+
// declared them. An entry is inherited by every descendant's rule and written
|
|
75
|
+
// once, so it is keyed by where it was written: an import anywhere under the
|
|
76
|
+
// declaring node is a use. An entry that arrived through `use` is keyed by
|
|
77
|
+
// the fragment, and is slack only when no node it was granted to uses it;
|
|
78
|
+
// used at some and not others, it is reported as concentration instead. A
|
|
79
|
+
// vacant node — one whose allowlist selects no walked file — contributes
|
|
80
|
+
// nothing to either.
|
|
81
|
+
export const slackOf = (
|
|
82
|
+
rules: ReadonlyArray<CompiledImportRule>,
|
|
83
|
+
edges: ReadonlyArray<ObservedEdge>,
|
|
84
|
+
files: ReadonlyArray<string>,
|
|
85
|
+
): SlackReport => {
|
|
86
|
+
const vacant = vacantNodesOf(rules, files);
|
|
87
|
+
|
|
88
|
+
const declared = new Map<string, Declared>();
|
|
89
|
+
for (const rule of rules) {
|
|
90
|
+
for (const allowance of rule.allowances) {
|
|
91
|
+
if (vacant.has(allowance.node)) continue;
|
|
92
|
+
const key = groupKeyOf(allowance);
|
|
93
|
+
const group = declared.get(key) ?? {
|
|
94
|
+
node: allowance.fragment ?? allowance.node,
|
|
95
|
+
kind: allowance.kind,
|
|
96
|
+
entry: allowance.entry,
|
|
97
|
+
fragment: allowance.fragment,
|
|
98
|
+
at: new Set<string>(),
|
|
99
|
+
};
|
|
100
|
+
group.at.add(allowance.node);
|
|
101
|
+
declared.set(key, group);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Which (node, kind, entry) some edge passes through.
|
|
106
|
+
const used = new Set<string>();
|
|
107
|
+
for (const edge of edges) {
|
|
108
|
+
for (const rule of rules) {
|
|
109
|
+
if (rule.allowances.length === 0) continue;
|
|
110
|
+
const captures = firstFromMatch(rule, edge.importer);
|
|
111
|
+
if (captures === null) continue;
|
|
112
|
+
for (const allowance of rule.allowances) {
|
|
113
|
+
if (uses(allowance, captures, edge.target)) used.add(keyOf(allowance));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const slack: Array<Slack> = [];
|
|
119
|
+
const concentration: Array<Concentration> = [];
|
|
120
|
+
for (const group of declared.values()) {
|
|
121
|
+
const { entry, kind } = group;
|
|
122
|
+
const usedAt = [...group.at].filter((node) => used.has(keyOf({ node, kind, entry }))).length;
|
|
123
|
+
if (group.fragment === undefined) {
|
|
124
|
+
if (usedAt === 0) slack.push({ node: group.node, kind, entry });
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const of = group.at.size;
|
|
128
|
+
if (usedAt === 0) {
|
|
129
|
+
slack.push({ node: group.node, kind, entry, fragment: group.fragment, of });
|
|
130
|
+
} else if (usedAt < of) {
|
|
131
|
+
concentration.push({ fragment: group.fragment, kind, entry, usedAt, of });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return { slack, concentration };
|
|
135
|
+
};
|
package/src/core/structure.ts
CHANGED
|
@@ -191,17 +191,22 @@ const resolveSibling = (folder: string, relative: string): string => {
|
|
|
191
191
|
return segments.join("/");
|
|
192
192
|
};
|
|
193
193
|
|
|
194
|
-
|
|
195
|
-
|
|
194
|
+
// The paths a list of `requires` templates names beside a file. Shared with
|
|
195
|
+
// the campaigns evaluator, whose `requires` term asks the same question.
|
|
196
|
+
export const siblingsOf = (
|
|
197
|
+
templates: ReadonlyArray<string>,
|
|
196
198
|
file: string,
|
|
197
199
|
): ReadonlyArray<string> => {
|
|
198
200
|
const base = baseOf(basenameOf(file));
|
|
199
201
|
const folder = dirnameOf(file);
|
|
200
|
-
return
|
|
201
|
-
resolveSibling(folder, template.replaceAll("{base}", base)),
|
|
202
|
-
);
|
|
202
|
+
return templates.map((template) => resolveSibling(folder, template.replaceAll("{base}", base)));
|
|
203
203
|
};
|
|
204
204
|
|
|
205
|
+
export const requiredSiblingsOf = (
|
|
206
|
+
rule: CompiledStructureParity,
|
|
207
|
+
file: string,
|
|
208
|
+
): ReadonlyArray<string> => siblingsOf(rule.requires, file);
|
|
209
|
+
|
|
205
210
|
type NamingMatch = {
|
|
206
211
|
readonly subject: string;
|
|
207
212
|
readonly expected: string | null;
|
|
@@ -16,7 +16,7 @@ const PatternList = Schema.Union([Schema.String, Schema.Array(Schema.String)]);
|
|
|
16
16
|
// importer path. `to` is the target in one of three forms, and the form is its
|
|
17
17
|
// dependency kind: a repo-relative resolved path is `local`, `{ external }`
|
|
18
18
|
// names a third-party package, `{ builtin }` names a runtime module.
|
|
19
|
-
const ImportProbeTarget = Schema.Union([
|
|
19
|
+
export const ImportProbeTarget = Schema.Union([
|
|
20
20
|
Schema.String,
|
|
21
21
|
Schema.Struct({ external: Schema.String }),
|
|
22
22
|
Schema.Struct({ builtin: Schema.String }),
|
|
@@ -27,6 +27,28 @@ 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
|
+
// The `defs` fragment the entry arrived through, when the node wrote
|
|
46
|
+
// `use: <name>` rather than the entry itself. Slack is attributed to the
|
|
47
|
+
// fragment then: the node's authors wrote one word, and the line to delete
|
|
48
|
+
// is in `defs`.
|
|
49
|
+
fragment: Schema.optionalKey(Schema.String),
|
|
50
|
+
});
|
|
51
|
+
|
|
30
52
|
export const ImportRule = Schema.Struct({
|
|
31
53
|
name: Schema.String,
|
|
32
54
|
message: Schema.String,
|
|
@@ -35,6 +57,9 @@ export const ImportRule = Schema.Struct({
|
|
|
35
57
|
fromNot: Schema.optionalKey(PatternList),
|
|
36
58
|
to: Schema.optionalKey(PatternList),
|
|
37
59
|
toNot: Schema.optionalKey(PatternList),
|
|
60
|
+
// Where each `toNot` pattern and `externals` name came from, for an
|
|
61
|
+
// allowlist lowered from a manifest. A hand-written rule carries none.
|
|
62
|
+
allowances: Schema.optionalKey(Schema.Array(Allowance)),
|
|
38
63
|
// Third-party packages this rule permits, by package name. An external
|
|
39
64
|
// target is judged by its package, never by where the language's resolver
|
|
40
65
|
// happened to find it on disk — `to`/`toNot` patterns are for the
|
|
@@ -270,6 +295,178 @@ export const GraphConfig = Schema.Struct({
|
|
|
270
295
|
reach: Schema.optionalKey(Schema.Array(GraphReachRule)),
|
|
271
296
|
});
|
|
272
297
|
|
|
298
|
+
// A campaign: a migration tracked as an object in the repository. Where a
|
|
299
|
+
// rule says what may never happen, a campaign names a pattern the code is
|
|
300
|
+
// moving away from, ledgers every place it still occurs, and refuses to let
|
|
301
|
+
// the count go up unrecorded. Lowered from the manifest's `campaigns` list
|
|
302
|
+
// with its globs resolved; the detector below is what the evaluator compiles.
|
|
303
|
+
|
|
304
|
+
// What a hit is: a whole file, a named declaration in it, or one matched
|
|
305
|
+
// expression. The unit decides what a term from another level means (a
|
|
306
|
+
// file-level term in a declaration campaign is a filter; a declaration-level
|
|
307
|
+
// term in a file campaign is existential) and what the fingerprint anchors on.
|
|
308
|
+
export const CampaignUnit = Schema.Literals(["file", "declaration", "match"]);
|
|
309
|
+
|
|
310
|
+
// The detector's leaf terms. Every pattern is a regular-expression source,
|
|
311
|
+
// as everywhere else in this config; the manifest writes globs and lowering
|
|
312
|
+
// translates them. Each term answers at one level — `path`, `imports`,
|
|
313
|
+
// `requires`, `content` and a boolean `fn` about the file; `exports` and
|
|
314
|
+
// `members` about a declaration; `syntax` and a listing `fn` about a match.
|
|
315
|
+
const PathTerm = Schema.Struct({
|
|
316
|
+
file: PatternList,
|
|
317
|
+
fileNot: Schema.optionalKey(PatternList),
|
|
318
|
+
// With `convention`: which capture group of `file` holds the name being
|
|
319
|
+
// judged, and the shape it must have for the term to hold.
|
|
320
|
+
subject: Schema.optionalKey(Schema.Finite),
|
|
321
|
+
convention: Schema.optionalKey(Schema.String),
|
|
322
|
+
});
|
|
323
|
+
export type PathTerm = (typeof PathTerm)["Type"];
|
|
324
|
+
|
|
325
|
+
const ImportsTerm = Schema.Struct({
|
|
326
|
+
// Where an edge of the file must resolve to: a path pattern, a package
|
|
327
|
+
// name, or a builtin. Holds when at least one edge does.
|
|
328
|
+
resolves: ImportProbeTarget,
|
|
329
|
+
// Names that must be pulled across that edge; omit for any binding.
|
|
330
|
+
symbols: Schema.optionalKey(Schema.Array(Schema.String)),
|
|
331
|
+
});
|
|
332
|
+
export type ImportsTerm = (typeof ImportsTerm)["Type"];
|
|
333
|
+
|
|
334
|
+
const ExportsTerm = Schema.Struct({
|
|
335
|
+
name: Schema.optionalKey(PatternList),
|
|
336
|
+
kinds: Schema.optionalKey(Schema.Array(BindingKind)),
|
|
337
|
+
declares: Schema.optionalKey(Schema.Array(DeclarationKind)),
|
|
338
|
+
reexport: Schema.optionalKey(Schema.Boolean),
|
|
339
|
+
});
|
|
340
|
+
export type ExportsTerm = (typeof ExportsTerm)["Type"];
|
|
341
|
+
|
|
342
|
+
const MembersTerm = Schema.Struct({
|
|
343
|
+
subject: MemberSubject,
|
|
344
|
+
name: Schema.optionalKey(PatternList),
|
|
345
|
+
in: Schema.optionalKey(PatternList),
|
|
346
|
+
declares: Schema.optionalKey(Schema.Array(DeclarationKind)),
|
|
347
|
+
});
|
|
348
|
+
export type MembersTerm = (typeof MembersTerm)["Type"];
|
|
349
|
+
|
|
350
|
+
const ContentTerm = Schema.Struct({ regex: Schema.String });
|
|
351
|
+
type ContentTerm = (typeof ContentTerm)["Type"];
|
|
352
|
+
|
|
353
|
+
// How a metavariable of a syntax rule is narrowed: by the text it captured,
|
|
354
|
+
// or by what the identifier at its root is bound to — the module it was
|
|
355
|
+
// imported from and the name it was imported as.
|
|
356
|
+
const BindingNarrowing = Schema.Struct({
|
|
357
|
+
resolves: ImportProbeTarget,
|
|
358
|
+
member: Schema.optionalKey(Schema.Array(Schema.String)),
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
const CaptureNarrowing = Schema.Struct({
|
|
362
|
+
regex: Schema.optionalKey(Schema.String),
|
|
363
|
+
binding: Schema.optionalKey(BindingNarrowing),
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
const SyntaxTerm = Schema.Struct({
|
|
367
|
+
// The engine's rule object, carried opaquely: the matcher validates it.
|
|
368
|
+
rule: Schema.Unknown,
|
|
369
|
+
where: Schema.optionalKey(Schema.Record(Schema.String, CaptureNarrowing)),
|
|
370
|
+
});
|
|
371
|
+
export type SyntaxTerm = (typeof SyntaxTerm)["Type"];
|
|
372
|
+
|
|
373
|
+
// A pattern no parser of ours sees, reported by another program: a type
|
|
374
|
+
// error, another linter's finding. Exactly one of `command` (run from the
|
|
375
|
+
// repository root, once per process) and `file` (written by an earlier
|
|
376
|
+
// step), each a list — the manifest's one string lowered to a list of one —
|
|
377
|
+
// read as one report. Each diagnostic on a file is a match anchored on the
|
|
378
|
+
// declaration at its position, keyed by its code and a hash of its message.
|
|
379
|
+
export const ReportFormat = Schema.Literals(["tsc", "eslint", "oxlint", "regex"]);
|
|
380
|
+
|
|
381
|
+
const ReportTerm = Schema.Struct({
|
|
382
|
+
command: Schema.optionalKey(Schema.Array(Schema.String)),
|
|
383
|
+
file: Schema.optionalKey(Schema.Array(Schema.String)),
|
|
384
|
+
format: ReportFormat,
|
|
385
|
+
// `regex` only: named groups `file`, `line`, and optionally `column`,
|
|
386
|
+
// `code`, `message`.
|
|
387
|
+
pattern: Schema.optionalKey(Schema.String),
|
|
388
|
+
// The codes the term speaks to; omit for every one.
|
|
389
|
+
codes: Schema.optionalKey(Schema.Array(Schema.String)),
|
|
390
|
+
codesNot: Schema.optionalKey(Schema.Array(Schema.String)),
|
|
391
|
+
});
|
|
392
|
+
export type ReportTerm = (typeof ReportTerm)["Type"];
|
|
393
|
+
|
|
394
|
+
export type Detector =
|
|
395
|
+
| { readonly all: ReadonlyArray<Detector> }
|
|
396
|
+
| { readonly any: ReadonlyArray<Detector> }
|
|
397
|
+
| { readonly not: Detector }
|
|
398
|
+
| { readonly path: PathTerm }
|
|
399
|
+
| { readonly imports: ImportsTerm }
|
|
400
|
+
| { readonly exports: ExportsTerm }
|
|
401
|
+
| { readonly members: MembersTerm }
|
|
402
|
+
| { readonly requires: ReadonlyArray<string> }
|
|
403
|
+
| { readonly content: ContentTerm }
|
|
404
|
+
| { readonly syntax: SyntaxTerm }
|
|
405
|
+
| { readonly report: ReportTerm }
|
|
406
|
+
// `module#export`, resolved by the host before the policy loads.
|
|
407
|
+
| { readonly fn: string };
|
|
408
|
+
|
|
409
|
+
const DetectorRef = Schema.suspend((): Schema.Codec<Detector> => Detector);
|
|
410
|
+
|
|
411
|
+
export const Detector = Schema.Union([
|
|
412
|
+
Schema.Struct({ all: Schema.Array(DetectorRef) }),
|
|
413
|
+
Schema.Struct({ any: Schema.Array(DetectorRef) }),
|
|
414
|
+
Schema.Struct({ not: DetectorRef }),
|
|
415
|
+
Schema.Struct({ path: PathTerm }),
|
|
416
|
+
Schema.Struct({ imports: ImportsTerm }),
|
|
417
|
+
Schema.Struct({ exports: ExportsTerm }),
|
|
418
|
+
Schema.Struct({ members: MembersTerm }),
|
|
419
|
+
Schema.Struct({ requires: Schema.Array(Schema.String) }),
|
|
420
|
+
Schema.Struct({ content: ContentTerm }),
|
|
421
|
+
Schema.Struct({ syntax: SyntaxTerm }),
|
|
422
|
+
Schema.Struct({ report: ReportTerm }),
|
|
423
|
+
Schema.Struct({ fn: Schema.String }),
|
|
424
|
+
]);
|
|
425
|
+
|
|
426
|
+
// One diagnostic a probe stands in for, positions one-based as a tool
|
|
427
|
+
// prints them.
|
|
428
|
+
export const ProbeDiagnostic = Schema.Struct({
|
|
429
|
+
line: Schema.Finite,
|
|
430
|
+
column: Schema.optionalKey(Schema.Finite),
|
|
431
|
+
code: Schema.optionalKey(Schema.String),
|
|
432
|
+
message: Schema.optionalKey(Schema.String),
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
// A source the campaign is proven against: the path it would have, its text
|
|
436
|
+
// when a term needs one, the target of each of its edges (in place of the live
|
|
437
|
+
// resolver), the files beside it (in place of the file system) and the
|
|
438
|
+
// diagnostics reported on it (in place of the report source).
|
|
439
|
+
export const CampaignProbe = Schema.Struct({
|
|
440
|
+
path: Schema.String,
|
|
441
|
+
source: Schema.optionalKey(Schema.String),
|
|
442
|
+
edges: Schema.optionalKey(Schema.Record(Schema.String, ImportProbeTarget)),
|
|
443
|
+
files: Schema.optionalKey(Schema.Array(Schema.String)),
|
|
444
|
+
report: Schema.optionalKey(Schema.Array(ProbeDiagnostic)),
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
export const CampaignRule = Schema.Struct({
|
|
448
|
+
// `campaign/<id>`, the rule name a violation carries.
|
|
449
|
+
name: Schema.String,
|
|
450
|
+
id: Schema.String,
|
|
451
|
+
title: Schema.optionalKey(Schema.String),
|
|
452
|
+
// The `how`: what a reader at a hit does about it.
|
|
453
|
+
message: Schema.String,
|
|
454
|
+
why: Schema.String,
|
|
455
|
+
owner: Schema.optionalKey(Schema.String),
|
|
456
|
+
scope: PatternList,
|
|
457
|
+
unit: CampaignUnit,
|
|
458
|
+
detect: Detector,
|
|
459
|
+
probes: Schema.Struct({
|
|
460
|
+
fires: Schema.Array(CampaignProbe),
|
|
461
|
+
ignores: Schema.Array(CampaignProbe),
|
|
462
|
+
}),
|
|
463
|
+
// Milliseconds without progress after which the campaign is stalled.
|
|
464
|
+
staleAfter: Schema.Finite,
|
|
465
|
+
// What `check` demands once the count is zero: keep the campaign as a
|
|
466
|
+
// guard against recurrence, or remove it from the manifest.
|
|
467
|
+
onComplete: Schema.Literals(["keep", "remove"]),
|
|
468
|
+
});
|
|
469
|
+
|
|
273
470
|
const PathProbe = Schema.Struct({ path: Schema.String });
|
|
274
471
|
|
|
275
472
|
// The file taxonomy, as three questions rather than one nested tree.
|
|
@@ -336,6 +533,7 @@ const StructureConfig = Schema.Struct({
|
|
|
336
533
|
naming: Schema.optionalKey(Schema.Array(StructureNaming)),
|
|
337
534
|
});
|
|
338
535
|
|
|
536
|
+
export type Allowance = (typeof Allowance)["Type"];
|
|
339
537
|
export type ImportRule = (typeof ImportRule)["Type"];
|
|
340
538
|
export type ResolveConfig = (typeof ResolveConfig)["Type"];
|
|
341
539
|
export type ResolveScope = (typeof ResolveScope)["Type"];
|
|
@@ -361,6 +559,12 @@ export type StructureRoot = (typeof StructureRoot)["Type"];
|
|
|
361
559
|
export type StructureFolder = (typeof StructureFolder)["Type"];
|
|
362
560
|
export type StructureParity = (typeof StructureParity)["Type"];
|
|
363
561
|
export type StructureNaming = (typeof StructureNaming)["Type"];
|
|
562
|
+
export type CampaignUnit = (typeof CampaignUnit)["Type"];
|
|
563
|
+
export type CampaignProbe = (typeof CampaignProbe)["Type"];
|
|
564
|
+
export type ProbeDiagnostic = (typeof ProbeDiagnostic)["Type"];
|
|
565
|
+
export type ReportFormat = (typeof ReportFormat)["Type"];
|
|
566
|
+
export type CampaignRule = (typeof CampaignRule)["Type"];
|
|
567
|
+
export type CaptureNarrowing = (typeof CaptureNarrowing)["Type"];
|
|
364
568
|
|
|
365
569
|
// The file pattern an open folder's layout rule carries: it admits any name,
|
|
366
570
|
// so it claims the folder without policing it. Coverage counts it apart.
|
|
@@ -57,3 +57,33 @@ export class ImportUnresolved extends Schema.TaggedErrorClass<ImportUnresolved>(
|
|
|
57
57
|
return `${this.fromFile} imports "${this.specifier}", which does not resolve: ${this.detail}`;
|
|
58
58
|
}
|
|
59
59
|
}
|
|
60
|
+
|
|
61
|
+
// A `report` term whose source cannot be read: a command that could not be
|
|
62
|
+
// spawned, or a file that is not there. The live source raises it once per
|
|
63
|
+
// spec and keeps it, as it would have kept the report, so a lint over eight
|
|
64
|
+
// hundred files does not attempt the spawn eight hundred times; the plugin
|
|
65
|
+
// catches it and reports it once. The message says what to do, because a
|
|
66
|
+
// command is forked from whichever process asks — the CLI, or oxlint with
|
|
67
|
+
// the plugin loaded, which is the linter itself — and Linux's default
|
|
68
|
+
// overcommit heuristic refuses to fork a process holding one mapping larger
|
|
69
|
+
// than RAM and swap, which the linter's AST buffers become mid-lint.
|
|
70
|
+
export class ReportUnavailable extends Schema.TaggedErrorClass<ReportUnavailable>(
|
|
71
|
+
"ReportUnavailable",
|
|
72
|
+
)("ReportUnavailable", {
|
|
73
|
+
// "command" or "file", and the value as authored.
|
|
74
|
+
kind: Schema.Literals(["command", "file"]),
|
|
75
|
+
source: Schema.String,
|
|
76
|
+
detail: Schema.String,
|
|
77
|
+
}) {
|
|
78
|
+
override get message(): string {
|
|
79
|
+
return this.kind === "command"
|
|
80
|
+
? `the report command \`${this.source}\` could not be run: ${this.detail}. The command ` +
|
|
81
|
+
`is forked from the process that asks for the report — \`architecture check\`, or ` +
|
|
82
|
+
`oxlint with the plugin loaded, which is the linter itself, and Linux can refuse to ` +
|
|
83
|
+
`fork the linter once it holds more memory than the machine could back. A report an ` +
|
|
84
|
+
`earlier step writes, named by \`file:\` instead of \`command:\`, is the form for CI ` +
|
|
85
|
+
`and the editor.`
|
|
86
|
+
: `the report file ${this.source} cannot be read: ${this.detail}. A \`report\` term's ` +
|
|
87
|
+
`\`file\` is written by an earlier step; run that first, or name a \`command\`.`;
|
|
88
|
+
}
|
|
89
|
+
}
|
package/src/domain/facts.ts
CHANGED
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
import type { BindingKind, DeclarationKind, MemberSubject } from "./architecture-config.js";
|
|
2
2
|
|
|
3
|
-
// What a policy can know about one source file, read once.
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
3
|
+
// What a policy can know about one source file, read once. A language pack
|
|
4
|
+
// produces this vocabulary out of its syntax tree, and both hosts read it —
|
|
5
|
+
// which is what keeps them answerable to the same core rather than to each
|
|
6
|
+
// other. A rule is evaluated against these and nothing else.
|
|
7
7
|
|
|
8
8
|
// One name pulled across one import edge: `import { makeCommandBus } from "…"`
|
|
9
9
|
// is a single binding, and so is the `Effect` in `import { Effect } from "effect"`.
|
|
10
10
|
export type Binding = {
|
|
11
11
|
readonly symbol: string;
|
|
12
12
|
readonly kind: BindingKind;
|
|
13
|
+
// The name the binding is known by in the importing file — `C` for
|
|
14
|
+
// `import { Component as C }`, `React` for a default import — which is how
|
|
15
|
+
// an identifier in the file's own syntax is traced back to the module it
|
|
16
|
+
// came from. Absent for a form that binds no name.
|
|
17
|
+
readonly local?: string;
|
|
13
18
|
};
|
|
14
19
|
|
|
15
20
|
// One declared or called name. For `members`, the declaration it is written in
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Where in the manifest file a value was written. A decode error that names a
|
|
2
|
+
// path is something the reader has to find; one that names a line is something
|
|
3
|
+
// an editor can jump to. The host that read the file knows its lines; the
|
|
4
|
+
// decoder knows the path — the locator is how the second asks the first.
|
|
5
|
+
|
|
6
|
+
// A path into the manifest as the decoder sees it: object keys and array
|
|
7
|
+
// indices, root first.
|
|
8
|
+
export type ManifestPath = ReadonlyArray<PropertyKey>;
|
|
9
|
+
|
|
10
|
+
export type ManifestPosition = {
|
|
11
|
+
// 1-based, as editors count.
|
|
12
|
+
readonly line: number;
|
|
13
|
+
readonly column: number;
|
|
14
|
+
// The file the position is in, when it is not the manifest itself: a
|
|
15
|
+
// manifest assembled from several files through `include` answers with the
|
|
16
|
+
// included file's path, relative to the manifest's own directory. Absent
|
|
17
|
+
// for a position in the manifest file.
|
|
18
|
+
readonly file?: string | undefined;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// Answers with the position of the value at `path`, or the nearest ancestor
|
|
22
|
+
// that exists when the path names something the file does not contain (a
|
|
23
|
+
// missing key), or `null` when the source has no positions to give.
|
|
24
|
+
export type ManifestLocator = (path: ManifestPath) => ManifestPosition | null;
|
|
25
|
+
|
|
26
|
+
const isIdentifier = (key: string): boolean => /^[A-Za-z_$][\w$]*$/.test(key);
|
|
27
|
+
|
|
28
|
+
// `tree["~/core/"].members[0].subject` — dotted where a key reads as a name,
|
|
29
|
+
// bracketed where it does not, so a node key that is a path pattern stays
|
|
30
|
+
// legible.
|
|
31
|
+
export const renderManifestPath = (path: ManifestPath): string =>
|
|
32
|
+
path.length === 0
|
|
33
|
+
? "(root)"
|
|
34
|
+
: path
|
|
35
|
+
.map((segment, index) => {
|
|
36
|
+
if (typeof segment === "number") return `[${String(segment)}]`;
|
|
37
|
+
const key = String(segment);
|
|
38
|
+
if (isIdentifier(key)) return index === 0 ? key : `.${key}`;
|
|
39
|
+
return `[${JSON.stringify(key)}]`;
|
|
40
|
+
})
|
|
41
|
+
.join("");
|