@goodbones/core 0.1.0-beta.5 → 0.1.0-beta.7

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.
Files changed (46) hide show
  1. package/build/dts/core/coverage.d.ts +6 -0
  2. package/build/dts/core/coverage.d.ts.map +1 -1
  3. package/build/dts/core/slack.d.ts +14 -2
  4. package/build/dts/core/slack.d.ts.map +1 -1
  5. package/build/dts/domain/architecture-config.d.ts +2 -0
  6. package/build/dts/domain/architecture-config.d.ts.map +1 -1
  7. package/build/dts/domain/snapshot.d.ts +41 -0
  8. package/build/dts/domain/snapshot.d.ts.map +1 -1
  9. package/build/dts/index.d.ts +4 -4
  10. package/build/dts/index.d.ts.map +1 -1
  11. package/build/dts/manifest/compile.d.ts +5 -1
  12. package/build/dts/manifest/compile.d.ts.map +1 -1
  13. package/build/dts/manifest/expand.d.ts +1 -0
  14. package/build/dts/manifest/expand.d.ts.map +1 -1
  15. package/build/dts/manifest/manifest.d.ts +2 -0
  16. package/build/dts/manifest/manifest.d.ts.map +1 -1
  17. package/build/esm/core/coverage.js +35 -0
  18. package/build/esm/core/coverage.js.map +1 -1
  19. package/build/esm/core/slack.js +44 -7
  20. package/build/esm/core/slack.js.map +1 -1
  21. package/build/esm/domain/architecture-config.js +5 -0
  22. package/build/esm/domain/architecture-config.js.map +1 -1
  23. package/build/esm/domain/snapshot.js +21 -4
  24. package/build/esm/domain/snapshot.js.map +1 -1
  25. package/build/esm/index.js +3 -3
  26. package/build/esm/index.js.map +1 -1
  27. package/build/esm/load/policy.js +1 -1
  28. package/build/esm/load/policy.js.map +1 -1
  29. package/build/esm/manifest/compile.js +27 -8
  30. package/build/esm/manifest/compile.js.map +1 -1
  31. package/build/esm/manifest/expand.js +5 -0
  32. package/build/esm/manifest/expand.js.map +1 -1
  33. package/build/esm/manifest/manifest.js +1 -0
  34. package/build/esm/manifest/manifest.js.map +1 -1
  35. package/package.json +1 -1
  36. package/schema/conformance.schema.json +91 -2
  37. package/src/core/coverage.ts +53 -0
  38. package/src/core/slack.ts +83 -10
  39. package/src/domain/architecture-config.ts +5 -0
  40. package/src/domain/facts.ts +4 -4
  41. package/src/domain/snapshot.ts +51 -6
  42. package/src/index.ts +17 -2
  43. package/src/load/policy.ts +1 -1
  44. package/src/manifest/compile.ts +47 -3
  45. package/src/manifest/expand.ts +9 -0
  46. 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
@@ -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
- export type Slack = Pick<Allowance, "node" | "kind" | "entry">;
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
- const keyOf = (one: Slack): string => `${one.node} ${one.kind} ${one.entry}`;
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
- ): ReadonlyArray<Slack> => {
41
- const declared = new Map<string, Slack>();
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 { entry, kind, node } of rule.allowances) {
44
- const one = { node, kind, entry };
45
- if (!declared.has(keyOf(one))) declared.set(keyOf(one), one);
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
- return [...declared.values()].filter((one) => !used.has(keyOf(one)));
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({
@@ -1,9 +1,9 @@
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. Both adapters
4
- // produce this vocabulary — the plugin from oxlint's syntax tree, the CLI from
5
- // TypeScript's — which is what keeps them answerable to the same core rather
6
- // than to each other. A rule is evaluated against these and nothing else.
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"`.
@@ -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(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`.",
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 { type ObservedEdge, type Slack, slackOf } from "./core/slack.js";
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 { type LoweredRules, lowerManifest, type ProbeLanguage } from "./manifest/compile.js";
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,
@@ -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
@@ -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(parent, node.imports, aliases, compiled.captures, nextGroup, name);
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
 
@@ -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;
@@ -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
  };