@goodbones/core 0.1.0-beta.6 → 0.1.0-beta.8

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 (108) hide show
  1. package/build/dts/core/campaigns.d.ts +140 -0
  2. package/build/dts/core/campaigns.d.ts.map +1 -0
  3. package/build/dts/core/ledger.d.ts +61 -0
  4. package/build/dts/core/ledger.d.ts.map +1 -0
  5. package/build/dts/core/structure.d.ts +1 -0
  6. package/build/dts/core/structure.d.ts.map +1 -1
  7. package/build/dts/domain/architecture-config.d.ts +309 -1
  8. package/build/dts/domain/architecture-config.d.ts.map +1 -1
  9. package/build/dts/domain/facts.d.ts +1 -0
  10. package/build/dts/domain/facts.d.ts.map +1 -1
  11. package/build/dts/domain/report.d.ts +15 -0
  12. package/build/dts/domain/report.d.ts.map +1 -0
  13. package/build/dts/domain/snapshot.d.ts +55 -3
  14. package/build/dts/domain/snapshot.d.ts.map +1 -1
  15. package/build/dts/domain/violation.d.ts +1 -1
  16. package/build/dts/domain/violation.d.ts.map +1 -1
  17. package/build/dts/index.d.ts +11 -3
  18. package/build/dts/index.d.ts.map +1 -1
  19. package/build/dts/infrastructure/campaign-functions.d.ts +7 -0
  20. package/build/dts/infrastructure/campaign-functions.d.ts.map +1 -0
  21. package/build/dts/infrastructure/report-source-fake.d.ts +4 -0
  22. package/build/dts/infrastructure/report-source-fake.d.ts.map +1 -0
  23. package/build/dts/infrastructure/report-source-live.d.ts +3 -0
  24. package/build/dts/infrastructure/report-source-live.d.ts.map +1 -0
  25. package/build/dts/infrastructure/syntax-matcher-fake.d.ts +10 -0
  26. package/build/dts/infrastructure/syntax-matcher-fake.d.ts.map +1 -0
  27. package/build/dts/load/policy.d.ts +14 -0
  28. package/build/dts/load/policy.d.ts.map +1 -1
  29. package/build/dts/manifest/compile.d.ts +2 -1
  30. package/build/dts/manifest/compile.d.ts.map +1 -1
  31. package/build/dts/manifest/json-schema.d.ts.map +1 -1
  32. package/build/dts/manifest/manifest.d.ts +362 -0
  33. package/build/dts/manifest/manifest.d.ts.map +1 -1
  34. package/build/dts/ports/campaign-predicate.d.ts +24 -0
  35. package/build/dts/ports/campaign-predicate.d.ts.map +1 -0
  36. package/build/dts/ports/language.d.ts +2 -0
  37. package/build/dts/ports/language.d.ts.map +1 -1
  38. package/build/dts/ports/report-source.d.ts +12 -0
  39. package/build/dts/ports/report-source.d.ts.map +1 -0
  40. package/build/dts/ports/syntax-matcher.d.ts +21 -0
  41. package/build/dts/ports/syntax-matcher.d.ts.map +1 -0
  42. package/build/dts/testing.d.ts +2 -0
  43. package/build/dts/testing.d.ts.map +1 -1
  44. package/build/esm/core/campaigns.js +732 -0
  45. package/build/esm/core/campaigns.js.map +1 -0
  46. package/build/esm/core/ledger.js +171 -0
  47. package/build/esm/core/ledger.js.map +1 -0
  48. package/build/esm/core/structure.js +5 -2
  49. package/build/esm/core/structure.js.map +1 -1
  50. package/build/esm/domain/architecture-config.js +133 -1
  51. package/build/esm/domain/architecture-config.js.map +1 -1
  52. package/build/esm/domain/report.js +151 -0
  53. package/build/esm/domain/report.js.map +1 -0
  54. package/build/esm/domain/snapshot.js +21 -0
  55. package/build/esm/domain/snapshot.js.map +1 -1
  56. package/build/esm/domain/violation.js.map +1 -1
  57. package/build/esm/index.js +7 -1
  58. package/build/esm/index.js.map +1 -1
  59. package/build/esm/infrastructure/campaign-functions.js +67 -0
  60. package/build/esm/infrastructure/campaign-functions.js.map +1 -0
  61. package/build/esm/infrastructure/report-source-fake.js +6 -0
  62. package/build/esm/infrastructure/report-source-fake.js.map +1 -0
  63. package/build/esm/infrastructure/report-source-live.js +57 -0
  64. package/build/esm/infrastructure/report-source-live.js.map +1 -0
  65. package/build/esm/infrastructure/syntax-matcher-fake.js +33 -0
  66. package/build/esm/infrastructure/syntax-matcher-fake.js.map +1 -0
  67. package/build/esm/load/policy.js +121 -1
  68. package/build/esm/load/policy.js.map +1 -1
  69. package/build/esm/manifest/compile.js +186 -1
  70. package/build/esm/manifest/compile.js.map +1 -1
  71. package/build/esm/manifest/json-schema.js +7 -3
  72. package/build/esm/manifest/json-schema.js.map +1 -1
  73. package/build/esm/manifest/manifest.js +148 -1
  74. package/build/esm/manifest/manifest.js.map +1 -1
  75. package/build/esm/ports/campaign-predicate.js +2 -0
  76. package/build/esm/ports/campaign-predicate.js.map +1 -0
  77. package/build/esm/ports/report-source.js +2 -0
  78. package/build/esm/ports/report-source.js.map +1 -0
  79. package/build/esm/ports/syntax-matcher.js +12 -0
  80. package/build/esm/ports/syntax-matcher.js.map +1 -0
  81. package/build/esm/testing.js +2 -0
  82. package/build/esm/testing.js.map +1 -1
  83. package/package.json +1 -1
  84. package/schema/architecture-node.schema.json +843 -0
  85. package/schema/architecture.schema.json +1253 -0
  86. package/schema/conformance.schema.json +118 -3
  87. package/src/core/campaigns.ts +1006 -0
  88. package/src/core/ledger.ts +242 -0
  89. package/src/core/structure.ts +10 -5
  90. package/src/domain/architecture-config.ts +178 -1
  91. package/src/domain/facts.ts +9 -4
  92. package/src/domain/report.ts +186 -0
  93. package/src/domain/snapshot.ts +52 -0
  94. package/src/domain/violation.ts +4 -1
  95. package/src/index.ts +74 -0
  96. package/src/infrastructure/campaign-functions.ts +98 -0
  97. package/src/infrastructure/report-source-fake.ts +10 -0
  98. package/src/infrastructure/report-source-live.ts +66 -0
  99. package/src/infrastructure/syntax-matcher-fake.ts +51 -0
  100. package/src/load/policy.ts +187 -1
  101. package/src/manifest/compile.ts +221 -0
  102. package/src/manifest/json-schema.ts +7 -3
  103. package/src/manifest/manifest.ts +192 -1
  104. package/src/ports/campaign-predicate.ts +30 -0
  105. package/src/ports/language.ts +6 -0
  106. package/src/ports/report-source.ts +21 -0
  107. package/src/ports/syntax-matcher.ts +42 -0
  108. package/src/testing.ts +2 -0
