@goodbones/core 0.1.0-beta.1 → 0.1.0-beta.11

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 (150) hide show
  1. package/build/dts/core/coverage.d.ts +20 -0
  2. package/build/dts/core/coverage.d.ts.map +1 -1
  3. package/build/dts/core/graph.d.ts +2 -0
  4. package/build/dts/core/graph.d.ts.map +1 -1
  5. package/build/dts/core/imports.d.ts +3 -1
  6. package/build/dts/core/imports.d.ts.map +1 -1
  7. package/build/dts/core/slack.d.ts +22 -0
  8. package/build/dts/core/slack.d.ts.map +1 -0
  9. package/build/dts/core/structure.d.ts +1 -0
  10. package/build/dts/core/structure.d.ts.map +1 -1
  11. package/build/dts/domain/architecture-config.d.ts +19 -3
  12. package/build/dts/domain/architecture-config.d.ts.map +1 -1
  13. package/build/dts/domain/architecture-error.d.ts +8 -0
  14. package/build/dts/domain/architecture-error.d.ts.map +1 -1
  15. package/build/dts/domain/facts.d.ts +1 -0
  16. package/build/dts/domain/facts.d.ts.map +1 -1
  17. package/build/dts/domain/manifest-location.d.ts +9 -0
  18. package/build/dts/domain/manifest-location.d.ts.map +1 -0
  19. package/build/dts/domain/snapshot.d.ts +356 -0
  20. package/build/dts/domain/snapshot.d.ts.map +1 -0
  21. package/build/dts/domain/violation.d.ts +1 -1
  22. package/build/dts/domain/violation.d.ts.map +1 -1
  23. package/build/dts/index.d.ts +22 -10
  24. package/build/dts/index.d.ts.map +1 -1
  25. package/build/dts/infrastructure/file-system-fake.d.ts.map +1 -1
  26. package/build/dts/infrastructure/file-system-live.d.ts.map +1 -1
  27. package/build/dts/infrastructure/manifest-file.d.ts +11 -2
  28. package/build/dts/infrastructure/manifest-file.d.ts.map +1 -1
  29. package/build/dts/infrastructure/manifest-include.d.ts +16 -0
  30. package/build/dts/infrastructure/manifest-include.d.ts.map +1 -0
  31. package/build/dts/infrastructure/syntax-matcher-fake.d.ts +10 -0
  32. package/build/dts/infrastructure/syntax-matcher-fake.d.ts.map +1 -0
  33. package/build/dts/infrastructure/walk.d.ts +12 -1
  34. package/build/dts/infrastructure/walk.d.ts.map +1 -1
  35. package/build/dts/load/extension.d.ts +33 -0
  36. package/build/dts/load/extension.d.ts.map +1 -0
  37. package/build/dts/load/policy.d.ts +10 -0
  38. package/build/dts/load/policy.d.ts.map +1 -1
  39. package/build/dts/manifest/compile.d.ts +11 -1
  40. package/build/dts/manifest/compile.d.ts.map +1 -1
  41. package/build/dts/manifest/expand.d.ts +27 -0
  42. package/build/dts/manifest/expand.d.ts.map +1 -0
  43. package/build/dts/manifest/extension.d.ts +8 -0
  44. package/build/dts/manifest/extension.d.ts.map +1 -0
  45. package/build/dts/manifest/glob.d.ts +1 -0
  46. package/build/dts/manifest/glob.d.ts.map +1 -1
  47. package/build/dts/manifest/infer.d.ts +55 -0
  48. package/build/dts/manifest/infer.d.ts.map +1 -0
  49. package/build/dts/manifest/json-schema.d.ts +14 -0
  50. package/build/dts/manifest/json-schema.d.ts.map +1 -0
  51. package/build/dts/manifest/manifest.d.ts +26 -1
  52. package/build/dts/manifest/manifest.d.ts.map +1 -1
  53. package/build/dts/ports/file-system.d.ts +1 -0
  54. package/build/dts/ports/file-system.d.ts.map +1 -1
  55. package/build/dts/ports/language.d.ts +4 -0
  56. package/build/dts/ports/language.d.ts.map +1 -1
  57. package/build/dts/ports/syntax-matcher.d.ts +21 -0
  58. package/build/dts/ports/syntax-matcher.d.ts.map +1 -0
  59. package/build/dts/testing.d.ts +1 -0
  60. package/build/dts/testing.d.ts.map +1 -1
  61. package/build/esm/core/coverage.js +107 -32
  62. package/build/esm/core/coverage.js.map +1 -1
  63. package/build/esm/core/graph.js +45 -0
  64. package/build/esm/core/graph.js.map +1 -1
  65. package/build/esm/core/imports.js +14 -9
  66. package/build/esm/core/imports.js.map +1 -1
  67. package/build/esm/core/slack.js +76 -0
  68. package/build/esm/core/slack.js.map +1 -0
  69. package/build/esm/core/structure.js +6 -3
  70. package/build/esm/core/structure.js.map +1 -1
  71. package/build/esm/domain/architecture-config.js +28 -4
  72. package/build/esm/domain/architecture-config.js.map +1 -1
  73. package/build/esm/domain/architecture-error.js +27 -0
  74. package/build/esm/domain/architecture-error.js.map +1 -1
  75. package/build/esm/domain/manifest-location.js +21 -0
  76. package/build/esm/domain/manifest-location.js.map +1 -0
  77. package/build/esm/domain/snapshot.js +181 -0
  78. package/build/esm/domain/snapshot.js.map +1 -0
  79. package/build/esm/domain/violation.js.map +1 -1
  80. package/build/esm/index.js +28 -9
  81. package/build/esm/index.js.map +1 -1
  82. package/build/esm/infrastructure/file-system-fake.js +10 -0
  83. package/build/esm/infrastructure/file-system-fake.js.map +1 -1
  84. package/build/esm/infrastructure/file-system-live.js +9 -1
  85. package/build/esm/infrastructure/file-system-live.js.map +1 -1
  86. package/build/esm/infrastructure/manifest-file.js +158 -7
  87. package/build/esm/infrastructure/manifest-file.js.map +1 -1
  88. package/build/esm/infrastructure/manifest-include.js +187 -0
  89. package/build/esm/infrastructure/manifest-include.js.map +1 -0
  90. package/build/esm/infrastructure/syntax-matcher-fake.js +33 -0
  91. package/build/esm/infrastructure/syntax-matcher-fake.js.map +1 -0
  92. package/build/esm/infrastructure/walk.js +106 -4
  93. package/build/esm/infrastructure/walk.js.map +1 -1
  94. package/build/esm/load/extension.js +2 -0
  95. package/build/esm/load/extension.js.map +1 -0
  96. package/build/esm/load/policy.js +49 -2
  97. package/build/esm/load/policy.js.map +1 -1
  98. package/build/esm/manifest/compile.js +50 -27
  99. package/build/esm/manifest/compile.js.map +1 -1
  100. package/build/esm/manifest/expand.js +116 -0
  101. package/build/esm/manifest/expand.js.map +1 -0
  102. package/build/esm/manifest/extension.js +2 -0
  103. package/build/esm/manifest/extension.js.map +1 -0
  104. package/build/esm/manifest/glob.js +3 -0
  105. package/build/esm/manifest/glob.js.map +1 -1
  106. package/build/esm/manifest/infer.js +455 -0
  107. package/build/esm/manifest/infer.js.map +1 -0
  108. package/build/esm/manifest/json-schema.js +169 -0
  109. package/build/esm/manifest/json-schema.js.map +1 -0
  110. package/build/esm/manifest/manifest.js +127 -5
  111. package/build/esm/manifest/manifest.js.map +1 -1
  112. package/build/esm/ports/syntax-matcher.js +12 -0
  113. package/build/esm/ports/syntax-matcher.js.map +1 -0
  114. package/build/esm/testing.js +1 -0
  115. package/build/esm/testing.js.map +1 -1
  116. package/package.json +9 -3
  117. package/schema/architecture-node.schema.json +1518 -0
  118. package/schema/architecture.schema.json +4498 -0
  119. package/schema/conformance.schema.json +1019 -0
  120. package/src/core/coverage.ts +164 -34
  121. package/src/core/graph.ts +48 -0
  122. package/src/core/imports.ts +29 -13
  123. package/src/core/slack.ts +135 -0
  124. package/src/core/structure.ts +15 -8
  125. package/src/domain/architecture-config.ts +31 -4
  126. package/src/domain/architecture-error.ts +30 -0
  127. package/src/domain/facts.ts +9 -4
  128. package/src/domain/manifest-location.ts +41 -0
  129. package/src/domain/snapshot.ts +402 -0
  130. package/src/domain/violation.ts +4 -1
  131. package/src/index.ts +149 -7
  132. package/src/infrastructure/file-system-fake.ts +12 -0
  133. package/src/infrastructure/file-system-live.ts +8 -1
  134. package/src/infrastructure/manifest-file.ts +204 -8
  135. package/src/infrastructure/manifest-include.ts +318 -0
  136. package/src/infrastructure/syntax-matcher-fake.ts +51 -0
  137. package/src/infrastructure/walk.ts +127 -3
  138. package/src/load/extension.ts +66 -0
  139. package/src/load/policy.ts +77 -2
  140. package/src/manifest/compile.ts +81 -30
  141. package/src/manifest/expand.ts +183 -0
  142. package/src/manifest/extension.ts +41 -0
  143. package/src/manifest/glob.ts +5 -0
  144. package/src/manifest/infer.ts +643 -0
  145. package/src/manifest/json-schema.ts +210 -0
  146. package/src/manifest/manifest.ts +206 -10
  147. package/src/ports/file-system.ts +3 -0
  148. package/src/ports/language.ts +17 -0
  149. package/src/ports/syntax-matcher.ts +42 -0
  150. package/src/testing.ts +1 -0
