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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. package/build/dts/core/campaigns.d.ts +141 -0
  2. package/build/dts/core/campaigns.d.ts.map +1 -0
  3. package/build/dts/core/coverage.d.ts +20 -0
  4. package/build/dts/core/coverage.d.ts.map +1 -1
  5. package/build/dts/core/graph.d.ts +2 -0
  6. package/build/dts/core/graph.d.ts.map +1 -1
  7. package/build/dts/core/imports.d.ts +3 -1
  8. package/build/dts/core/imports.d.ts.map +1 -1
  9. package/build/dts/core/ledger.d.ts +61 -0
  10. package/build/dts/core/ledger.d.ts.map +1 -0
  11. package/build/dts/core/slack.d.ts +22 -0
  12. package/build/dts/core/slack.d.ts.map +1 -0
  13. package/build/dts/core/structure.d.ts +1 -0
  14. package/build/dts/core/structure.d.ts.map +1 -1
  15. package/build/dts/domain/architecture-config.d.ts +324 -1
  16. package/build/dts/domain/architecture-config.d.ts.map +1 -1
  17. package/build/dts/domain/architecture-error.d.ts +8 -0
  18. package/build/dts/domain/architecture-error.d.ts.map +1 -1
  19. package/build/dts/domain/facts.d.ts +1 -0
  20. package/build/dts/domain/facts.d.ts.map +1 -1
  21. package/build/dts/domain/manifest-location.d.ts +9 -0
  22. package/build/dts/domain/manifest-location.d.ts.map +1 -0
  23. package/build/dts/domain/report.d.ts +16 -0
  24. package/build/dts/domain/report.d.ts.map +1 -0
  25. package/build/dts/domain/snapshot.d.ts +224 -0
  26. package/build/dts/domain/snapshot.d.ts.map +1 -0
  27. package/build/dts/domain/violation.d.ts +1 -1
  28. package/build/dts/domain/violation.d.ts.map +1 -1
  29. package/build/dts/index.d.ts +24 -9
  30. package/build/dts/index.d.ts.map +1 -1
  31. package/build/dts/infrastructure/campaign-functions.d.ts +7 -0
  32. package/build/dts/infrastructure/campaign-functions.d.ts.map +1 -0
  33. package/build/dts/infrastructure/manifest-file.d.ts +11 -2
  34. package/build/dts/infrastructure/manifest-file.d.ts.map +1 -1
  35. package/build/dts/infrastructure/manifest-include.d.ts +16 -0
  36. package/build/dts/infrastructure/manifest-include.d.ts.map +1 -0
  37. package/build/dts/infrastructure/report-source-fake.d.ts +4 -0
  38. package/build/dts/infrastructure/report-source-fake.d.ts.map +1 -0
  39. package/build/dts/infrastructure/report-source-live.d.ts +3 -0
  40. package/build/dts/infrastructure/report-source-live.d.ts.map +1 -0
  41. package/build/dts/infrastructure/syntax-matcher-fake.d.ts +10 -0
  42. package/build/dts/infrastructure/syntax-matcher-fake.d.ts.map +1 -0
  43. package/build/dts/infrastructure/walk.d.ts +6 -0
  44. package/build/dts/infrastructure/walk.d.ts.map +1 -1
  45. package/build/dts/load/policy.d.ts +16 -0
  46. package/build/dts/load/policy.d.ts.map +1 -1
  47. package/build/dts/manifest/compile.d.ts +7 -2
  48. package/build/dts/manifest/compile.d.ts.map +1 -1
  49. package/build/dts/manifest/expand.d.ts +27 -0
  50. package/build/dts/manifest/expand.d.ts.map +1 -0
  51. package/build/dts/manifest/infer.d.ts +55 -0
  52. package/build/dts/manifest/infer.d.ts.map +1 -0
  53. package/build/dts/manifest/json-schema.d.ts +10 -0
  54. package/build/dts/manifest/json-schema.d.ts.map +1 -0
  55. package/build/dts/manifest/manifest.d.ts +381 -1
  56. package/build/dts/manifest/manifest.d.ts.map +1 -1
  57. package/build/dts/ports/campaign-predicate.d.ts +24 -0
  58. package/build/dts/ports/campaign-predicate.d.ts.map +1 -0
  59. package/build/dts/ports/language.d.ts +4 -0
  60. package/build/dts/ports/language.d.ts.map +1 -1
  61. package/build/dts/ports/report-source.d.ts +14 -0
  62. package/build/dts/ports/report-source.d.ts.map +1 -0
  63. package/build/dts/ports/syntax-matcher.d.ts +21 -0
  64. package/build/dts/ports/syntax-matcher.d.ts.map +1 -0
  65. package/build/dts/testing.d.ts +2 -0
  66. package/build/dts/testing.d.ts.map +1 -1
  67. package/build/esm/core/campaigns.js +778 -0
  68. package/build/esm/core/campaigns.js.map +1 -0
  69. package/build/esm/core/coverage.js +107 -32
  70. package/build/esm/core/coverage.js.map +1 -1
  71. package/build/esm/core/graph.js +45 -0
  72. package/build/esm/core/graph.js.map +1 -1
  73. package/build/esm/core/imports.js +14 -9
  74. package/build/esm/core/imports.js.map +1 -1
  75. package/build/esm/core/ledger.js +171 -0
  76. package/build/esm/core/ledger.js.map +1 -0
  77. package/build/esm/core/slack.js +76 -0
  78. package/build/esm/core/slack.js.map +1 -0
  79. package/build/esm/core/structure.js +5 -2
  80. package/build/esm/core/structure.js.map +1 -1
  81. package/build/esm/domain/architecture-config.js +158 -1
  82. package/build/esm/domain/architecture-config.js.map +1 -1
  83. package/build/esm/domain/architecture-error.js +27 -0
  84. package/build/esm/domain/architecture-error.js.map +1 -1
  85. package/build/esm/domain/manifest-location.js +21 -0
  86. package/build/esm/domain/manifest-location.js.map +1 -0
  87. package/build/esm/domain/report.js +168 -0
  88. package/build/esm/domain/report.js.map +1 -0
  89. package/build/esm/domain/snapshot.js +141 -0
  90. package/build/esm/domain/snapshot.js.map +1 -0
  91. package/build/esm/domain/violation.js.map +1 -1
  92. package/build/esm/index.js +21 -8
  93. package/build/esm/index.js.map +1 -1
  94. package/build/esm/infrastructure/campaign-functions.js +67 -0
  95. package/build/esm/infrastructure/campaign-functions.js.map +1 -0
  96. package/build/esm/infrastructure/manifest-file.js +158 -7
  97. package/build/esm/infrastructure/manifest-file.js.map +1 -1
  98. package/build/esm/infrastructure/manifest-include.js +187 -0
  99. package/build/esm/infrastructure/manifest-include.js.map +1 -0
  100. package/build/esm/infrastructure/report-source-fake.js +6 -0
  101. package/build/esm/infrastructure/report-source-fake.js.map +1 -0
  102. package/build/esm/infrastructure/report-source-live.js +165 -0
  103. package/build/esm/infrastructure/report-source-live.js.map +1 -0
  104. package/build/esm/infrastructure/syntax-matcher-fake.js +33 -0
  105. package/build/esm/infrastructure/syntax-matcher-fake.js.map +1 -0
  106. package/build/esm/infrastructure/walk.js +57 -1
  107. package/build/esm/infrastructure/walk.js.map +1 -1
  108. package/build/esm/load/policy.js +123 -3
  109. package/build/esm/load/policy.js.map +1 -1
  110. package/build/esm/manifest/compile.js +228 -26
  111. package/build/esm/manifest/compile.js.map +1 -1
  112. package/build/esm/manifest/expand.js +116 -0
  113. package/build/esm/manifest/expand.js.map +1 -0
  114. package/build/esm/manifest/infer.js +455 -0
  115. package/build/esm/manifest/infer.js.map +1 -0
  116. package/build/esm/manifest/json-schema.js +135 -0
  117. package/build/esm/manifest/json-schema.js.map +1 -0
  118. package/build/esm/manifest/manifest.js +243 -5
  119. package/build/esm/manifest/manifest.js.map +1 -1
  120. package/build/esm/ports/campaign-predicate.js +2 -0
  121. package/build/esm/ports/campaign-predicate.js.map +1 -0
  122. package/build/esm/ports/report-source.js +7 -0
  123. package/build/esm/ports/report-source.js.map +1 -0
  124. package/build/esm/ports/syntax-matcher.js +12 -0
  125. package/build/esm/ports/syntax-matcher.js.map +1 -0
  126. package/build/esm/testing.js +2 -0
  127. package/build/esm/testing.js.map +1 -1
  128. package/package.json +9 -3
  129. package/schema/architecture-node.schema.json +2405 -0
  130. package/schema/architecture.schema.json +2825 -0
  131. package/schema/conformance.schema.json +775 -0
  132. package/src/core/campaigns.ts +1056 -0
  133. package/src/core/coverage.ts +164 -34
  134. package/src/core/graph.ts +48 -0
  135. package/src/core/imports.ts +29 -13
  136. package/src/core/ledger.ts +242 -0
  137. package/src/core/slack.ts +135 -0
  138. package/src/core/structure.ts +10 -5
  139. package/src/domain/architecture-config.ts +205 -1
  140. package/src/domain/architecture-error.ts +30 -0
  141. package/src/domain/facts.ts +9 -4
  142. package/src/domain/manifest-location.ts +41 -0
  143. package/src/domain/report.ts +203 -0
  144. package/src/domain/snapshot.ts +302 -0
  145. package/src/domain/violation.ts +4 -1
  146. package/src/index.ts +172 -3
  147. package/src/infrastructure/campaign-functions.ts +98 -0
  148. package/src/infrastructure/manifest-file.ts +204 -8
  149. package/src/infrastructure/manifest-include.ts +318 -0
  150. package/src/infrastructure/report-source-fake.ts +10 -0
  151. package/src/infrastructure/report-source-live.ts +192 -0
  152. package/src/infrastructure/syntax-matcher-fake.ts +51 -0
  153. package/src/infrastructure/walk.ts +70 -1
  154. package/src/load/policy.ts +193 -3
  155. package/src/manifest/compile.ts +290 -28
  156. package/src/manifest/expand.ts +183 -0
  157. package/src/manifest/infer.ts +643 -0
  158. package/src/manifest/json-schema.ts +168 -0
  159. package/src/manifest/manifest.ts +339 -11
  160. package/src/ports/campaign-predicate.ts +30 -0
  161. package/src/ports/language.ts +17 -0
  162. package/src/ports/report-source.ts +35 -0
  163. package/src/ports/syntax-matcher.ts +42 -0
  164. package/src/testing.ts +2 -0