@@ -7,6 +7,13 @@ import {
7
7
  EMPTY_BASELINE,
8
8
  makeBaselineFilter,
9
9
  } from "../core/baseline.js";
10
+ import {
11
+ type CampaignPredicate,
12
+ campaignsFailingTheirProbe,
13
+ compileCampaignRules,
14
+ type CompiledCampaign,
15
+ leafTermsOf,
16
+ } from "../core/campaigns.js";
10
17
  import {
11
18
  type CompiledExportRule,
12
19
  compileExportRules,
@@ -22,6 +29,7 @@ import {
22
29
  compileImportRules,
23
30
  rulesFailingTheirProbe,
24
31
  } from "../core/imports.js";
32
+ import { decodeLedger, type Ledger } from "../core/ledger.js";
25
33
  import {
26
34
  type CompiledMemberRule,
27
35
  compileMemberRules,
@@ -46,11 +54,13 @@ import {
46
54
  import type { SourceFacts } from "../domain/facts.js";
47
55
  import type { ManifestLocator } from "../domain/manifest-location.js";
48
56
  import { type LoweredRules, lowerManifest } from "../manifest/compile.js";
49
- import { decodeManifest, type Manifest } from "../manifest/manifest.js";
57
+ import { decodeManifest, DEFAULT_LEDGER_DIR, type Manifest } from "../manifest/manifest.js";
50
58
  import type { FactExtractor } from "../ports/fact-extractor.js";
51
59
  import type { FileSystem } from "../ports/file-system.js";
52
60
  import type { Language } from "../ports/language.js";
53
61
  import type { ModuleResolver } from "../ports/module-resolver.js";
62
+ import { NO_REPORTS, type ReportSource } from "../ports/report-source.js";
63
+ import type { SyntaxMatcher } from "../ports/syntax-matcher.js";
54
64
 
55
65
  // A manifest, read by a host, turned into the policy both adapters evaluate.
56
66
  // Decoding, lowering, compiling and probing happen here, once, with whatever
@@ -70,6 +80,19 @@ export type LoadedPolicy = {
70
80
  readonly graph: CompiledGraph;
71
81
  readonly adoption: LoweredRules["adoption"];
72
82
  readonly structure: CompiledStructure;
83
+ // Evaluated by both hosts, one file at a time; the ledgers say which hits
84
+ // are already counted. A campaign with no ledger is one `campaigns init`
85
+ // has not been run for, and `check` says so.
86
+ readonly campaignRules: ReadonlyArray<CompiledCampaign>;
87
+ readonly ledgers: ReadonlyMap<string, Ledger>;
88
+ // Repo-relative; `<ledgerDir>/<id>.json` is a campaign's ledger.
89
+ readonly ledgerDir: string;
90
+ // The predicate functions the host imported for the `fn` terms.
91
+ readonly functions: ReadonlyMap<string, CampaignPredicate>;
92
+ // Answers the `report` terms: the host's live source, or nothing.
93
+ readonly reports: ReportSource;
94
+ // The clock the campaigns are judged by — stalls, timestamps.
95
+ readonly now: number;
73
96
  readonly fileSystem: FileSystem;
74
97
  // The language packs this policy is evaluated with. The walker takes its
75
98
  // extensions from them, and lowering the shape of its probes.
@@ -82,6 +105,9 @@ export type LoadedPolicy = {
82
105
  // Routes each file to the extractor of the language whose scope covers it.
83
106
  // The CLI reads every file through this; the plugin reads oxlint's tree.
84
107
  readonly extractor: FactExtractor;
108
+ // Routes each file to the syntax matcher of the language whose scope
109
+ // covers it; `parse` answers `null` for a file whose language has none.
110
+ readonly syntax: SyntaxMatcher;
85
111
  readonly ignoreUnresolved: ReadonlyArray<RegExp>;
86
112
  // Deprecation notices from reading the manifest. The host prints them once.
87
113
  readonly notices: ReadonlyArray<string>;
@@ -99,6 +125,15 @@ export type LoadPolicyInput = {
99
125
  readonly locate?: ManifestLocator | undefined;
100
126
  readonly languages: ReadonlyArray<Language>;
101
127
  readonly fileSystem: FileSystem;
128
+ // The predicate functions the manifest's `fn` terms name, imported by the
129
+ // host before loading — the core touches no module loader.
130
+ readonly functions?: ReadonlyMap<string, CampaignPredicate> | undefined;
131
+ // The host's report source, for the `report` terms. Absent, a term
132
+ // answers nothing — a policy with one is refused, since it would be a
133
+ // campaign that can never fire.
134
+ readonly reports?: ReportSource | undefined;
135
+ // For tests and for a CI that pins the clock; defaults to `Date.now()`.
136
+ readonly now?: number | undefined;
102
137
  };
103
138
 
104
139
  const NOTHING: SourceFacts = {
@@ -144,6 +179,20 @@ const routesOf = (
144
179
  const routeFor = (routes: ReadonlyArray<Route>, file: string): Route | undefined =>
145
180
  routes.find((route) => route.matches.test(file));
146
181
 
182
+ const referencedFunctions = (detect: CompiledCampaign["detect"]): ReadonlyArray<string> => {
183
+ switch (detect.kind) {
184
+ case "all":
185
+ case "any":
186
+ return detect.terms.flatMap(referencedFunctions);
187
+ case "not":
188
+ return referencedFunctions(detect.term);
189
+ case "fn":
190
+ return [detect.name];
191
+ default:
192
+ return [];
193
+ }
194
+ };
195
+
147
196
  // One resolver per scope, built by the scope's language, behind one port that
148
197
  // picks the scope by the importing file.
149
198
  const makeRouter = (
@@ -184,6 +233,58 @@ const makeRoutingExtractor = (routes: ReadonlyArray<Route>): FactExtractor => ({
184
233
  routeFor(routes, file)?.language.extractor.factsOf(file, text) ?? NOTHING,
185
234
  });
186
235
 
236
+ // One matcher per scope, behind one port that picks the scope by the file. A
237
+ // language without one parses nothing, which a `syntax` term reads as no
238
+ // matches — and which the probe check below refuses for a campaign that
239
+ // depends on it.
240
+ const makeRoutingMatcher = (routes: ReadonlyArray<Route>): SyntaxMatcher => ({
241
+ parse: (file, text) => routeFor(routes, file)?.language.syntax?.parse(file, text) ?? null,
242
+ });
243
+
244
+ // The ledgers, read through the port. An absent file is a campaign with no
245
+ // ledger yet; a malformed one is refused, since reading it as empty would
246
+ // report every hit as unrecorded growth.
247
+ const readLedgers = (
248
+ configPath: string,
249
+ fileSystem: FileSystem,
250
+ ledgerDir: string,
251
+ campaigns: ReadonlyArray<CompiledCampaign>,
252
+ ): Result.Result<ReadonlyMap<string, Ledger>, ConfigInvalid> => {
253
+ const ledgers = new Map<string, Ledger>();
254
+ for (const campaign of campaigns) {
255
+ const at = `${ledgerDir}/${campaign.id}.json`;
256
+ const text = fileSystem.readText(at);
257
+ if (text === null) continue;
258
+ let raw: unknown;
259
+ try {
260
+ raw = JSON.parse(text);
261
+ } catch (cause) {
262
+ return Result.fail(
263
+ new ConfigInvalid({ configPath, detail: `the ledger ${at} is not JSON: ${String(cause)}` }),
264
+ );
265
+ }
266
+ const decoded = decodeLedger(raw);
267
+ if (Result.isFailure(decoded)) {
268
+ return Result.fail(
269
+ new ConfigInvalid({
270
+ configPath,
271
+ detail: `the ledger ${at} does not decode:\n${decoded.failure}`,
272
+ }),
273
+ );
274
+ }
275
+ if (decoded.success.id !== campaign.id) {
276
+ return Result.fail(
277
+ new ConfigInvalid({
278
+ configPath,
279
+ detail: `the ledger ${at} says it belongs to "${decoded.success.id}", not "${campaign.id}".`,
280
+ }),
281
+ );
282
+ }
283
+ ledgers.set(campaign.id, decoded.success);
284
+ }
285
+ return Result.succeed(ledgers);
286
+ };
287
+
187
288
  // The baseline is read through the port, so this tier touches no file itself.
188
289
  // An absent or unreadable one carries nothing, which is the safe direction:
189
290
  // every violation reports.
@@ -280,6 +381,67 @@ export const loadPolicy = (
280
381
  const structure = compileStructure(rules.structure);
281
382
  if (Result.isFailure(structure)) return Result.fail(structure.failure);
282
383
 
384
+ const campaignRules = compileCampaignRules(rules.campaigns);
385
+ if (Result.isFailure(campaignRules)) return Result.fail(campaignRules.failure);
386
+
387
+ // A `fn` term names a function the host was to import. One it did not is
388
+ // a term that would answer false for every file, and is refused.
389
+ const functions = input.functions ?? new Map<string, CampaignPredicate>();
390
+ const missing = campaignRules.success.flatMap((rule) =>
391
+ referencedFunctions(rule.detect).filter((name) => !functions.has(name)),
392
+ );
393
+ if (missing.length > 0) {
394
+ return Result.fail(
395
+ new ConfigInvalid({
396
+ configPath,
397
+ detail:
398
+ `these campaigns name a predicate function the host did not load: ` +
399
+ `${[...new Set(missing)].join(", ")}. A \`fn\` term is \`module#export\`, resolved ` +
400
+ `relative to the manifest, and the export must be a function.`,
401
+ }),
402
+ );
403
+ }
404
+
405
+ // A `report` term is answered by the host's source. Without one every
406
+ // such campaign would report nothing and look complete.
407
+ const reporting = campaignRules.success.filter((rule) =>
408
+ leafTermsOf(rule.detect).includes("report"),
409
+ );
410
+ if (input.reports === undefined && reporting.length > 0) {
411
+ return Result.fail(
412
+ new ConfigInvalid({
413
+ configPath,
414
+ detail:
415
+ `these campaigns hold a \`report\` term and the host provided no report source: ` +
416
+ `${reporting.map((rule) => rule.name).join(", ")}.`,
417
+ }),
418
+ );
419
+ }
420
+
421
+ // A `syntax` term needs a matcher for the language of every file in its
422
+ // scope. The probes are the files the campaign is proven on; a probe whose
423
+ // language has none would pass no probe and mean nothing.
424
+ const syntaxless = campaignRules.success.flatMap((rule) => {
425
+ if (!leafTermsOf(rule.detect).includes("syntax")) return [];
426
+ return [...rule.probes.fires, ...rule.probes.ignores].flatMap((probe) => {
427
+ const route = routeFor(routes.success, probe.path);
428
+ return route === undefined || route.language.syntax !== undefined
429
+ ? []
430
+ : [`${rule.name} (${probe.path}: ${route.language.id} carries no syntax matcher)`];
431
+ });
432
+ });
433
+ if (syntaxless.length > 0) {
434
+ return Result.fail(
435
+ new ConfigInvalid({
436
+ configPath,
437
+ detail:
438
+ `these campaigns hold a \`syntax\` term in a scope whose language has no syntax ` +
439
+ `matcher: ${[...new Set(syntaxless)].join(", ")}. Compose the language pack with ` +
440
+ `one (\`@goodbones/ast-grep\` for TypeScript), or write the detector without it.`,
441
+ }),
442
+ );
443
+ }
444
+
283
445
  // A probe carrying a source snippet is parsed by the extractor of the
284
446
  // language whose scope covers the probe's file — the same extractor the CLI
285
447
  // reads that file through. The plugin reads through oxlint's tree instead,
@@ -304,6 +466,19 @@ export const loadPolicy = (
304
466
  ),
305
467
  ...structureRulesFailingTheirProbe(structure.success),
306
468
  ...graphRulesFailingTheirProbe(graph.success),
469
+ ...campaignsFailingTheirProbe(
470
+ campaignRules.success,
471
+ extractor,
472
+ (file) => routeFor(routes.success, file)?.language.syntax ?? null,
473
+ functions,
474
+ ).map((failed) =>
475
+ failed.outOfScope === true
476
+ ? `${failed.name} (its probe ${failed.probe.path} is outside the campaign's own scope)`
477
+ : failed.expected === "fires"
478
+ ? `${failed.name} (fires probe ${failed.probe.path} did not fire)`
479
+ : `${failed.name} (ignores probe ${failed.probe.path} fired` +
480
+ (failed.admittedBy === undefined ? ")" : `, admitted by \`${failed.admittedBy}\`)`),
481
+ ),
307
482
  ];
308
483
  if (vacuous.length > 0) {
309
484
  return Result.fail(
@@ -325,6 +500,10 @@ export const loadPolicy = (
325
500
  const resolver = makeRouter(configPath, repoRoot, routes.success);
326
501
  if (Result.isFailure(resolver)) return Result.fail(resolver.failure);
327
502
 
503
+ const ledgerDir = config.ledger ?? DEFAULT_LEDGER_DIR;
504
+ const ledgers = readLedgers(configPath, fileSystem, ledgerDir, campaignRules.success);
505
+ if (Result.isFailure(ledgers)) return Result.fail(ledgers.failure);
506
+
328
507
  return Result.succeed({
329
508
  repoRoot,
330
509
  config,
@@ -335,11 +514,18 @@ export const loadPolicy = (
335
514
  graph: graph.success,
336
515
  adoption: rules.adoption,
337
516
  structure: structure.success,
517
+ campaignRules: campaignRules.success,
518
+ ledgers: ledgers.success,
519
+ ledgerDir,
520
+ functions,
521
+ reports: input.reports ?? NO_REPORTS,
522
+ now: input.now ?? Date.now(),
338
523
  fileSystem,
339
524
  languages,
340
525
  baseline: makeBaselineFilter(readBaseline(fileSystem, config.baseline)),
341
526
  resolver: resolver.success,
342
527
  extractor,
528
+ syntax: makeRoutingMatcher(routes.success),
343
529
  ignoreUnresolved: (config.resolve.ignoreUnresolved ?? []).map(
344
530
  (pattern: string) => new RegExp(pattern),
345
531
  ),
@@ -1,5 +1,7 @@
1
1
  import {
2
2
  type Allowance,
3
+ type CampaignRule,
4
+ type Detector,
3
5
  type ExportRule,
4
6
  type GraphConfig,
5
7
  type ImportRule,
@@ -15,11 +17,15 @@ import type { ManifestPath } from "../domain/manifest-location.js";
15
17
  import { fragmentOf, type Substitution } from "./expand.js";
16
18
  import { anchored, type CaptureIndex, globToRegexSource, prefixed } from "./glob.js";
17
19
  import {
20
+ type CampaignSpec,
21
+ type DetectorSpec,
22
+ durationMs,
18
23
  globsOf,
19
24
  type ImportsSpec,
20
25
  type Manifest,
21
26
  type ManifestNode,
22
27
  type NamingSpec,
28
+ type SyntaxTermSpec,
23
29
  } from "./manifest.js";
24
30
 
25
31
  // The manifest is the authoring surface; these flat rules are the machine's.
@@ -31,6 +37,8 @@ export type LoweredRules = {
31
37
  readonly members: ReadonlyArray<MemberRule>;
32
38
  readonly surface: ReadonlyArray<SurfaceRule>;
33
39
  readonly graph: GraphConfig;
40
+ // Campaigns pass through by their own id, as top-level `exports` rules do.
41
+ readonly campaigns: ReadonlyArray<CampaignRule>;
34
42
  // The nodes that said "not tightened yet", by name — the adoption backlog,
35
43
  // and what `limits` puts a ceiling on.
36
44
  readonly adoption: {
@@ -1050,13 +1058,226 @@ export const lowerManifest = (
1050
1058
  })),
1051
1059
  };
1052
1060
 
1061
+ const campaigns = (manifest.campaigns ?? []).map((campaign) => lowerCampaign(campaign, aliases));
1062
+
1053
1063
  return {
1054
1064
  imports,
1055
1065
  exports,
1056
1066
  members,
1057
1067
  surface,
1058
1068
  graph,
1069
+ campaigns,
1059
1070
  adoption: { unrestricted: unrestrictedNodes, partial: partialNodes },
1060
1071
  structure: { roots, folders, parity, naming: namingRules },
1061
1072
  };
1062
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
+ const term = detector.report;
1192
+ if ((term.command === undefined) === (term.file === undefined)) {
1193
+ throw new Error(
1194
+ `campaign "${campaign.id}" has a report term that must name exactly one of ` +
1195
+ `\`command\` (a program to run) and \`file\` (a report already written).`,
1196
+ );
1197
+ }
1198
+ if (term.format === "regex" && term.pattern === undefined) {
1199
+ throw new Error(
1200
+ `campaign "${campaign.id}" has a \`regex\` report term with no \`pattern\`: give ` +
1201
+ `one with named groups \`file\` and \`line\`.`,
1202
+ );
1203
+ }
1204
+ return {
1205
+ report: {
1206
+ ...(term.command === undefined ? {} : { command: term.command }),
1207
+ ...(term.file === undefined ? {} : { file: term.file }),
1208
+ format: term.format,
1209
+ ...(term.pattern === undefined ? {} : { pattern: term.pattern }),
1210
+ ...(term.codes === undefined ? {} : { codes: [...term.codes] }),
1211
+ ...(term.codesNot === undefined ? {} : { codesNot: [...term.codesNot] }),
1212
+ },
1213
+ };
1214
+ }
1215
+ if ("syntax" in detector) {
1216
+ const { where } = detector.syntax;
1217
+ const narrowed =
1218
+ where === undefined
1219
+ ? {}
1220
+ : {
1221
+ where: Object.fromEntries(
1222
+ Object.entries(where).map(([capture, narrowing]) => [
1223
+ capture,
1224
+ {
1225
+ ...(narrowing.regex === undefined ? {} : { regex: narrowing.regex }),
1226
+ ...(narrowing.binding === undefined
1227
+ ? {}
1228
+ : {
1229
+ binding: {
1230
+ resolves:
1231
+ typeof narrowing.binding.resolves === "string"
1232
+ ? asPath(narrowing.binding.resolves)
1233
+ : narrowing.binding.resolves,
1234
+ ...(narrowing.binding.member === undefined
1235
+ ? {}
1236
+ : { member: [...narrowing.binding.member] }),
1237
+ },
1238
+ }),
1239
+ },
1240
+ ]),
1241
+ ),
1242
+ };
1243
+ return { syntax: { rule: syntaxRuleOf(detector.syntax), ...narrowed } };
1244
+ }
1245
+ return { fn: detector.fn };
1246
+ };
1247
+
1248
+ // A probe without a source proves only the path; a detector that reads the
1249
+ // file needs the file. Refused here, with the term named, rather than at
1250
+ // load as a probe that mysteriously never fires.
1251
+ const leaves = leafTermsOf(campaign.detect);
1252
+ const needsSource = TERMS_NEEDING_SOURCE.filter((term) => leaves.includes(term));
1253
+ const probes = [...campaign.probes.fires, ...(campaign.probes.ignores ?? [])];
1254
+ const sourceless = probes.find((probe) => probe.source === undefined);
1255
+ if (needsSource.length > 0 && sourceless !== undefined) {
1256
+ throw new Error(
1257
+ `campaign "${campaign.id}" has a probe (${sourceless.path}) with no \`source\`, and its ` +
1258
+ `detector holds a ${needsSource.map((term) => `\`${term}\``).join(", ")} term, which ` +
1259
+ `a path alone cannot exercise. Give every probe a source.`,
1260
+ );
1261
+ }
1262
+ if (campaign.probes.fires.length === 0) {
1263
+ throw new Error(
1264
+ `campaign "${campaign.id}" carries no \`probes.fires\`. A campaign proves it can fire ` +
1265
+ `the way every rule does; write at least one source it must report.`,
1266
+ );
1267
+ }
1268
+
1269
+ return {
1270
+ name,
1271
+ id: campaign.id,
1272
+ ...(campaign.title === undefined ? {} : { title: campaign.title }),
1273
+ message: campaign.how,
1274
+ why: campaign.why,
1275
+ ...(campaign.owner === undefined ? {} : { owner: campaign.owner }),
1276
+ scope: globsOf(campaign.scope).map(asPath),
1277
+ unit: campaign.unit,
1278
+ detect: lower(campaign.detect),
1279
+ probes: { fires: [...campaign.probes.fires], ignores: [...(campaign.probes.ignores ?? [])] },
1280
+ staleAfter: durationMs(campaign.staleAfter),
1281
+ onComplete: campaign.onComplete ?? "keep",
1282
+ };
1283
+ };
@@ -31,9 +31,13 @@ const isObject = (value: JsonValue): value is JsonObject =>
31
31
 
32
32
  const isList = (value: JsonValue): value is ReadonlyArray<JsonValue> => Array.isArray(value);
33
33
 
34
- // The generator names the recursive node after its own internal wrapper. A
35
- // stable name is what a `$ref` in an error message or a docs page can point at.
36
- const DEFINITION_NAMES: Readonly<Record<string, string>> = { Suspend_: "ManifestNode" };
34
+ // The generator names the recursive schemas after its own internal wrappers.
35
+ // A stable name is what a `$ref` in an error message or a docs page can
36
+ // point at: the tree's node, and a campaign's detector.
37
+ const DEFINITION_NAMES: Readonly<Record<string, string>> = {
38
+ Suspend_: "ManifestNode",
39
+ Union_: "Detector",
40
+ };
37
41
 
38
42
  const USE_REFERENCE = "#/$defs/Use";
39
43
  const INCLUDE_REFERENCE = "#/$defs/Include";