@@ -44,18 +44,26 @@ import {
44
44
  type PatternInvalid,
45
45
  } from "../domain/architecture-error.js";
46
46
  import type { SourceFacts } from "../domain/facts.js";
47
+ import type { ManifestLocator } from "../domain/manifest-location.js";
47
48
  import { type LoweredRules, lowerManifest } from "../manifest/compile.js";
48
49
  import { decodeManifest, type Manifest } from "../manifest/manifest.js";
49
50
  import type { FactExtractor } from "../ports/fact-extractor.js";
50
51
  import type { FileSystem } from "../ports/file-system.js";
51
52
  import type { Language } from "../ports/language.js";
52
53
  import type { ModuleResolver } from "../ports/module-resolver.js";
54
+ import type { SyntaxMatcher } from "../ports/syntax-matcher.js";
55
+ import type { ExtensionRoute, PolicyExtension } from "./extension.js";
53
56
 
54
57
  // A manifest, read by a host, turned into the policy both adapters evaluate.
55
58
  // Decoding, lowering, compiling and probing happen here, once, with whatever
56
59
  // languages the host hands in — this tier never names one. The host reads the
57
60
  // manifest file, constructs its language packs and the live file system, and
58
61
  // hands them over; that is the whole of what a host has to know.
62
+ //
63
+ // A family the core does not own arrives the same way: as a `PolicyExtension`
64
+ // in `extensions`, which claims its keys of the manifest, decodes them, and
65
+ // hands back a value carried on `extensions` below. The core never looks
66
+ // inside that value; the package that produced it exports the accessor.
59
67
 