@@ -1,4 +1,7 @@
1
1
  import {
2
+ type Allowance,
3
+ type CampaignRule,
4
+ type Detector,
2
5
  type ExportRule,
3
6
  type GraphConfig,
4
7
  type ImportRule,
@@ -10,13 +13,19 @@ import {
10
13
  type StructureRoot,
11
14
  type SurfaceRule,
12
15
  } from "../domain/architecture-config.js";
16
+ import type { ManifestPath } from "../domain/manifest-location.js";
17
+ import { fragmentOf, type Substitution } from "./expand.js";
13
18
  import { anchored, type CaptureIndex, globToRegexSource, prefixed } from "./glob.js";
14
19
  import {
20
+ type CampaignSpec,
21
+ type DetectorSpec,
22
+ durationMs,
15
23
  globsOf,
16
24
  type ImportsSpec,
17
25
  type Manifest,
18
26
  type ManifestNode,
19
27
  type NamingSpec,
28
+ type SyntaxTermSpec,
20
29
  } from "./manifest.js";
21
30
 
22
31
  // The manifest is the authoring surface; these flat rules are the machine's.
@@ -28,6 +37,8 @@ export type LoweredRules = {
28
37
  readonly members: ReadonlyArray<MemberRule>;
29
38
  readonly surface: ReadonlyArray<SurfaceRule>;
30
39
  readonly graph: GraphConfig;
40
+ // Campaigns pass through by their own id, as top-level `exports` rules do.
41
+ readonly campaigns: ReadonlyArray<CampaignRule>;
31
42
  // The nodes that said "not tightened yet", by name — the adoption backlog,
32
43
  // and what `limits` puts a ceiling on.
33
44
  readonly adoption: {
@@ -84,12 +95,11 @@ type Frame = {
84
95
  readonly pathGlob: string;
85
96
  readonly captures: CaptureIndex;
86
97
  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>;
98
+ // The allowlist in force, accumulated down the tree; `reset` is the only
99
+ // thing that clears it. Each entry remembers the node that wrote it. A path
100
+ // glob is compiled to a target pattern; a package is judged by its name and
101
+ // never by where the language's resolver found it, so it carries none.
102
+ readonly allowances: ReadonlyArray<Allowance>;
93
103
  readonly importsMessage: string;
94
104
  // Inherited like the allowlist: a tier states its naming convention once.
95
105
  readonly naming: NamingSpec | undefined;
@@ -225,22 +235,24 @@ type Denial = {
225
235
  readonly probe: string;
226
236
  };
227
237
 
238
+ // Which `defs` fragment one key of a node's `imports` was written in, when it
239
+ // was written in one. Resolved per key, since `imports: { use: x, allow: […] }`
240
+ // takes `allow` from the reference site and `external` from the fragment.
241
+ type ImportsProvenance = (key: "allow" | "external") => string | undefined;
242
+
228
243
  const mergeImports = (
229
244
  frame: Frame,
230
245
  spec: ImportsSpec | undefined,
231
246
  aliases: Readonly<Record<string, string>>,
232
247
  captures: CaptureIndex,
233
248
  nextGroup: number,
234
- ): Pick<Frame, "allow" | "externals" | "importsMessage"> & {
249
+ node: string,
250
+ provenance: ImportsProvenance,
251
+ ): Pick<Frame, "allowances" | "importsMessage"> & {
235
252
  readonly deny: ReadonlyArray<Denial>;
236
253
  } => {
237
254
  if (spec === undefined) {
238
- return {
239
- allow: frame.allow,
240
- externals: frame.externals,
241
- deny: [],
242
- importsMessage: frame.importsMessage,
243
- };
255
+ return { allowances: frame.allowances, deny: [], importsMessage: frame.importsMessage };
244
256
  }
245
257
 
246
258
  const compileAllow = (glob: string): string =>
@@ -249,8 +261,25 @@ const mergeImports = (
249
261
  .source,
250
262
  );
251
263
 
252
- const own = globsOf(spec.allow ?? []).map(compileAllow);
253
- const external = spec.external ?? [];
264
+ const via = (key: "allow" | "external"): Pick<Allowance, "fragment"> => {
265
+ const fragment = provenance(key);
266
+ return fragment === undefined ? {} : { fragment };
267
+ };
268
+ const own: ReadonlyArray<Allowance> = [
269
+ ...globsOf(spec.allow ?? []).map((glob) => ({
270
+ node,
271
+ kind: "allow" as const,
272
+ entry: expandAliases(glob, aliases),
273
+ pattern: compileAllow(glob),
274
+ ...via("allow"),
275
+ })),
276
+ ...(spec.external ?? []).map((name) => ({
277
+ node,
278
+ kind: "external" as const,
279
+ entry: name,
280
+ ...via("external"),
281
+ })),
282
+ ];
254
283
  const deny = (spec.deny ?? []).flatMap((entry) =>
255
284
  globsOf(entry.match).map((glob) => ({
256
285
  match: compileAllow(glob),
@@ -266,8 +295,7 @@ const mergeImports = (
266
295
  // a mistake here would be dangerous in.
267
296
  const dropping = spec.reset === true || spec.unrestricted === true;
268
297
  return {
269
- allow: dropping ? own : [...frame.allow, ...own],
270
- externals: dropping ? external : [...frame.externals, ...external],
298
+ allowances: dropping ? own : [...frame.allowances, ...own],
271
299
  // Only what this node declares. A prohibition is emitted once, over its whole
272
300
  // subtree, so descendants neither re-emit it nor can escape it — which is
273
301
  // what makes `reset` structurally unable to make a subtree quieter.
@@ -276,11 +304,20 @@ const mergeImports = (
276
304
  };
277
305
  };
278
306
 
307
+ export type LowerOptions = {
308
+ // The `use` references the expansion replaced, so an allowance can say
309
+ // which fragment it came through. A manifest lowered without them is one
310
+ // whose every entry reads as authored where it sits.
311
+ readonly substitutions?: ReadonlyArray<Substitution>;
312
+ };
313
+
279
314
  export const lowerManifest = (
280
315
  manifest: Manifest,
281
316
  languages: ReadonlyArray<ProbeLanguage> = [],
317
+ options: LowerOptions = {},
282
318
  ): LoweredRules => {
283
319
  const aliases = manifest.aliases ?? {};
320
+ const substitutions = options.substitutions ?? [];
284
321
 
285
322
  // The extension a synthetic probe file carries: the first extension of the
286
323
  // language whose scope covers the probe's folder. A probe is matched by its
@@ -343,6 +380,9 @@ export const lowerManifest = (
343
380
  parent: Frame,
344
381
  name: string,
345
382
  siblings: ReadonlyArray<string>,
383
+ // Where this node sits in the expanded document, so its `imports` keys
384
+ // can be traced back through any `use` that carried them.
385
+ nodePath: ManifestPath,
346
386
  ): void => {
347
387
  const literalSiblings = siblings
348
388
  .filter((sibling) => sibling !== key)
@@ -392,15 +432,22 @@ export const lowerManifest = (
392
432
  parent.nextGroup +
393
433
  (Object.keys(compiled.captures).length - Object.keys(parent.captures).length);
394
434
 
395
- const merged = mergeImports(parent, node.imports, aliases, compiled.captures, nextGroup);
435
+ const merged = mergeImports(
436
+ parent,
437
+ node.imports,
438
+ aliases,
439
+ compiled.captures,
440
+ nextGroup,
441
+ name,
442
+ (field) => fragmentOf(substitutions, [...nodePath, "imports", field]),
443
+ );
396
444
  const ownDenials = merged.deny;
397
445
  const frame: Frame = {
398
446
  pathSource,
399
447
  pathGlob: joinedGlob,
400
448
  captures: compiled.captures,
401
449
  nextGroup,
402
- allow: merged.allow,
403
- externals: merged.externals,
450
+ allowances: merged.allowances,
404
451
  importsMessage: merged.importsMessage,
405
452
  naming: node.name ?? parent.naming,
406
453
  };
@@ -590,7 +637,11 @@ export const lowerManifest = (
590
637
  }
591
638
  const siblingKeys = childKeys.map(([childKey]) => childKey);
592
639
  for (const [childKey, child] of childKeys) {
593
- walk(childKey, child, frame, `${name}/${alternativesOf(childKey)[0] ?? ""}`, siblingKeys);
640
+ walk(childKey, child, frame, `${name}/${alternativesOf(childKey)[0] ?? ""}`, siblingKeys, [
641
+ ...nodePath,
642
+ "children",
643
+ childKey,
644
+ ]);
594
645
  }
595
646
  }
596
647
 
@@ -608,9 +659,14 @@ export const lowerManifest = (
608
659
  ? probePathOf(joinedGlob, "")
609
660
  : probePathOf(joinedGlob, "").replace(/\/[^/]*$/, "");
610
661
 
611
- const admitsEverything = frame.allow.some((pattern) => pattern === "^.*" || pattern === "^");
612
- const hasAllowlist =
613
- (frame.allow.length > 0 || frame.externals.length > 0) && !admitsEverything;
662
+ const allow = frame.allowances.flatMap((one) =>
663
+ one.pattern === undefined ? [] : [one.pattern],
664
+ );
665
+ const externals = frame.allowances
666
+ .filter((one) => one.kind === "external")
667
+ .map((one) => one.entry);
668
+ const admitsEverything = allow.some((pattern) => pattern === "^.*" || pattern === "^");
669
+ const hasAllowlist = frame.allowances.length > 0 && !admitsEverything;
614
670
 
615
671
  if (emitsOwnImports && node.imports?.unrestricted !== true && !hasAllowlist) {
616
672
  throw new Error(
@@ -630,8 +686,9 @@ export const lowerManifest = (
630
686
  probe: { from: scopeProbe, to: probeOutside("nowhere", ownFolder) },
631
687
  from: scope,
632
688
  ...exemptions,
633
- toNot: [...frame.allow],
634
- ...(frame.externals.length > 0 ? { externals: [...frame.externals] } : {}),
689
+ toNot: allow,
690
+ ...(externals.length > 0 ? { externals } : {}),
691
+ allowances: frame.allowances,
635
692
  });
636
693
  }
637
694
  }
@@ -841,8 +898,7 @@ export const lowerManifest = (
841
898
  pathGlob: "",
842
899
  captures: {},
843
900
  nextGroup: 1,
844
- allow: [],
845
- externals: [],
901
+ allowances: [],
846
902
  importsMessage: "This import is not on this folder's allowlist.",
847
903
  naming: undefined,
848
904
  };
@@ -856,6 +912,8 @@ export const lowerManifest = (
856
912
  aliases,
857
913
  {},
858
914
  1,
915
+ "repo",
916
+ () => undefined,
859
917
  ).deny.entries()) {
860
918
  imports.push({
861
919
  name: `repo/deny-${String(index)}`,
@@ -898,6 +956,7 @@ export const lowerManifest = (
898
956
  .replace(/[^a-zA-Z0-9]+/g, "-")
899
957
  .replace(/^-|-$/g, ""),
900
958
  Object.keys(manifest.tree),
959
+ ["tree", key],
901
960
  );
902
961
  }
903
962
 
@@ -999,13 +1058,216 @@ export const lowerManifest = (
999
1058
  })),
1000
1059
  };
1001
1060
 
1061
+ const campaigns = (manifest.campaigns ?? []).map((campaign) => lowerCampaign(campaign, aliases));
1062
+
1002
1063
  return {
1003
1064
  imports,
1004
1065
  exports,
1005
1066
  members,
1006
1067
  surface,
1007
1068
  graph,
1069
+ campaigns,
1008
1070
  adoption: { unrestricted: unrestrictedNodes, partial: partialNodes },
1009
1071
  structure: { roots, folders, parity, naming: namingRules },
1010
1072
  };
1011
1073
  };
1074
+
1075
+ // The keys of a `syntax` term that belong to the engine's rule; `where` is
1076
+ // the one that does not.
1077
+ const SYNTAX_RULE_KEYS = [
1078
+ "pattern",
1079
+ "kind",
1080
+ "regex",
1081
+ "nthChild",
1082
+ "inside",
1083
+ "has",
1084
+ "precedes",
1085
+ "follows",
1086
+ "all",
1087
+ "any",
1088
+ "not",
1089
+ ] as const;
1090
+
1091
+ const syntaxRuleOf = (term: SyntaxTermSpec): unknown =>
1092
+ Object.fromEntries(
1093
+ SYNTAX_RULE_KEYS.flatMap((key) => (term[key] === undefined ? [] : [[key, term[key]]])),
1094
+ );
1095
+
1096
+ // The leaf terms a path alone cannot exercise: each reads the file's text,
1097
+ // its syntax, or its facts, so a probe for a detector holding one must carry
1098
+ // a `source`.
1099
+ const TERMS_NEEDING_SOURCE = ["content", "syntax", "exports", "members", "fn"] as const;
1100
+
1101
+ const leafTermsOf = (detector: DetectorSpec): ReadonlyArray<string> => {
1102
+ if ("all" in detector) return detector.all.flatMap(leafTermsOf);
1103
+ if ("any" in detector) return detector.any.flatMap(leafTermsOf);
1104
+ if ("not" in detector) return leafTermsOf(detector.not);
1105
+ return Object.keys(detector);
1106
+ };
1107
+
1108
+ // A campaign's globs resolved: `scope` and a path-shaped `resolves` the way
1109
+ // graph rules resolve theirs, `exports`/`members` names the way `surface` and
1110
+ // `members` rules do, and the rest carried as written. The `fn` string is
1111
+ // kept verbatim for the loader, which holds the function it names.
1112
+ const lowerCampaign = (
1113
+ campaign: CampaignSpec,
1114
+ aliases: Readonly<Record<string, string>>,
1115
+ ): CampaignRule => {
1116
+ const asPath = (glob: string): string =>
1117
+ prefixed(
1118
+ globToRegexSource(expandAliases(glob, aliases), {}, { declaring: false, nextGroup: 1 })
1119
+ .source,
1120
+ );
1121
+ const asName = (globs: string | ReadonlyArray<string>): ReadonlyArray<string> =>
1122
+ globsOf(globs).map((one) =>
1123
+ anchored(globToRegexSource(one, {}, { declaring: false, nextGroup: 1 }).source),
1124
+ );
1125
+ const name = `campaign/${campaign.id}`;
1126
+
1127
+ const lower = (detector: DetectorSpec): Detector => {
1128
+ if ("all" in detector) return { all: detector.all.map(lower) };
1129
+ if ("any" in detector) return { any: detector.any.map(lower) };
1130
+ if ("not" in detector) return { not: lower(detector.not) };
1131
+ if ("path" in detector) {
1132
+ const { convention, ...rest } = detector.path;
1133
+ const conventionSource =
1134
+ convention === undefined
1135
+ ? undefined
1136
+ : typeof convention === "string"
1137
+ ? CONVENTIONS[convention]?.source
1138
+ : convention.regex;
1139
+ if (convention !== undefined && conventionSource === undefined) {
1140
+ throw new Error(`campaign "${campaign.id}" names an unknown convention "${convention}".`);
1141
+ }
1142
+ if (conventionSource !== undefined && rest.subject === undefined) {
1143
+ throw new Error(
1144
+ `campaign "${campaign.id}" states a path convention with no \`subject\`: say which ` +
1145
+ `capture group of \`file\` holds the name the convention is about.`,
1146
+ );
1147
+ }
1148
+ return {
1149
+ path: {
1150
+ file: globsOf(rest.file),
1151
+ ...(rest.fileNot === undefined ? {} : { fileNot: globsOf(rest.fileNot) }),
1152
+ ...(rest.subject === undefined ? {} : { subject: rest.subject }),
1153
+ ...(conventionSource === undefined ? {} : { convention: conventionSource }),
1154
+ },
1155
+ };
1156
+ }
1157
+ if ("imports" in detector) {
1158
+ const { resolves, symbols } = detector.imports;
1159
+ return {
1160
+ imports: {
1161
+ resolves: typeof resolves === "string" ? asPath(resolves) : resolves,
1162
+ ...(symbols === undefined ? {} : { symbols: [...symbols] }),
1163
+ },
1164
+ };
1165
+ }
1166
+ if ("exports" in detector) {
1167
+ const term = detector.exports;
1168
+ return {
1169
+ exports: {
1170
+ ...(term.name === undefined ? {} : { name: asName(term.name) }),
1171
+ ...(term.kinds === undefined ? {} : { kinds: [...term.kinds] }),
1172
+ ...(term.declares === undefined ? {} : { declares: [...term.declares] }),
1173
+ ...(term.reexport === undefined ? {} : { reexport: term.reexport }),
1174
+ },
1175
+ };
1176
+ }
1177
+ if ("members" in detector) {
1178
+ const term = detector.members;
1179
+ return {
1180
+ members: {
1181
+ subject: term.subject,
1182
+ ...(term.name === undefined ? {} : { name: asName(term.name) }),
1183
+ ...(term.in === undefined ? {} : { in: asName(term.in) }),
1184
+ ...(term.declares === undefined ? {} : { declares: [...term.declares] }),
1185
+ },
1186
+ };
1187
+ }
1188
+ if ("requires" in detector) return { requires: [...detector.requires] };
1189
+ if ("content" in detector) return { content: { regex: detector.content.regex } };
1190
+ if ("report" in detector) {
1191
+ // Exactly one of `command` and `file`, and a pattern for `regex`: the
1192
+ // manifest schema refused anything else at decode.
1193
+ const term = detector.report;
1194
+ return {
1195
+ report: {
1196
+ ...(term.command === undefined ? {} : { command: globsOf(term.command) }),
1197
+ ...(term.file === undefined ? {} : { file: globsOf(term.file) }),
1198
+ format: term.format,
1199
+ ...(term.pattern === undefined ? {} : { pattern: term.pattern }),
1200
+ ...(term.codes === undefined ? {} : { codes: [...term.codes] }),
1201
+ ...(term.codesNot === undefined ? {} : { codesNot: [...term.codesNot] }),
1202
+ },
1203
+ };
1204
+ }
1205
+ if ("syntax" in detector) {
1206
+ const { where } = detector.syntax;
1207
+ const narrowed =
1208
+ where === undefined
1209
+ ? {}
1210
+ : {
1211
+ where: Object.fromEntries(
1212
+ Object.entries(where).map(([capture, narrowing]) => [
1213
+ capture,
1214
+ {
1215
+ ...(narrowing.regex === undefined ? {} : { regex: narrowing.regex }),
1216
+ ...(narrowing.binding === undefined
1217
+ ? {}
1218
+ : {
1219
+ binding: {
1220
+ resolves:
1221
+ typeof narrowing.binding.resolves === "string"
1222
+ ? asPath(narrowing.binding.resolves)
1223
+ : narrowing.binding.resolves,
1224
+ ...(narrowing.binding.member === undefined
1225
+ ? {}
1226
+ : { member: [...narrowing.binding.member] }),
1227
+ },
1228
+ }),
1229
+ },
1230
+ ]),
1231
+ ),
1232
+ };
1233
+ return { syntax: { rule: syntaxRuleOf(detector.syntax), ...narrowed } };
1234
+ }
1235
+ return { fn: detector.fn };
1236
+ };
1237
+
1238
+ // A probe without a source proves only the path; a detector that reads the
1239
+ // file needs the file. Refused here, with the term named, rather than at
1240
+ // load as a probe that mysteriously never fires.
1241
+ const leaves = leafTermsOf(campaign.detect);
1242
+ const needsSource = TERMS_NEEDING_SOURCE.filter((term) => leaves.includes(term));
1243
+ const probes = [...campaign.probes.fires, ...(campaign.probes.ignores ?? [])];
1244
+ const sourceless = probes.find((probe) => probe.source === undefined);
1245
+ if (needsSource.length > 0 && sourceless !== undefined) {
1246
+ throw new Error(
1247
+ `campaign "${campaign.id}" has a probe (${sourceless.path}) with no \`source\`, and its ` +
1248
+ `detector holds a ${needsSource.map((term) => `\`${term}\``).join(", ")} term, which ` +
1249
+ `a path alone cannot exercise. Give every probe a source.`,
1250
+ );
1251
+ }
1252
+ if (campaign.probes.fires.length === 0) {
1253
+ throw new Error(
1254
+ `campaign "${campaign.id}" carries no \`probes.fires\`. A campaign proves it can fire ` +
1255
+ `the way every rule does; write at least one source it must report.`,
1256
+ );
1257
+ }
1258
+
1259
+ return {
1260
+ name,
1261
+ id: campaign.id,
1262
+ ...(campaign.title === undefined ? {} : { title: campaign.title }),
1263
+ message: campaign.how,
1264
+ why: campaign.why,
1265
+ ...(campaign.owner === undefined ? {} : { owner: campaign.owner }),
1266
+ scope: globsOf(campaign.scope).map(asPath),
1267
+ unit: campaign.unit,
1268
+ detect: lower(campaign.detect),
1269
+ probes: { fires: [...campaign.probes.fires], ignores: [...(campaign.probes.ignores ?? [])] },
1270
+ staleAfter: durationMs(campaign.staleAfter),
1271
+ onComplete: campaign.onComplete ?? "keep",
1272
+ };
1273
+ };
@@ -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;