@goodbones/core 0.1.0-beta.5 → 0.1.0-beta.6
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 +6 -0
- package/build/dts/core/coverage.d.ts.map +1 -1
- package/build/dts/core/slack.d.ts +14 -2
- package/build/dts/core/slack.d.ts.map +1 -1
- package/build/dts/domain/architecture-config.d.ts +2 -0
- package/build/dts/domain/architecture-config.d.ts.map +1 -1
- package/build/dts/domain/snapshot.d.ts +41 -0
- package/build/dts/domain/snapshot.d.ts.map +1 -1
- package/build/dts/index.d.ts +4 -4
- package/build/dts/index.d.ts.map +1 -1
- package/build/dts/manifest/compile.d.ts +5 -1
- package/build/dts/manifest/compile.d.ts.map +1 -1
- package/build/dts/manifest/expand.d.ts +1 -0
- package/build/dts/manifest/expand.d.ts.map +1 -1
- package/build/dts/manifest/manifest.d.ts +2 -0
- package/build/dts/manifest/manifest.d.ts.map +1 -1
- package/build/esm/core/coverage.js +35 -0
- package/build/esm/core/coverage.js.map +1 -1
- package/build/esm/core/slack.js +44 -7
- package/build/esm/core/slack.js.map +1 -1
- package/build/esm/domain/architecture-config.js +5 -0
- package/build/esm/domain/architecture-config.js.map +1 -1
- package/build/esm/domain/snapshot.js +21 -4
- package/build/esm/domain/snapshot.js.map +1 -1
- package/build/esm/index.js +3 -3
- package/build/esm/index.js.map +1 -1
- package/build/esm/load/policy.js +1 -1
- package/build/esm/load/policy.js.map +1 -1
- package/build/esm/manifest/compile.js +27 -8
- package/build/esm/manifest/compile.js.map +1 -1
- package/build/esm/manifest/expand.js +5 -0
- package/build/esm/manifest/expand.js.map +1 -1
- package/build/esm/manifest/manifest.js +1 -0
- package/build/esm/manifest/manifest.js.map +1 -1
- package/package.json +1 -1
- package/schema/conformance.schema.json +91 -2
- package/src/core/coverage.ts +53 -0
- package/src/core/slack.ts +83 -10
- package/src/domain/architecture-config.ts +5 -0
- package/src/domain/snapshot.ts +51 -6
- package/src/index.ts +17 -2
- package/src/load/policy.ts +1 -1
- package/src/manifest/compile.ts +47 -3
- package/src/manifest/expand.ts +9 -0
- package/src/manifest/manifest.ts +4 -0
|
@@ -262,6 +262,32 @@
|
|
|
262
262
|
"additionalProperties": false,
|
|
263
263
|
"description": "What the policy has nothing to say about. A file in an open folder under no allowlist is claimed, not policed, and counts."
|
|
264
264
|
},
|
|
265
|
+
"vacant": {
|
|
266
|
+
"type": "array",
|
|
267
|
+
"items": {
|
|
268
|
+
"type": "object",
|
|
269
|
+
"properties": {
|
|
270
|
+
"node": {
|
|
271
|
+
"type": "string",
|
|
272
|
+
"description": "The manifest node."
|
|
273
|
+
},
|
|
274
|
+
"allowances": {
|
|
275
|
+
"type": "number",
|
|
276
|
+
"allOf": [
|
|
277
|
+
{
|
|
278
|
+
"description": "Distinct entries the node wrote, `allow` and `external` together."
|
|
279
|
+
}
|
|
280
|
+
]
|
|
281
|
+
}
|
|
282
|
+
},
|
|
283
|
+
"required": [
|
|
284
|
+
"node",
|
|
285
|
+
"allowances"
|
|
286
|
+
],
|
|
287
|
+
"additionalProperties": false
|
|
288
|
+
},
|
|
289
|
+
"description": "Nodes that state an import allowlist and select no walked file, in manifest order. Every allowance on one is unused by construction, so none is counted as slack; the node is a tier declared ahead of its first file, or a pattern that no longer matches. Residue is files no node reaches; this is nodes no file reaches."
|
|
290
|
+
},
|
|
265
291
|
"violations": {
|
|
266
292
|
"type": "array",
|
|
267
293
|
"items": {
|
|
@@ -387,7 +413,7 @@
|
|
|
387
413
|
"properties": {
|
|
388
414
|
"node": {
|
|
389
415
|
"type": "string",
|
|
390
|
-
"description": "The manifest node that wrote the entry."
|
|
416
|
+
"description": "The manifest node that wrote the entry — or, when `fragment` is set, the `defs` fragment it was written in."
|
|
391
417
|
},
|
|
392
418
|
"kind": {
|
|
393
419
|
"type": "string",
|
|
@@ -400,6 +426,18 @@
|
|
|
400
426
|
"entry": {
|
|
401
427
|
"type": "string",
|
|
402
428
|
"description": "The entry as written, after alias expansion."
|
|
429
|
+
},
|
|
430
|
+
"fragment": {
|
|
431
|
+
"type": "string",
|
|
432
|
+
"description": "Present when the entry arrived through `use`. The nodes that referenced the fragment wrote one word, so the entry is reported once, against the fragment, rather than once per node."
|
|
433
|
+
},
|
|
434
|
+
"of": {
|
|
435
|
+
"type": "number",
|
|
436
|
+
"allOf": [
|
|
437
|
+
{
|
|
438
|
+
"description": "With `fragment`: how many non-vacant nodes were granted the entry through it. None of them uses it."
|
|
439
|
+
}
|
|
440
|
+
]
|
|
403
441
|
}
|
|
404
442
|
},
|
|
405
443
|
"required": [
|
|
@@ -409,7 +447,56 @@
|
|
|
409
447
|
],
|
|
410
448
|
"additionalProperties": false
|
|
411
449
|
},
|
|
412
|
-
"description": "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."
|
|
450
|
+
"description": "Allowances no observed import uses, in manifest order, vacant nodes excluded. A manifest inferred from the tree has none on the day it is written; every entry here is permission nothing needs. An entry that arrived through `use` is reported once, against the fragment, and only when no node granted it uses it."
|
|
451
|
+
},
|
|
452
|
+
"concentration": {
|
|
453
|
+
"type": "array",
|
|
454
|
+
"items": {
|
|
455
|
+
"type": "object",
|
|
456
|
+
"properties": {
|
|
457
|
+
"fragment": {
|
|
458
|
+
"type": "string",
|
|
459
|
+
"description": "The `defs` fragment the entry was written in."
|
|
460
|
+
},
|
|
461
|
+
"kind": {
|
|
462
|
+
"type": "string",
|
|
463
|
+
"enum": [
|
|
464
|
+
"allow",
|
|
465
|
+
"external"
|
|
466
|
+
],
|
|
467
|
+
"description": "`allow` is a path glob under `imports.allow`; `external` a package name under `imports.external`."
|
|
468
|
+
},
|
|
469
|
+
"entry": {
|
|
470
|
+
"type": "string",
|
|
471
|
+
"description": "The entry as written, after alias expansion."
|
|
472
|
+
},
|
|
473
|
+
"usedAt": {
|
|
474
|
+
"type": "number",
|
|
475
|
+
"allOf": [
|
|
476
|
+
{
|
|
477
|
+
"description": "Nodes granted the entry through the fragment that use it."
|
|
478
|
+
}
|
|
479
|
+
]
|
|
480
|
+
},
|
|
481
|
+
"of": {
|
|
482
|
+
"type": "number",
|
|
483
|
+
"allOf": [
|
|
484
|
+
{
|
|
485
|
+
"description": "Non-vacant nodes granted the entry through the fragment."
|
|
486
|
+
}
|
|
487
|
+
]
|
|
488
|
+
}
|
|
489
|
+
},
|
|
490
|
+
"required": [
|
|
491
|
+
"fragment",
|
|
492
|
+
"kind",
|
|
493
|
+
"entry",
|
|
494
|
+
"usedAt",
|
|
495
|
+
"of"
|
|
496
|
+
],
|
|
497
|
+
"additionalProperties": false
|
|
498
|
+
},
|
|
499
|
+
"description": "Fragment entries used at some of the nodes granted them and not the rest. Not slack — the fragment's line is needed somewhere — but a per-file permission written as a many-node allowance, which is what an allowlist widened to make one build green looks like."
|
|
413
500
|
},
|
|
414
501
|
"adoption": {
|
|
415
502
|
"type": "object",
|
|
@@ -445,12 +532,14 @@
|
|
|
445
532
|
"ok",
|
|
446
533
|
"coverage",
|
|
447
534
|
"residue",
|
|
535
|
+
"vacant",
|
|
448
536
|
"violations",
|
|
449
537
|
"unresolved",
|
|
450
538
|
"stale",
|
|
451
539
|
"baseline",
|
|
452
540
|
"cycles",
|
|
453
541
|
"slack",
|
|
542
|
+
"concentration",
|
|
454
543
|
"adoption"
|
|
455
544
|
],
|
|
456
545
|
"additionalProperties": false
|
package/src/core/coverage.ts
CHANGED
|
@@ -194,6 +194,59 @@ export type CoverageFloors = {
|
|
|
194
194
|
|
|
195
195
|
export type CoverageFamily = keyof CoverageFloors;
|
|
196
196
|
|
|
197
|
+
// Residue is files no node reaches; this is nodes no file reaches. A node
|
|
198
|
+
// that states an import allowlist and selects no walked file grants
|
|
199
|
+
// permission to nothing: every allowance on it is unused by construction,
|
|
200
|
+
// which is not slack — there is no line to delete, only a node that is a
|
|
201
|
+
// tier declared ahead of its first file, or a pattern that no longer
|
|
202
|
+
// matches. Which of the two, the reader decides; the report tells both
|
|
203
|
+
// apart from slack so nothing has to be read twice.
|
|
204
|
+
export type Vacancy = ReadonlyArray<{
|
|
205
|
+
readonly node: string;
|
|
206
|
+
// Distinct entries the node wrote, `allow` and `external` together.
|
|
207
|
+
readonly allowances: number;
|
|
208
|
+
}>;
|
|
209
|
+
|
|
210
|
+
// The nodes whose allowances no live rule carries. A node's allowlist is
|
|
211
|
+
// inherited by every descendant's rule until one `reset`s, so a node whose
|
|
212
|
+
// own rule steps aside for an overriding child still reaches that child's
|
|
213
|
+
// files through the child's rule — and is not vacant while the child has any.
|
|
214
|
+
export const vacantNodesOf = (
|
|
215
|
+
rules: ReadonlyArray<CompiledImportRule>,
|
|
216
|
+
files: ReadonlyArray<string>,
|
|
217
|
+
): ReadonlySet<string> => {
|
|
218
|
+
const declaring = new Set<string>();
|
|
219
|
+
const reached = new Set<string>();
|
|
220
|
+
for (const rule of rules) {
|
|
221
|
+
if (rule.allowances.length === 0) continue;
|
|
222
|
+
const live = files.some((file) => selects(rule, file));
|
|
223
|
+
for (const { node } of rule.allowances) {
|
|
224
|
+
declaring.add(node);
|
|
225
|
+
if (live) reached.add(node);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return new Set([...declaring].filter((node) => !reached.has(node)));
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
// Every vacant node with how many entries it wrote, in the order the
|
|
232
|
+
// allowlists declared them.
|
|
233
|
+
export const vacancyOf = (
|
|
234
|
+
rules: ReadonlyArray<CompiledImportRule>,
|
|
235
|
+
files: ReadonlyArray<string>,
|
|
236
|
+
): Vacancy => {
|
|
237
|
+
const vacant = vacantNodesOf(rules, files);
|
|
238
|
+
const entries = new Map<string, Set<string>>();
|
|
239
|
+
for (const rule of rules) {
|
|
240
|
+
for (const { entry, kind, node } of rule.allowances) {
|
|
241
|
+
if (!vacant.has(node)) continue;
|
|
242
|
+
const written = entries.get(node) ?? new Set<string>();
|
|
243
|
+
written.add(`${kind} ${entry}`);
|
|
244
|
+
entries.set(node, written);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return [...entries.entries()].map(([node, written]) => ({ node, allowances: written.size }));
|
|
248
|
+
};
|
|
249
|
+
|
|
197
250
|
export const fractionOf = (covered: number, total: number): number =>
|
|
198
251
|
total === 0 ? 1 : covered / total;
|
|
199
252
|
|
package/src/core/slack.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Allowance } from "../domain/architecture-config.js";
|
|
2
2
|
import type { ResolvedTarget } from "../ports/module-resolver.js";
|
|
3
|
+
import { vacantNodesOf } from "./coverage.js";
|
|
3
4
|
import type { CompiledImportRule } from "./imports.js";
|
|
4
5
|
import { firstFromMatch, matchesAny } from "./patterns.js";
|
|
5
6
|
|
|
@@ -17,10 +18,31 @@ export type ObservedEdge = {
|
|
|
17
18
|
};
|
|
18
19
|
|
|
19
20
|
// An allowance no observed edge uses. The same shape the lowering recorded,
|
|
20
|
-
// minus the compiled pattern nobody wrote.
|
|
21
|
-
|
|
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
|
+
};
|
|
22
38
|
|
|
23
|
-
|
|
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}`;
|
|
24
46
|
|
|
25
47
|
// Whether one edge, from a file this rule selects, passes through this entry.
|
|
26
48
|
const uses = (allowance: Allowance, captures: RegExpExecArray, target: ResolvedTarget): boolean => {
|
|
@@ -30,22 +52,57 @@ const uses = (allowance: Allowance, captures: RegExpExecArray, target: ResolvedT
|
|
|
30
52
|
return allowance.pattern !== undefined && matchesAny([allowance.pattern], captures, target.path);
|
|
31
53
|
};
|
|
32
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
|
+
|
|
33
73
|
// Every allowance the rules carry that no edge uses, in the order the manifest
|
|
34
74
|
// declared them. An entry is inherited by every descendant's rule and written
|
|
35
75
|
// once, so it is keyed by where it was written: an import anywhere under the
|
|
36
|
-
// declaring node is a use.
|
|
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.
|
|
37
81
|
export const slackOf = (
|
|
38
82
|
rules: ReadonlyArray<CompiledImportRule>,
|
|
39
83
|
edges: ReadonlyArray<ObservedEdge>,
|
|
40
|
-
|
|
41
|
-
|
|
84
|
+
files: ReadonlyArray<string>,
|
|
85
|
+
): SlackReport => {
|
|
86
|
+
const vacant = vacantNodesOf(rules, files);
|
|
87
|
+
|
|
88
|
+
const declared = new Map<string, Declared>();
|
|
42
89
|
for (const rule of rules) {
|
|
43
|
-
for (const
|
|
44
|
-
|
|
45
|
-
|
|
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);
|
|
46
102
|
}
|
|
47
103
|
}
|
|
48
104
|
|
|
105
|
+
// Which (node, kind, entry) some edge passes through.
|
|
49
106
|
const used = new Set<string>();
|
|
50
107
|
for (const edge of edges) {
|
|
51
108
|
for (const rule of rules) {
|
|
@@ -58,5 +115,21 @@ export const slackOf = (
|
|
|
58
115
|
}
|
|
59
116
|
}
|
|
60
117
|
|
|
61
|
-
|
|
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 };
|
|
62
135
|
};
|
|
@@ -42,6 +42,11 @@ export const Allowance = Schema.Struct({
|
|
|
42
42
|
entry: Schema.String,
|
|
43
43
|
// For an `allow`: the compiled target pattern, as `toNot` carries it.
|
|
44
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),
|
|
45
50
|
});
|
|
46
51
|
|
|
47
52
|
export const ImportRule = Schema.Struct({
|
package/src/domain/snapshot.ts
CHANGED
|
@@ -65,13 +65,48 @@ export const SnapshotViolation = Schema.Struct({
|
|
|
65
65
|
baselined: describe(Schema.Boolean, "Carried by the baseline, so `check` does not fail on it."),
|
|
66
66
|
});
|
|
67
67
|
|
|
68
|
+
const AllowanceKind = describe(
|
|
69
|
+
Schema.Literals(["allow", "external"]),
|
|
70
|
+
"`allow` is a path glob under `imports.allow`; `external` a package name under `imports.external`.",
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const Entry = describe(Schema.String, "The entry as written, after alias expansion.");
|
|
74
|
+
|
|
68
75
|
export const SnapshotSlack = Schema.Struct({
|
|
69
|
-
node: describe(
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
76
|
+
node: describe(
|
|
77
|
+
Schema.String,
|
|
78
|
+
"The manifest node that wrote the entry — or, when `fragment` is set, the `defs` fragment it was written in.",
|
|
79
|
+
),
|
|
80
|
+
kind: AllowanceKind,
|
|
81
|
+
entry: Entry,
|
|
82
|
+
fragment: Schema.optionalKey(
|
|
83
|
+
describe(
|
|
84
|
+
Schema.String,
|
|
85
|
+
"Present when the entry arrived through `use`. The nodes that referenced the fragment wrote one word, so the entry is reported once, against the fragment, rather than once per node.",
|
|
86
|
+
),
|
|
87
|
+
),
|
|
88
|
+
of: Schema.optionalKey(
|
|
89
|
+
describe(
|
|
90
|
+
Schema.Finite,
|
|
91
|
+
"With `fragment`: how many non-vacant nodes were granted the entry through it. None of them uses it.",
|
|
92
|
+
),
|
|
93
|
+
),
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
export const SnapshotConcentration = Schema.Struct({
|
|
97
|
+
fragment: describe(Schema.String, "The `defs` fragment the entry was written in."),
|
|
98
|
+
kind: AllowanceKind,
|
|
99
|
+
entry: Entry,
|
|
100
|
+
usedAt: describe(Schema.Finite, "Nodes granted the entry through the fragment that use it."),
|
|
101
|
+
of: describe(Schema.Finite, "Non-vacant nodes granted the entry through the fragment."),
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
export const SnapshotVacancy = Schema.Struct({
|
|
105
|
+
node: describe(Schema.String, "The manifest node."),
|
|
106
|
+
allowances: describe(
|
|
107
|
+
Schema.Finite,
|
|
108
|
+
"Distinct entries the node wrote, `allow` and `external` together.",
|
|
73
109
|
),
|
|
74
|
-
entry: describe(Schema.String, "The entry as written, after alias expansion."),
|
|
75
110
|
});
|
|
76
111
|
|
|
77
112
|
export const Snapshot = Schema.Struct({
|
|
@@ -114,6 +149,10 @@ export const Snapshot = Schema.Struct({
|
|
|
114
149
|
}),
|
|
115
150
|
"What the policy has nothing to say about. A file in an open folder under no allowlist is claimed, not policed, and counts.",
|
|
116
151
|
),
|
|
152
|
+
vacant: describe(
|
|
153
|
+
Schema.Array(SnapshotVacancy),
|
|
154
|
+
"Nodes that state an import allowlist and select no walked file, in manifest order. Every allowance on one is unused by construction, so none is counted as slack; the node is a tier declared ahead of its first file, or a pattern that no longer matches. Residue is files no node reaches; this is nodes no file reaches.",
|
|
155
|
+
),
|
|
117
156
|
violations: describe(
|
|
118
157
|
Schema.Array(SnapshotViolation),
|
|
119
158
|
"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.",
|
|
@@ -135,7 +174,11 @@ export const Snapshot = Schema.Struct({
|
|
|
135
174
|
),
|
|
136
175
|
slack: describe(
|
|
137
176
|
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.",
|
|
177
|
+
"Allowances no observed import uses, in manifest order, vacant nodes excluded. A manifest inferred from the tree has none on the day it is written; every entry here is permission nothing needs. An entry that arrived through `use` is reported once, against the fragment, and only when no node granted it uses it.",
|
|
178
|
+
),
|
|
179
|
+
concentration: describe(
|
|
180
|
+
Schema.Array(SnapshotConcentration),
|
|
181
|
+
"Fragment entries used at some of the nodes granted them and not the rest. Not slack — the fragment's line is needed somewhere — but a per-file permission written as a many-node allowance, which is what an allowlist widened to make one build green looks like.",
|
|
139
182
|
),
|
|
140
183
|
adoption: describe(
|
|
141
184
|
Schema.Struct({
|
|
@@ -149,6 +192,8 @@ export const Snapshot = Schema.Struct({
|
|
|
149
192
|
export type Snapshot = typeof Snapshot.Type;
|
|
150
193
|
export type SnapshotViolation = typeof SnapshotViolation.Type;
|
|
151
194
|
export type SnapshotSlack = typeof SnapshotSlack.Type;
|
|
195
|
+
export type SnapshotConcentration = typeof SnapshotConcentration.Type;
|
|
196
|
+
export type SnapshotVacancy = typeof SnapshotVacancy.Type;
|
|
152
197
|
|
|
153
198
|
// Decodes a document some other run wrote — the base of a pull request, a
|
|
154
199
|
// stored one — refusing a key the shape does not declare, so a consumer never
|
package/src/index.ts
CHANGED
|
@@ -25,6 +25,8 @@ export {
|
|
|
25
25
|
reachOf,
|
|
26
26
|
type Residue,
|
|
27
27
|
residueOf,
|
|
28
|
+
type Vacancy,
|
|
29
|
+
vacancyOf,
|
|
28
30
|
} from "./core/coverage.js";
|
|
29
31
|
export {
|
|
30
32
|
type BindingEdge,
|
|
@@ -69,7 +71,13 @@ export {
|
|
|
69
71
|
memberRulesFailingTheirProbe,
|
|
70
72
|
memberRulesSelecting,
|
|
71
73
|
} from "./core/members.js";
|
|
72
|
-
export {
|
|
74
|
+
export {
|
|
75
|
+
type Concentration,
|
|
76
|
+
type ObservedEdge,
|
|
77
|
+
type Slack,
|
|
78
|
+
slackOf,
|
|
79
|
+
type SlackReport,
|
|
80
|
+
} from "./core/slack.js";
|
|
73
81
|
export {
|
|
74
82
|
type CompiledStructure,
|
|
75
83
|
compileStructure,
|
|
@@ -128,9 +136,11 @@ export {
|
|
|
128
136
|
type Snapshot,
|
|
129
137
|
SNAPSHOT_SCHEMA_ID,
|
|
130
138
|
SNAPSHOT_VERSION,
|
|
139
|
+
type SnapshotConcentration,
|
|
131
140
|
snapshotJsonSchema,
|
|
132
141
|
Snapshot as SnapshotSchema,
|
|
133
142
|
type SnapshotSlack,
|
|
143
|
+
type SnapshotVacancy,
|
|
134
144
|
type SnapshotViolation,
|
|
135
145
|
} from "./domain/snapshot.js";
|
|
136
146
|
export {
|
|
@@ -161,7 +171,12 @@ export {
|
|
|
161
171
|
type WalkedLanguage,
|
|
162
172
|
} from "./infrastructure/walk.js";
|
|
163
173
|
export { type LoadedPolicy, loadPolicy, type LoadPolicyInput } from "./load/policy.js";
|
|
164
|
-
export {
|
|
174
|
+
export {
|
|
175
|
+
type LoweredRules,
|
|
176
|
+
lowerManifest,
|
|
177
|
+
type LowerOptions,
|
|
178
|
+
type ProbeLanguage,
|
|
179
|
+
} from "./manifest/compile.js";
|
|
165
180
|
export {
|
|
166
181
|
type ExpandedManifest,
|
|
167
182
|
type ExpandIssue,
|
package/src/load/policy.ts
CHANGED
|
@@ -216,7 +216,7 @@ export const loadPolicy = (
|
|
|
216
216
|
// The manifest is the authoring surface; these flat rules are the machine's.
|
|
217
217
|
// The languages tell lowering what a source file in each scope is called, so
|
|
218
218
|
// a synthetic probe is a file of the scope's language.
|
|
219
|
-
const rules = lowerManifest(config, languages);
|
|
219
|
+
const rules = lowerManifest(config, languages, { substitutions: decoded.success.substitutions });
|
|
220
220
|
|
|
221
221
|
// The ceilings. A tier that says "not tightened yet" is a sentence someone
|
|
222
222
|
// wrote; a ceiling on how many may say so is what keeps the backlog from
|
package/src/manifest/compile.ts
CHANGED
|
@@ -11,6 +11,8 @@ import {
|
|
|
11
11
|
type StructureRoot,
|
|
12
12
|
type SurfaceRule,
|
|
13
13
|
} from "../domain/architecture-config.js";
|
|
14
|
+
import type { ManifestPath } from "../domain/manifest-location.js";
|
|
15
|
+
import { fragmentOf, type Substitution } from "./expand.js";
|
|
14
16
|
import { anchored, type CaptureIndex, globToRegexSource, prefixed } from "./glob.js";
|
|
15
17
|
import {
|
|
16
18
|
globsOf,
|
|
@@ -225,6 +227,11 @@ type Denial = {
|
|
|
225
227
|
readonly probe: string;
|
|
226
228
|
};
|
|
227
229
|
|
|
230
|
+
// Which `defs` fragment one key of a node's `imports` was written in, when it
|
|
231
|
+
// was written in one. Resolved per key, since `imports: { use: x, allow: […] }`
|
|
232
|
+
// takes `allow` from the reference site and `external` from the fragment.
|
|
233
|
+
type ImportsProvenance = (key: "allow" | "external") => string | undefined;
|
|
234
|
+
|
|
228
235
|
const mergeImports = (
|
|
229
236
|
frame: Frame,
|
|
230
237
|
spec: ImportsSpec | undefined,
|
|
@@ -232,6 +239,7 @@ const mergeImports = (
|
|
|
232
239
|
captures: CaptureIndex,
|
|
233
240
|
nextGroup: number,
|
|
234
241
|
node: string,
|
|
242
|
+
provenance: ImportsProvenance,
|
|
235
243
|
): Pick<Frame, "allowances" | "importsMessage"> & {
|
|
236
244
|
readonly deny: ReadonlyArray<Denial>;
|
|
237
245
|
} => {
|
|
@@ -245,14 +253,24 @@ const mergeImports = (
|
|
|
245
253
|
.source,
|
|
246
254
|
);
|
|
247
255
|
|
|
256
|
+
const via = (key: "allow" | "external"): Pick<Allowance, "fragment"> => {
|
|
257
|
+
const fragment = provenance(key);
|
|
258
|
+
return fragment === undefined ? {} : { fragment };
|
|
259
|
+
};
|
|
248
260
|
const own: ReadonlyArray<Allowance> = [
|
|
249
261
|
...globsOf(spec.allow ?? []).map((glob) => ({
|
|
250
262
|
node,
|
|
251
263
|
kind: "allow" as const,
|
|
252
264
|
entry: expandAliases(glob, aliases),
|
|
253
265
|
pattern: compileAllow(glob),
|
|
266
|
+
...via("allow"),
|
|
267
|
+
})),
|
|
268
|
+
...(spec.external ?? []).map((name) => ({
|
|
269
|
+
node,
|
|
270
|
+
kind: "external" as const,
|
|
271
|
+
entry: name,
|
|
272
|
+
...via("external"),
|
|
254
273
|
})),
|
|
255
|
-
...(spec.external ?? []).map((name) => ({ node, kind: "external" as const, entry: name })),
|
|
256
274
|
];
|
|
257
275
|
const deny = (spec.deny ?? []).flatMap((entry) =>
|
|
258
276
|
globsOf(entry.match).map((glob) => ({
|
|
@@ -278,11 +296,20 @@ const mergeImports = (
|
|
|
278
296
|
};
|
|
279
297
|
};
|
|
280
298
|
|
|
299
|
+
export type LowerOptions = {
|
|
300
|
+
// The `use` references the expansion replaced, so an allowance can say
|
|
301
|
+
// which fragment it came through. A manifest lowered without them is one
|
|
302
|
+
// whose every entry reads as authored where it sits.
|
|
303
|
+
readonly substitutions?: ReadonlyArray<Substitution>;
|
|
304
|
+
};
|
|
305
|
+
|
|
281
306
|
export const lowerManifest = (
|
|
282
307
|
manifest: Manifest,
|
|
283
308
|
languages: ReadonlyArray<ProbeLanguage> = [],
|
|
309
|
+
options: LowerOptions = {},
|
|
284
310
|
): LoweredRules => {
|
|
285
311
|
const aliases = manifest.aliases ?? {};
|
|
312
|
+
const substitutions = options.substitutions ?? [];
|
|
286
313
|
|
|
287
314
|
// The extension a synthetic probe file carries: the first extension of the
|
|
288
315
|
// language whose scope covers the probe's folder. A probe is matched by its
|
|
@@ -345,6 +372,9 @@ export const lowerManifest = (
|
|
|
345
372
|
parent: Frame,
|
|
346
373
|
name: string,
|
|
347
374
|
siblings: ReadonlyArray<string>,
|
|
375
|
+
// Where this node sits in the expanded document, so its `imports` keys
|
|
376
|
+
// can be traced back through any `use` that carried them.
|
|
377
|
+
nodePath: ManifestPath,
|
|
348
378
|
): void => {
|
|
349
379
|
const literalSiblings = siblings
|
|
350
380
|
.filter((sibling) => sibling !== key)
|
|
@@ -394,7 +424,15 @@ export const lowerManifest = (
|
|
|
394
424
|
parent.nextGroup +
|
|
395
425
|
(Object.keys(compiled.captures).length - Object.keys(parent.captures).length);
|
|
396
426
|
|
|
397
|
-
const merged = mergeImports(
|
|
427
|
+
const merged = mergeImports(
|
|
428
|
+
parent,
|
|
429
|
+
node.imports,
|
|
430
|
+
aliases,
|
|
431
|
+
compiled.captures,
|
|
432
|
+
nextGroup,
|
|
433
|
+
name,
|
|
434
|
+
(field) => fragmentOf(substitutions, [...nodePath, "imports", field]),
|
|
435
|
+
);
|
|
398
436
|
const ownDenials = merged.deny;
|
|
399
437
|
const frame: Frame = {
|
|
400
438
|
pathSource,
|
|
@@ -591,7 +629,11 @@ export const lowerManifest = (
|
|
|
591
629
|
}
|
|
592
630
|
const siblingKeys = childKeys.map(([childKey]) => childKey);
|
|
593
631
|
for (const [childKey, child] of childKeys) {
|
|
594
|
-
walk(childKey, child, frame, `${name}/${alternativesOf(childKey)[0] ?? ""}`, siblingKeys
|
|
632
|
+
walk(childKey, child, frame, `${name}/${alternativesOf(childKey)[0] ?? ""}`, siblingKeys, [
|
|
633
|
+
...nodePath,
|
|
634
|
+
"children",
|
|
635
|
+
childKey,
|
|
636
|
+
]);
|
|
595
637
|
}
|
|
596
638
|
}
|
|
597
639
|
|
|
@@ -863,6 +905,7 @@ export const lowerManifest = (
|
|
|
863
905
|
{},
|
|
864
906
|
1,
|
|
865
907
|
"repo",
|
|
908
|
+
() => undefined,
|
|
866
909
|
).deny.entries()) {
|
|
867
910
|
imports.push({
|
|
868
911
|
name: `repo/deny-${String(index)}`,
|
|
@@ -905,6 +948,7 @@ export const lowerManifest = (
|
|
|
905
948
|
.replace(/[^a-zA-Z0-9]+/g, "-")
|
|
906
949
|
.replace(/^-|-$/g, ""),
|
|
907
950
|
Object.keys(manifest.tree),
|
|
951
|
+
["tree", key],
|
|
908
952
|
);
|
|
909
953
|
}
|
|
910
954
|
|
package/src/manifest/expand.ts
CHANGED
|
@@ -172,3 +172,12 @@ export const originOf = (
|
|
|
172
172
|
via: [...outer, { at: innermost.ref, name: innermost.name }],
|
|
173
173
|
};
|
|
174
174
|
};
|
|
175
|
+
|
|
176
|
+
// The fragment a path in the expanded document was written in, when it was:
|
|
177
|
+
// the innermost `use` the path crossed, or nothing when the value at that path
|
|
178
|
+
// was authored where it sits — including a key written beside `use`, which
|
|
179
|
+
// belongs to the reference site.
|
|
180
|
+
export const fragmentOf = (
|
|
181
|
+
substitutions: ReadonlyArray<Substitution>,
|
|
182
|
+
path: ManifestPath,
|
|
183
|
+
): string | undefined => originOf(substitutions, path).via.at(-1)?.name;
|
package/src/manifest/manifest.ts
CHANGED
|
@@ -305,6 +305,9 @@ export type DecodedManifest = {
|
|
|
305
305
|
// Things the manifest said in a form that still loads but is on its way out.
|
|
306
306
|
// The host prints them; nothing else acts on them.
|
|
307
307
|
readonly notices: ReadonlyArray<string>;
|
|
308
|
+
// Every `use` the expansion replaced. Lowering reads them to say which
|
|
309
|
+
// fragment an allowance came through; nothing else needs them.
|
|
310
|
+
readonly substitutions: ReadonlyArray<Substitution>;
|
|
308
311
|
};
|
|
309
312
|
|
|
310
313
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
@@ -496,5 +499,6 @@ export const decodeManifest = (
|
|
|
496
499
|
return Result.succeed({
|
|
497
500
|
manifest: decoded.success,
|
|
498
501
|
notices: [...resolve.notices, ...members.notices],
|
|
502
|
+
substitutions,
|
|
499
503
|
});
|
|
500
504
|
};
|