60
68
  export type LoadedPolicy = {
61
69
  readonly repoRoot: string;
@@ -69,6 +77,10 @@ export type LoadedPolicy = {
69
77
  readonly graph: CompiledGraph;
70
78
  readonly adoption: LoweredRules["adoption"];
71
79
  readonly structure: CompiledStructure;
80
+ // What each loaded extension loaded, keyed by its id. Opaque here.
81
+ readonly extensions: ReadonlyMap<string, unknown>;
82
+ // The clock the policy is judged by — a stall, a window, a timestamp.
83
+ readonly now: number;
72
84
  readonly fileSystem: FileSystem;
73
85
  // The language packs this policy is evaluated with. The walker takes its
74
86
  // extensions from them, and lowering the shape of its probes.
@@ -81,6 +93,11 @@ export type LoadedPolicy = {
81
93
  // Routes each file to the extractor of the language whose scope covers it.
82
94
  // The CLI reads every file through this; the plugin reads oxlint's tree.
83
95
  readonly extractor: FactExtractor;
96
+ // Routes each file to the syntax matcher of the language whose scope
97
+ // covers it; `parse` answers `null` for a file whose language has none.
98
+ readonly syntax: SyntaxMatcher;
99
+ // Which scope, and so which language, covers a given file.
100
+ readonly routeFor: (file: string) => ExtensionRoute | undefined;
84
101
  readonly ignoreUnresolved: ReadonlyArray<RegExp>;
85
102
  // Deprecation notices from reading the manifest. The host prints them once.
86
103
  readonly notices: ReadonlyArray<string>;
@@ -93,8 +110,17 @@ export type LoadPolicyInput = {
93
110
  // The manifest as the host read it — a module's default export, a parsed
94
111
  // document — before decoding.
95
112
  readonly manifest: unknown;
113
+ // From a data file, where a path in the manifest was written, so a decode
114
+ // error names a line. A module manifest has none.
115
+ readonly locate?: ManifestLocator | undefined;
96
116
  readonly languages: ReadonlyArray<Language>;
97
117
  readonly fileSystem: FileSystem;
118
+ // The families the core does not own, composed by the host. A manifest key
119
+ // one of these claims is decoded by it; a key none claims is refused as the
120
+ // excess property it is.
121
+ readonly extensions?: ReadonlyArray<PolicyExtension> | undefined;
122
+ // For tests and for a CI that pins the clock; defaults to `Date.now()`.
123
+ readonly now?: number | undefined;
98
124
  };
99
125
 
100
126
  const NOTHING: SourceFacts = {
@@ -180,6 +206,14 @@ const makeRoutingExtractor = (routes: ReadonlyArray<Route>): FactExtractor => ({
180
206
  routeFor(routes, file)?.language.extractor.factsOf(file, text) ?? NOTHING,
181
207
  });
182
208
 
209
+ // One matcher per scope, behind one port that picks the scope by the file. A
210
+ // language without one parses nothing, which a `syntax` term reads as no
211
+ // matches — and which an extension's probe check refuses for a family that
212
+ // depends on it.
213
+ const makeRoutingMatcher = (routes: ReadonlyArray<Route>): SyntaxMatcher => ({
214
+ parse: (file, text) => routeFor(routes, file)?.language.syntax?.parse(file, text) ?? null,
215
+ });
216
+
183
217
  // The baseline is read through the port, so this tier touches no file itself.
184
218
  // An absent or unreadable one carries nothing, which is the safe direction:
185
219
  // every violation reports.
@@ -201,8 +235,12 @@ export const loadPolicy = (
201
235
  input: LoadPolicyInput,
202
236
  ): Result.Result<LoadedPolicy, ConfigInvalid | PatternInvalid> => {
203
237
  const { configPath, fileSystem, languages, repoRoot } = input;
238
+ const extensions = input.extensions ?? [];
204
239
 
205
- const decoded = decodeManifest(configPath, input.manifest);
240
+ const decoded = decodeManifest(configPath, input.manifest, {
241
+ locate: input.locate,
242
+ extensions,
243
+ });
206
244
  if (Result.isFailure(decoded)) return Result.fail(decoded.failure);
207
245
  const config = decoded.success.manifest;
208
246
 
@@ -212,7 +250,7 @@ export const loadPolicy = (
212
250
  // The manifest is the authoring surface; these flat rules are the machine's.
213
251
  // The languages tell lowering what a source file in each scope is called, so
214
252
  // a synthetic probe is a file of the scope's language.
215
- const rules = lowerManifest(config, languages);
253
+ const rules = lowerManifest(config, languages, { substitutions: decoded.success.substitutions });
216
254
 
217
255
  // The ceilings. A tier that says "not tightened yet" is a sentence someone
218
256
  // wrote; a ceiling on how many may say so is what keeps the backlog from
@@ -281,12 +319,44 @@ export const loadPolicy = (
281
319
  // reads that file through. The plugin reads through oxlint's tree instead,
282
320
  // and the parity suite is what holds that tree to this one.
283
321
  const extractor = makeRoutingExtractor(routes.success);
322
+ const syntax = makeRoutingMatcher(routes.success);
323
+ const routeOf = (file: string): ExtensionRoute | undefined => {
324
+ const found = routeFor(routes.success, file);
325
+ return found === undefined ? undefined : { language: found.language, scope: found.scope };
326
+ };
327
+ const now = input.now ?? Date.now();
328
+
329
+ // Each family the core does not own compiles and probes its own rules, with
330
+ // the same extractor and matcher the core used for its. Its probe failures
331
+ // join the one refusal below, so a vacuous campaign and a vacuous import
332
+ // rule are reported in the same sentence.
333
+ const loadedExtensions = new Map<string, unknown>();
334
+ const extensionVacuous: Array<string> = [];
335
+ for (const extension of extensions) {
336
+ const loaded = extension.load({
337
+ spec: decoded.success.extensions.get(extension.id),
338
+ configPath,
339
+ repoRoot,
340
+ config,
341
+ languages,
342
+ fileSystem,
343
+ extractor,
344
+ syntax,
345
+ routeFor: routeOf,
346
+ now,
347
+ });
348
+ if (Result.isFailure(loaded)) return Result.fail(loaded.failure);
349
+ loadedExtensions.set(extension.id, loaded.success.value);
350
+ for (const name of loaded.success.vacuous ?? []) extensionVacuous.push(name);
351
+ }
352
+
284
353
  const parsedBy = (rule: { readonly probe: { readonly from: string } }): string => {
285
354
  const route = routeFor(routes.success, rule.probe.from);
286
355
  return route === undefined
287
356
  ? ` — no resolve scope covers its probe ${JSON.stringify(rule.probe.from)}, so no language parsed it`
288
357
  : ` — probe parsed by ${route.language.id}, selected by the scope ${JSON.stringify(route.scope.files)}`;
289
358
  };
359
+
290
360
  const vacuous = [
291
361
  ...rulesFailingTheirProbe(importRules.success).map((rule) => rule.name),
292
362
  ...exportRulesFailingTheirProbe(exportRules.success, extractor).map(
@@ -300,6 +370,7 @@ export const loadPolicy = (
300
370
  ),
301
371
  ...structureRulesFailingTheirProbe(structure.success),
302
372
  ...graphRulesFailingTheirProbe(graph.success),
373
+ ...extensionVacuous,
303
374
  ];
304
375
  if (vacuous.length > 0) {
305
376
  return Result.fail(
@@ -331,11 +402,15 @@ export const loadPolicy = (
331
402
  graph: graph.success,
332
403
  adoption: rules.adoption,
333
404
  structure: structure.success,
405
+ extensions: loadedExtensions,
406
+ now,
334
407
  fileSystem,
335
408
  languages,
336
409
  baseline: makeBaselineFilter(readBaseline(fileSystem, config.baseline)),
337
410
  resolver: resolver.success,
338
411
  extractor,
412
+ syntax,
413
+ routeFor: routeOf,
339
414
  ignoreUnresolved: (config.resolve.ignoreUnresolved ?? []).map(
340
415
  (pattern: string) => new RegExp(pattern),
341
416
  ),
@@ -1,4 +1,5 @@
1
1
  import {
2
+ type Allowance,
2
3
  type ExportRule,
3
4
  type GraphConfig,
4
5
  type ImportRule,
@@ -10,6 +11,8 @@ import {
10
11
  type StructureRoot,
11
12
  type SurfaceRule,
12
13
  } from "../domain/architecture-config.js";
14
+ import type { ManifestPath } from "../domain/manifest-location.js";
15
+ import { fragmentOf, type Substitution } from "./expand.js";
13
16
  import { anchored, type CaptureIndex, globToRegexSource, prefixed } from "./glob.js";
14
17
  import {
15
18
  globsOf,
@@ -69,7 +72,7 @@ const alternativesOf = (key: string): ReadonlyArray<string> =>
69
72
  .map(stripSlash)
70
73
  .filter((one) => one !== "");
71
74
 
72
- const expandAliases = (glob: string, aliases: Readonly<Record<string, string>>): string => {
75
+ export const expandAliases = (glob: string, aliases: Readonly<Record<string, string>>): string => {
73
76
  for (const [alias, target] of Object.entries(aliases)) {
74
77
  if (glob === alias) return target;
75
78
  if (glob.startsWith(`${alias}/`)) return target + glob.slice(alias.length);
@@ -84,12 +87,11 @@ type Frame = {
84
87
  readonly pathGlob: string;
85
88
  readonly captures: CaptureIndex;
86
89
  readonly nextGroup: number;
87
- // Accumulated down the tree. `reset` is the only thing that clears it.
88
- readonly allow: ReadonlyArray<string>;
89
- // Third-party packages, by name, accumulated and reset the same way. Kept
90
- // apart from `allow` because a package is judged by its name and never by
91
- // where the language's resolver found it.
92
- readonly externals: ReadonlyArray<string>;
90
+ // The allowlist in force, accumulated down the tree; `reset` is the only
91
+ // thing that clears it. Each entry remembers the node that wrote it. A path
92
+ // glob is compiled to a target pattern; a package is judged by its name and
93
+ // never by where the language's resolver found it, so it carries none.
94
+ readonly allowances: ReadonlyArray<Allowance>;
93
95
  readonly importsMessage: string;
94
96
  // Inherited like the allowlist: a tier states its naming convention once.
95
97
  readonly naming: NamingSpec | undefined;
@@ -136,7 +138,7 @@ const probeMemberName = (match: string | ReadonlyArray<string> | undefined): str
136
138
 
137
139
  // Each convention pairs the shape a name must have with a name that does not
138
140
  // have it, so the rule's probe is generated rather than written.
139
- const CONVENTIONS: Readonly<
141
+ export const CONVENTIONS: Readonly<
140
142
  Record<string, { readonly source: string; readonly violating: string; readonly shape: string }>
141
143
  > = {
142
144
  "kebab-case": {
@@ -225,22 +227,24 @@ 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,
231
238
  aliases: Readonly<Record<string, string>>,
232
239
  captures: CaptureIndex,
233
240
  nextGroup: number,
234
- ): Pick<Frame, "allow" | "externals" | "importsMessage"> & {
241
+ node: string,
242
+ provenance: ImportsProvenance,
243
+ ): Pick<Frame, "allowances" | "importsMessage"> & {
235
244
  readonly deny: ReadonlyArray<Denial>;
236
245
  } => {
237
246
  if (spec === undefined) {
238
- return {
239
- allow: frame.allow,
240
- externals: frame.externals,
241
- deny: [],
242
- importsMessage: frame.importsMessage,
243
- };
247
+ return { allowances: frame.allowances, deny: [], importsMessage: frame.importsMessage };
244
248
  }
245
249
 
246
250
  const compileAllow = (glob: string): string =>
@@ -249,8 +253,25 @@ const mergeImports = (
249
253
  .source,
250
254
  );
251
255
 
252
- const own = globsOf(spec.allow ?? []).map(compileAllow);
253
- const external = spec.external ?? [];
256
+ const via = (key: "allow" | "external"): Pick<Allowance, "fragment"> => {
257
+ const fragment = provenance(key);
258
+ return fragment === undefined ? {} : { fragment };
259
+ };
260
+ const own: ReadonlyArray<Allowance> = [
261
+ ...globsOf(spec.allow ?? []).map((glob) => ({
262
+ node,
263
+ kind: "allow" as const,
264
+ entry: expandAliases(glob, aliases),
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"),
273
+ })),
274
+ ];
254
275
  const deny = (spec.deny ?? []).flatMap((entry) =>
255
276
  globsOf(entry.match).map((glob) => ({
256
277
  match: compileAllow(glob),
@@ -266,8 +287,7 @@ const mergeImports = (
266
287
  // a mistake here would be dangerous in.
267
288
  const dropping = spec.reset === true || spec.unrestricted === true;
268
289
  return {
269
- allow: dropping ? own : [...frame.allow, ...own],
270
- externals: dropping ? external : [...frame.externals, ...external],
290
+ allowances: dropping ? own : [...frame.allowances, ...own],
271
291
  // Only what this node declares. A prohibition is emitted once, over its whole
272
292
  // subtree, so descendants neither re-emit it nor can escape it — which is
273
293
  // what makes `reset` structurally unable to make a subtree quieter.
@@ -276,11 +296,20 @@ const mergeImports = (
276
296
  };
277
297
  };
278
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
+
279
306
  export const lowerManifest = (
280
307
  manifest: Manifest,
281
308
  languages: ReadonlyArray<ProbeLanguage> = [],
309
+ options: LowerOptions = {},
282
310
  ): LoweredRules => {
283
311
  const aliases = manifest.aliases ?? {};
312
+ const substitutions = options.substitutions ?? [];
284
313
 
285
314
  // The extension a synthetic probe file carries: the first extension of the
286
315
  // language whose scope covers the probe's folder. A probe is matched by its
@@ -343,6 +372,9 @@ export const lowerManifest = (
343
372
  parent: Frame,
344
373
  name: string,
345
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,
346
378
  ): void => {
347
379
  const literalSiblings = siblings
348
380
  .filter((sibling) => sibling !== key)
@@ -392,15 +424,22 @@ export const lowerManifest = (
392
424
  parent.nextGroup +
393
425
  (Object.keys(compiled.captures).length - Object.keys(parent.captures).length);
394
426
 
395
- const merged = mergeImports(parent, node.imports, aliases, compiled.captures, nextGroup);
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
+ );
396
436
  const ownDenials = merged.deny;
397
437
  const frame: Frame = {
398
438
  pathSource,
399
439
  pathGlob: joinedGlob,
400
440
  captures: compiled.captures,
401
441
  nextGroup,
402
- allow: merged.allow,
403
- externals: merged.externals,
442
+ allowances: merged.allowances,
404
443
  importsMessage: merged.importsMessage,
405
444
  naming: node.name ?? parent.naming,
406
445
  };
@@ -590,7 +629,11 @@ export const lowerManifest = (
590
629
  }
591
630
  const siblingKeys = childKeys.map(([childKey]) => childKey);
592
631
  for (const [childKey, child] of childKeys) {
593
- 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
+ ]);
594
637
  }
595
638
  }
596
639
 
@@ -608,9 +651,14 @@ export const lowerManifest = (
608
651
  ? probePathOf(joinedGlob, "")
609
652
  : probePathOf(joinedGlob, "").replace(/\/[^/]*$/, "");
610
653
 
611
- const admitsEverything = frame.allow.some((pattern) => pattern === "^.*" || pattern === "^");
612
- const hasAllowlist =
613
- (frame.allow.length > 0 || frame.externals.length > 0) && !admitsEverything;
654
+ const allow = frame.allowances.flatMap((one) =>
655
+ one.pattern === undefined ? [] : [one.pattern],
656
+ );
657
+ const externals = frame.allowances
658
+ .filter((one) => one.kind === "external")
659
+ .map((one) => one.entry);
660
+ const admitsEverything = allow.some((pattern) => pattern === "^.*" || pattern === "^");
661
+ const hasAllowlist = frame.allowances.length > 0 && !admitsEverything;
614
662
 
615
663
  if (emitsOwnImports && node.imports?.unrestricted !== true && !hasAllowlist) {
616
664
  throw new Error(
@@ -630,8 +678,9 @@ export const lowerManifest = (
630
678
  probe: { from: scopeProbe, to: probeOutside("nowhere", ownFolder) },
631
679
  from: scope,
632
680
  ...exemptions,
633
- toNot: [...frame.allow],
634
- ...(frame.externals.length > 0 ? { externals: [...frame.externals] } : {}),
681
+ toNot: allow,
682
+ ...(externals.length > 0 ? { externals } : {}),
683
+ allowances: frame.allowances,
635
684
  });
636
685
  }
637
686
  }
@@ -841,8 +890,7 @@ export const lowerManifest = (
841
890
  pathGlob: "",
842
891
  captures: {},
843
892
  nextGroup: 1,
844
- allow: [],
845
- externals: [],
893
+ allowances: [],
846
894
  importsMessage: "This import is not on this folder's allowlist.",
847
895
  naming: undefined,
848
896
  };
@@ -856,6 +904,8 @@ export const lowerManifest = (
856
904
  aliases,
857
905
  {},
858
906
  1,
907
+ "repo",
908
+ () => undefined,
859
909
  ).deny.entries()) {
860
910
  imports.push({
861
911
  name: `repo/deny-${String(index)}`,
@@ -898,6 +948,7 @@ export const lowerManifest = (
898
948
  .replace(/[^a-zA-Z0-9]+/g, "-")
899
949
  .replace(/^-|-$/g, ""),
900
950
  Object.keys(manifest.tree),
951
+ ["tree", key],
901
952
  );
902
953
  }
903
954
 
@@ -0,0 +1,183 @@
1
+ import * as Result from "effect/Result";
2
+
3
+ import type { ManifestPath } from "../domain/manifest-location.js";
4
+
5
+ // Reuse inside a manifest, in the manifest's own schema rather than the
6
+ // format's. A top-level `defs` map names fragments; `{ use: "<name>" }`
7
+ // anywhere in the rest of the document is replaced by a deep copy of the
8
+ // fragment. This runs on the raw value before decoding, so it works the same
9
+ // in YAML, in JSON, and in a JavaScript module that chose to write it — and
10
+ // the schema that decodes the result never has to know a reference existed.
11
+ //
12
+ // There is deliberately nothing else here: no interpolation, no includes, no
13
+ // deep merge. A fragment that needs partial override is two fragments; a
14
+ // manifest that needs more than a data format offers needs a generator, and
15
+ // a generator can emit YAML.
16
+
17
+ export type ExpandIssue = {
18
+ readonly path: ManifestPath;
19
+ readonly detail: string;
20
+ };
21
+
22
+ // One `use` that was expanded. `at` is where the fragment landed in the
23
+ // expanded document; `ref` is where the reference was written in the original
24
+ // one; `overrides` are the keys written beside `use`, which came from the
25
+ // reference site rather than from the fragment.
26
+ export type Substitution = {
27
+ readonly at: ManifestPath;
28
+ readonly ref: ManifestPath;
29
+ readonly name: string;
30
+ readonly overrides: ReadonlySet<string>;
31
+ };
32
+
33
+ export type ExpandedManifest = {
34
+ readonly value: unknown;
35
+ readonly substitutions: ReadonlyArray<Substitution>;
36
+ };
37
+
38
+ // Where a path in the expanded document was written in the original one. When
39
+ // the path crosses a `use`, `via` lists each reference it passed through,
40
+ // outermost first, so an error inside a fragment can name the line that pulled
41
+ // the fragment in as well as the fragment itself.
42
+ export type Origin = {
43
+ readonly path: ManifestPath;
44
+ readonly via: ReadonlyArray<{ readonly at: ManifestPath; readonly name: string }>;
45
+ };
46
+
47
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
48
+ typeof value === "object" && value !== null && !Array.isArray(value);
49
+
50
+ const isReference = (value: unknown): value is Record<string, unknown> & { readonly use: string } =>
51
+ isRecord(value) && typeof value.use === "string";
52
+
53
+ export const expandManifest = (input: unknown): Result.Result<ExpandedManifest, ExpandIssue> => {
54
+ if (!isRecord(input)) return Result.succeed({ value: input, substitutions: [] });
55
+
56
+ // Two keys the file may carry that the schema does not: the fragments, and
57
+ // the `$schema` a JSON author writes for editor validation.
58
+ const { $schema: _schema, defs, ...rest } = input;
59
+ if (defs !== undefined && !isRecord(defs)) {
60
+ return Result.fail({
61
+ path: ["defs"],
62
+ detail: "`defs` must be a map of named fragments.",
63
+ });
64
+ }
65
+ const fragments: Record<string, unknown> = defs ?? {};
66
+ const defined = Object.keys(fragments);
67
+ const substitutions: Array<Substitution> = [];
68
+
69
+ // `at` is the path in the document being built; `origin` the path in the
70
+ // document as written; `stack` the fragments currently being expanded, for
71
+ // the cycle check.
72
+ const walk = (
73
+ value: unknown,
74
+ at: ManifestPath,
75
+ origin: ManifestPath,
76
+ stack: ReadonlyArray<string>,
77
+ ): Result.Result<unknown, ExpandIssue> => {
78
+ if (isReference(value)) {
79
+ const { use: name, ...overrides } = value;
80
+ if (!(name in fragments)) {
81
+ return Result.fail({
82
+ path: origin,
83
+ detail:
84
+ `\`use: ${JSON.stringify(name)}\` names no entry in \`defs\`` +
85
+ (defined.length === 0
86
+ ? " — the manifest defines none."
87
+ : ` (defined: ${defined.join(", ")}).`),
88
+ });
89
+ }
90
+ if (stack.includes(name)) {
91
+ return Result.fail({
92
+ path: origin,
93
+ detail: `\`defs\` contains a cycle: ${[...stack, name].join(" → ")}.`,
94
+ });
95
+ }
96
+ substitutions.push({ at, ref: origin, name, overrides: new Set(Object.keys(overrides)) });
97
+
98
+ const fragment = walk(fragments[name], at, ["defs", name], [...stack, name]);
99
+ if (Result.isFailure(fragment)) return fragment;
100
+ if (Object.keys(overrides).length === 0) return fragment;
101
+
102
+ if (!isRecord(fragment.success)) {
103
+ return Result.fail({
104
+ path: origin,
105
+ detail:
106
+ `\`use: ${JSON.stringify(name)}\` is written with overrides ` +
107
+ `(${Object.keys(overrides).join(", ")}), but \`defs.${name}\` is not an object, ` +
108
+ `so there is nothing to override.`,
109
+ });
110
+ }
111
+ const merged: Record<string, unknown> = { ...fragment.success };
112
+ for (const [key, override] of Object.entries(overrides)) {
113
+ const expanded = walk(override, [...at, key], [...origin, key], stack);
114
+ if (Result.isFailure(expanded)) return expanded;
115
+ merged[key] = expanded.success;
116
+ }
117
+ return Result.succeed(merged);
118
+ }
119
+
120
+ if (Array.isArray(value)) {
121
+ const items: Array<unknown> = [];
122
+ for (const [index, item] of value.entries()) {
123
+ const expanded = walk(item, [...at, index], [...origin, index], stack);
124
+ if (Result.isFailure(expanded)) return expanded;
125
+ items.push(expanded.success);
126
+ }
127
+ return Result.succeed(items);
128
+ }
129
+
130
+ if (isRecord(value)) {
131
+ const entries: Record<string, unknown> = {};
132
+ for (const [key, item] of Object.entries(value)) {
133
+ const expanded = walk(item, [...at, key], [...origin, key], stack);
134
+ if (Result.isFailure(expanded)) return expanded;
135
+ entries[key] = expanded.success;
136
+ }
137
+ return Result.succeed(entries);
138
+ }
139
+
140
+ return Result.succeed(value);
141
+ };
142
+
143
+ const expanded = walk(rest, [], [], []);
144
+ if (Result.isFailure(expanded)) return Result.fail(expanded.failure);
145
+ return Result.succeed({ value: expanded.success, substitutions });
146
+ };
147
+
148
+ const isPrefix = (prefix: ManifestPath, path: ManifestPath): boolean =>
149
+ prefix.length <= path.length && prefix.every((segment, index) => path[index] === segment);
150
+
151
+ // Maps a path in the expanded document back to where it was written.
152
+ export const originOf = (
153
+ substitutions: ReadonlyArray<Substitution>,
154
+ path: ManifestPath,
155
+ ): Origin => {
156
+ const crossed = substitutions
157
+ .filter((one) => isPrefix(one.at, path))
158
+ .sort((a, b) => a.at.length - b.at.length);
159
+ const innermost = crossed.at(-1);
160
+ if (innermost === undefined) return { path, via: [] };
161
+
162
+ const outer = crossed.slice(0, -1).map((one) => ({ at: one.ref, name: one.name }));
163
+ const rest = path.slice(innermost.at.length);
164
+ const first = rest[0];
165
+
166
+ // A key written beside `use` belongs to the reference site, not the fragment.
167
+ if (typeof first === "string" && innermost.overrides.has(first)) {
168
+ return { path: [...innermost.ref, ...rest], via: outer };
169
+ }
170
+ return {
171
+ path: ["defs", innermost.name, ...rest],
172
+ via: [...outer, { at: innermost.ref, name: innermost.name }],
173
+ };
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;
@@ -0,0 +1,41 @@
1
+ import type * as Result from "effect/Result";
2
+
3
+ import type { ManifestPath } from "../domain/manifest-location.js";
4
+
5
+ // A family the core does not own.
6
+ //
7
+ // The five per-file families, the graph and the limits are the core's: it
8
+ // declares their vocabulary, decodes it and evaluates it. A family that is
9
+ // not — `campaigns`, which @goodbones/campaigns owns — states which top-level
10
+ // manifest keys it claims, and decodes them itself. The core splits those
11
+ // keys off the expanded manifest before decoding what is left, so the core's
12
+ // codec never learns a word of the family's vocabulary and a key no
13
+ // extension claims is still the excess property it always was.
14
+ //
15
+ // This is the decode half. The load half — compiling, probing, and whatever
16
+ // state the family reads off disk — is `PolicyExtension` in `load/`, which
17
+ // needs the ports and so cannot live here.
18
+
19
+ export type ManifestExtension<Spec = unknown> = {
20
+ // Names the family in errors, and keys it in `LoadedPolicy.extensions`.
21
+ readonly id: string;
22
+ // The top-level keys of the manifest this family owns.
23
+ readonly manifestKeys: ReadonlyArray<string>;
24
+ // The slice, as the expansion passes left it: the claimed keys that were
25
+ // present, with `defs`/`use` and `include` already resolved.
26
+ //
27
+ // `describe` renders one issue the way the core's own decoder does — the
28
+ // position in the file, the path, and the `use` it came through — so a
29
+ // decode error from a family the core does not own reads exactly like one
30
+ // from a tree node. Failure is a list of rendered lines, because a manifest
31
+ // is edited by hand and the reader fixing one wants the other three.
32
+ //
33
+ // Declared as a method rather than a function property on purpose: the
34
+ // loader holds extensions as `PolicyExtension<unknown, unknown>`, having
35
+ // erased what each one decodes, and method signatures are what let a
36
+ // concretely-typed extension be one of those.
37
+ decode(
38
+ slice: Readonly<Record<string, unknown>>,
39
+ describe: (path: ManifestPath, detail: string) => string,
40
+ ): Result.Result<Spec, ReadonlyArray<string>>;
41
+ };
@@ -106,3 +106,8 @@ export const globToRegexSource = (
106
106
  export const anchored = (source: string): string => `^${source}$`;
107
107
 
108
108
  export const prefixed = (source: string): string => `^${source}`;
109
+
110
+ // A repo-relative glob as the sector discovery compiles a marker's `owns`:
111
+ // anchored at the start, so it names a subtree.
112
+ export const globToRegExp = (glob: string): RegExp =>
113
+ new RegExp(prefixed(globToRegexSource(glob, {}, { declaring: false, nextGroup: 1 }).source));