@goodbones/campaigns 0.1.1-beta.1 → 0.1.1-beta.2

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 (55) hide show
  1. package/build/dts/core/campaign-state.d.ts +3 -1
  2. package/build/dts/core/campaign-state.d.ts.map +1 -1
  3. package/build/dts/core/campaigns.d.ts +46 -2
  4. package/build/dts/core/campaigns.d.ts.map +1 -1
  5. package/build/dts/core/ledger.d.ts +58 -2
  6. package/build/dts/core/ledger.d.ts.map +1 -1
  7. package/build/dts/domain/config.d.ts +204 -2
  8. package/build/dts/domain/config.d.ts.map +1 -1
  9. package/build/dts/host/campaigns.d.ts +34 -0
  10. package/build/dts/host/campaigns.d.ts.map +1 -1
  11. package/build/dts/host/measure-command.d.ts +3 -0
  12. package/build/dts/host/measure-command.d.ts.map +1 -0
  13. package/build/dts/index.d.ts +5 -4
  14. package/build/dts/index.d.ts.map +1 -1
  15. package/build/dts/load/extension.d.ts +2 -1
  16. package/build/dts/load/extension.d.ts.map +1 -1
  17. package/build/dts/manifest/lower.d.ts.map +1 -1
  18. package/build/dts/manifest/spec.d.ts +209 -3
  19. package/build/dts/manifest/spec.d.ts.map +1 -1
  20. package/build/dts/ports/campaign-measure.d.ts +3 -0
  21. package/build/dts/ports/campaign-measure.d.ts.map +1 -0
  22. package/build/esm/core/campaign-state.js +35 -2
  23. package/build/esm/core/campaign-state.js.map +1 -1
  24. package/build/esm/core/campaigns.js +177 -2
  25. package/build/esm/core/campaigns.js.map +1 -1
  26. package/build/esm/core/ledger.js +215 -0
  27. package/build/esm/core/ledger.js.map +1 -1
  28. package/build/esm/domain/config.js +40 -4
  29. package/build/esm/domain/config.js.map +1 -1
  30. package/build/esm/host/campaigns.js +421 -28
  31. package/build/esm/host/campaigns.js.map +1 -1
  32. package/build/esm/host/measure-command.js +35 -0
  33. package/build/esm/host/measure-command.js.map +1 -0
  34. package/build/esm/index.js +2 -2
  35. package/build/esm/index.js.map +1 -1
  36. package/build/esm/load/extension.js +57 -13
  37. package/build/esm/load/extension.js.map +1 -1
  38. package/build/esm/manifest/lower.js +68 -1
  39. package/build/esm/manifest/lower.js.map +1 -1
  40. package/build/esm/manifest/spec.js +85 -5
  41. package/build/esm/manifest/spec.js.map +1 -1
  42. package/build/esm/ports/campaign-measure.js +2 -0
  43. package/build/esm/ports/campaign-measure.js.map +1 -0
  44. package/package.json +2 -2
  45. package/src/core/campaign-state.ts +49 -2
  46. package/src/core/campaigns.ts +232 -3
  47. package/src/core/ledger.ts +264 -2
  48. package/src/domain/config.ts +46 -4
  49. package/src/host/campaigns.ts +571 -33
  50. package/src/host/measure-command.ts +36 -0
  51. package/src/index.ts +31 -0
  52. package/src/load/extension.ts +70 -6
  53. package/src/manifest/lower.ts +80 -1
  54. package/src/manifest/spec.ts +100 -5
  55. package/src/ports/campaign-measure.ts +11 -0
@@ -0,0 +1,36 @@
1
+ import { spawnSync } from "node:child_process";
2
+
3
+ import { type CompiledMeasure, numberFromOutput } from "../core/campaigns.js";
4
+
5
+ // A `command` measure's number: the command run once from the repository
6
+ // root through the shell, its stdout read whole or through the measure's
7
+ // pattern. Cached per command for the life of the returned function, which
8
+ // the host makes once per evaluation — a number about the repository is
9
+ // one number however many sectors ask. A command that cannot be spawned, or
10
+ // whose output reads as no number, measures `NaN`, and `check` refuses it.
11
+ // The exit status is not read: a tool that exits non-zero when the number
12
+ // is over its own threshold is still a tool that printed the number.
13
+
14
+ const MAX_BUFFER = 64 * 1024 * 1024;
15
+
16
+ export const commandValues = (repoRoot: string): ((measure: CompiledMeasure) => number) => {
17
+ const outputs = new Map<string, string | null>();
18
+ const outputOf = (command: string): string | null => {
19
+ if (outputs.has(command)) return outputs.get(command) ?? null;
20
+ const run = spawnSync(command, {
21
+ cwd: repoRoot,
22
+ shell: true,
23
+ encoding: "utf8",
24
+ maxBuffer: MAX_BUFFER,
25
+ stdio: ["ignore", "pipe", "pipe"],
26
+ });
27
+ const output = run.error === undefined ? run.stdout : null;
28
+ outputs.set(command, output);
29
+ return output;
30
+ };
31
+ return (measure) => {
32
+ if (measure.kind !== "command") return Number.NaN;
33
+ const output = outputOf(measure.command);
34
+ return output === null ? Number.NaN : numberFromOutput(measure, output);
35
+ };
36
+ };
package/src/index.ts CHANGED
@@ -28,12 +28,15 @@ export {
28
28
  compileCampaignRules,
29
29
  type CompiledCampaign,
30
30
  type CompiledDetector,
31
+ type CompiledMeasure,
32
+ type CompiledMeasureSource,
31
33
  type CompiledObjective,
32
34
  type CompiledPerimeter,
33
35
  type CompiledSectorTerm,
34
36
  compileObjective,
35
37
  candidatesOf as detectorCandidatesOf,
36
38
  detectorOf,
39
+ distanceToTarget,
37
40
  evaluateObjective,
38
41
  evaluateObjectives,
39
42
  explainDetector,
@@ -41,40 +44,61 @@ export {
41
44
  type FailedProbe,
42
45
  leafTermsOf,
43
46
  matchKeyOf,
47
+ measureDetectorsOf,
48
+ measureFile,
49
+ type MeasureParts,
44
50
  needsSyntax,
51
+ numberFromOutput,
52
+ perFileMeasuresOf,
45
53
  perFileObjectivesOf,
46
54
  probeInputOf,
47
55
  reportSpecsOf,
56
+ roundMeasure,
48
57
  type TermAnswer,
58
+ valueOfParts,
49
59
  } from "./core/campaigns.js";
50
60
  export {
51
61
  allowedOf,
52
62
  type Attestation,
53
63
  attestedRecord,
64
+ clearedMeasure,
54
65
  clearedOf,
55
66
  clearedSector,
56
67
  closedOf,
68
+ concededMeasure,
57
69
  concededSector,
58
70
  type Concession,
59
71
  type ConcessionRecord,
60
72
  decodeLedger,
73
+ decodeMeasureLedger,
61
74
  decodePlanRecord,
62
75
  decodeSectorRecord,
63
76
  deltaOf,
64
77
  EMPTY_LEDGER,
78
+ EMPTY_MEASURE_LEDGER,
65
79
  EMPTY_SECTOR_RECORD,
66
80
  encodeSectorName,
67
81
  holdoutsOf,
68
82
  initialOf,
69
83
  isComplete,
70
84
  isLegacyLedger,
85
+ isMeasureLedger,
71
86
  isStalled,
72
87
  lastClearedOf,
88
+ lastImprovedOf,
73
89
  type Ledger,
74
90
  ledgerArithmeticHolds,
75
91
  ledgerPathOf,
76
92
  Ledger as LedgerSchema,
77
93
  legacyLedgerPathOf,
94
+ measureArithmeticHolds,
95
+ type MeasureConcession,
96
+ type MeasureLedger,
97
+ measureLedgerArithmeticHolds,
98
+ MeasureLedger as MeasureLedgerSchema,
99
+ measureProgressOf,
100
+ type MeasureSectorLedger,
101
+ measureStandingOf,
78
102
  type Note,
79
103
  NOTE_CAP,
80
104
  NOTE_LENGTH,
@@ -90,12 +114,14 @@ export {
90
114
  rebaselinedSector,
91
115
  reconcileSector,
92
116
  type Reconciliation,
117
+ recordedOf,
93
118
  sectorArithmeticHolds,
94
119
  sectorClockOf,
95
120
  type SectorLedger,
96
121
  type SectorRecord,
97
122
  sectorRecordPathOf,
98
123
  serializeLedger,
124
+ serializeMeasureLedger,
99
125
  serializePlanRecord,
100
126
  serializeSectorRecord,
101
127
  } from "./core/ledger.js";
@@ -143,6 +169,9 @@ export {
143
169
  type CampaignUnit,
144
170
  type Detector,
145
171
  type Holdout,
172
+ type Measure,
173
+ type MeasureDirection,
174
+ type MeasureSource,
146
175
  type ObjectiveRule,
147
176
  type OnTouch,
148
177
  type PerimeterRule,
@@ -236,12 +265,14 @@ export {
236
265
  type DetectorSpec,
237
266
  durationMs,
238
267
  type EndStateSpec,
268
+ type MeasureSourceSpec,
239
269
  type ObjectiveSpec,
240
270
  type PerimeterSpec,
241
271
  type PhaseSpec,
242
272
  type SectorTermSpec,
243
273
  type SyntaxTermSpec,
244
274
  } from "./manifest/spec.js";
275
+ export { type CampaignMeasure } from "./ports/campaign-measure.js";
245
276
  export {
246
277
  type CampaignPredicate,
247
278
  type CampaignPredicateInput,
@@ -28,15 +28,19 @@ import {
28
28
  type CompiledDetector,
29
29
  detectorOf,
30
30
  leafTermsOf,
31
+ measureDetectorsOf,
31
32
  } from "../core/campaigns.js";
32
33
  import {
33
34
  decodeLedger,
35
+ decodeMeasureLedger,
34
36
  decodePlanRecord,
35
37
  decodeSectorRecord,
36
38
  isLegacyLedger,
39
+ isMeasureLedger,
37
40
  type Ledger,
38
41
  ledgerPathOf,
39
42
  legacyLedgerPathOf,
43
+ type MeasureLedger,
40
44
  planPathOf,
41
45
  type PlanRecord,
42
46
  type SectorRecord,
@@ -69,8 +73,10 @@ export type CampaignPolicy = {
69
73
  // has seen, is one `objectives clear` has not been run for, and `check`
70
74
  // says so.
71
75
  readonly campaignRules: ReadonlyArray<CompiledCampaign>;
72
- // Keyed `<campaign>/<objective>`.
76
+ // Keyed `<campaign>/<objective>`: the holdout ledgers, and the scalar
77
+ // objectives' in a map of their own — the same file, a second schema.
73
78
  readonly ledgers: ReadonlyMap<string, Ledger>;
79
+ readonly measureLedgers: ReadonlyMap<string, MeasureLedger>;
74
80
  // Ledgers read from the family's first layout, `<ledgerDir>/<campaign>.json`,
75
81
  // by campaign: `clear` rewrites each in the new layout and removes it.
76
82
  readonly legacyLedgers: ReadonlyMap<string, string>;
@@ -91,6 +97,7 @@ export type CampaignPolicy = {
91
97
  const NOTHING: CampaignPolicy = {
92
98
  campaignRules: [],
93
99
  ledgers: new Map(),
100
+ measureLedgers: new Map(),
94
101
  legacyLedgers: new Map(),
95
102
  sectorRecords: new Map(),
96
103
  plans: new Map(),
@@ -130,18 +137,19 @@ const referencedFunctions = (detect: CompiledDetector): ReadonlyArray<string> =>
130
137
  }
131
138
  };
132
139
 
133
- // Every detector a campaign holds: its objectives' (a `has` term's included)
134
- // and its perimeter's.
140
+ // Every detector a campaign holds: its objectives' (a `has` term's and a
141
+ // measure's report and function sources included) and its perimeter's.
135
142
  const detectorsOf = (rule: CompiledCampaign): ReadonlyArray<CompiledDetector> => [
136
143
  ...rule.objectives.flatMap((objective) => {
137
144
  const detect = detectorOf(objective);
138
- return detect === null ? [] : [detect];
145
+ return [...(detect === null ? [] : [detect]), ...measureDetectorsOf(objective)];
139
146
  }),
140
147
  ...(rule.perimeter?.kind === "match" ? [rule.perimeter.detect] : []),
141
148
  ];
142
149
 
143
150
  type ReadLedgers = {
144
151
  readonly ledgers: ReadonlyMap<string, Ledger>;
152
+ readonly measureLedgers: ReadonlyMap<string, MeasureLedger>;
145
153
  readonly legacy: ReadonlyMap<string, string>;
146
154
  readonly sectorRecords: ReadonlyMap<string, SectorRecord>;
147
155
  readonly plans: ReadonlyMap<string, PlanRecord>;
@@ -176,6 +184,7 @@ const readLedgers = (
176
184
  campaigns: ReadonlyArray<CompiledCampaign>,
177
185
  ): Result.Result<ReadLedgers, ConfigInvalid> => {
178
186
  const ledgers = new Map<string, Ledger>();
187
+ const measureLedgers = new Map<string, MeasureLedger>();
179
188
  const legacy = new Map<string, string>();
180
189
  const sectorRecords = new Map<string, SectorRecord>();
181
190
  const plans = new Map<string, PlanRecord>();
@@ -202,6 +211,57 @@ const readLedgers = (
202
211
  if (raw.success !== null) legacy.set(campaign.id, from);
203
212
  }
204
213
  if (raw.success === null) continue;
214
+ // A scalar objective's ledger is the second schema. Either kind in the
215
+ // other's place is refused by name: read as the wrong one it would be
216
+ // a decode error that says nothing about why.
217
+ if ((objective.measure !== null) !== isMeasureLedger(raw.success)) {
218
+ return Result.fail(
219
+ new ConfigInvalid({
220
+ configPath,
221
+ detail:
222
+ objective.measure !== null
223
+ ? `the ledger ${from} is a holdout ledger, and ${campaign.id}/${objective.id} is a scalar objective. Remove it and run \`objectives clear\` to record the number.`
224
+ : `the ledger ${from} is a scalar objective's, and ${campaign.id}/${objective.id} holds out ${objective.holdout ?? "matches"}. Remove it and run \`objectives clear\`.`,
225
+ }),
226
+ );
227
+ }
228
+ if (objective.measure !== null) {
229
+ const measured = decodeMeasureLedger(raw.success);
230
+ if (Result.isFailure(measured)) {
231
+ return Result.fail(
232
+ new ConfigInvalid({
233
+ configPath,
234
+ detail: `the ledger ${from} does not decode:\n${measured.failure}`,
235
+ }),
236
+ );
237
+ }
238
+ if (
239
+ measured.success.campaign !== campaign.id ||
240
+ measured.success.objective !== objective.id
241
+ ) {
242
+ return Result.fail(
243
+ new ConfigInvalid({
244
+ configPath,
245
+ detail:
246
+ `the ledger ${from} says it belongs to "${measured.success.campaign}/${measured.success.objective}", ` +
247
+ `not "${campaign.id}/${objective.id}".`,
248
+ }),
249
+ );
250
+ }
251
+ if (measured.success.direction !== objective.direction) {
252
+ return Result.fail(
253
+ new ConfigInvalid({
254
+ configPath,
255
+ detail:
256
+ `the ledger ${from} records ${campaign.id}/${objective.id} with \`direction: ${measured.success.direction}\`, ` +
257
+ `and the manifest now says \`${objective.direction}\`. A number that changed which way is better is a new objective: ` +
258
+ `remove the ledger and run \`objectives clear\`.`,
259
+ }),
260
+ );
261
+ }
262
+ measureLedgers.set(ledgerKeyOf(campaign.id, objective.id), measured.success);
263
+ continue;
264
+ }
205
265
  const decoded = decodeLedger(raw.success);
206
266
  if (Result.isFailure(decoded)) {
207
267
  return Result.fail(
@@ -270,7 +330,7 @@ const readLedgers = (
270
330
  plans.set(campaign.id, decoded.success);
271
331
  }
272
332
  }
273
- return Result.succeed({ ledgers, legacy, sectorRecords, plans });
333
+ return Result.succeed({ ledgers, measureLedgers, legacy, sectorRecords, plans });
274
334
  };
275
335
 
276
336
  const load = (
@@ -423,7 +483,10 @@ const load = (
423
483
  ).map((failed) =>
424
484
  failed.outOfScope === true
425
485
  ? `${failed.name} (its probe ${failed.probe.path} is outside the campaign's own scope)`
426
- : failed.expected === "fires"
486
+ : failed.measured !== undefined
487
+ ? `${failed.name} (${failed.expected} probe ${failed.probe.path} measured ${String(failed.measured)}, ` +
488
+ `${failed.expected === "ignores" ? "not 0" : failed.probe.value === undefined ? "not above 0" : `not ${String(failed.probe.value)}`})`
489
+ : failed.expected === "fires"
427
490
  ? `${failed.name} (fires probe ${failed.probe.path} did not fire)`
428
491
  : failed.expected === "end-shape"
429
492
  ? `${failed.name} (no fires probe is a sector in its end shape — one that no objective ` +
@@ -442,6 +505,7 @@ const load = (
442
505
  value: {
443
506
  campaignRules: campaignRules.success,
444
507
  ledgers: ledgers.success.ledgers,
508
+ measureLedgers: ledgers.success.measureLedgers,
445
509
  legacyLedgers: ledgers.success.legacy,
446
510
  sectorRecords: ledgers.success.sectorRecords,
447
511
  plans: ledgers.success.plans,
@@ -17,6 +17,8 @@ import type {
17
17
  CampaignProbe,
18
18
  CampaignRule,
19
19
  Detector,
20
+ Measure,
21
+ MeasureSource,
20
22
  ObjectiveRule,
21
23
  PerimeterRule,
22
24
  PhaseRule,
@@ -28,6 +30,7 @@ import {
28
30
  type CampaignSpec,
29
31
  type DetectorSpec,
30
32
  durationMs,
33
+ type MeasureSourceSpec,
31
34
  type PhaseSpec,
32
35
  type SyntaxTermSpec,
33
36
  } from "./spec.js";
@@ -376,9 +379,66 @@ const lowerCampaign = (
376
379
  campaign: id,
377
380
  message,
378
381
  ...(why === undefined ? {} : { why }),
379
- holdout: spec.holdout,
382
+ ...(spec.holdout === undefined ? {} : { holdout: spec.holdout }),
380
383
  ...(spec.until === undefined ? {} : { until: spec.until }),
381
384
  };
385
+ if (spec.measure !== undefined) {
386
+ const measure = spec.measure;
387
+ if ("command" in measure && campaign.perimeter !== undefined) {
388
+ refuse(
389
+ `(${objectiveId}) measures a \`command\`, and the campaign has a perimeter. A ` +
390
+ `command's number is about the repository, not a sector; it belongs to a ` +
391
+ `campaign with no perimeter, whose scope is its one sector.`,
392
+ );
393
+ }
394
+ const source = (one: MeasureSourceSpec): MeasureSource =>
395
+ "report" in one || "fn" in one
396
+ ? (lower(one, objectiveId) as MeasureSource)
397
+ : "lines" in one
398
+ ? { lines: true }
399
+ : { files: true };
400
+ const lowered: Measure =
401
+ "ratio" in measure
402
+ ? {
403
+ ratio: {
404
+ of: source(measure.ratio.of),
405
+ per: source(measure.ratio.per),
406
+ scale: measure.ratio.scale ?? 1,
407
+ },
408
+ }
409
+ : "command" in measure
410
+ ? {
411
+ command: measure.command,
412
+ ...(measure.pattern === undefined ? {} : { pattern: measure.pattern }),
413
+ }
414
+ : source(measure);
415
+ if ("command" in measure && measure.pattern !== undefined) {
416
+ let groups: Readonly<Record<string, unknown>> | undefined;
417
+ try {
418
+ groups = new RegExp(`${measure.pattern}|`).exec("")?.groups;
419
+ } catch (cause) {
420
+ refuse(
421
+ `(${objectiveId}) has a \`pattern\` that is not a regular expression: ${String(cause)}`,
422
+ );
423
+ }
424
+ if (groups === undefined || !("value" in groups)) {
425
+ refuse(
426
+ `(${objectiveId}) has a \`pattern\` with no named group \`value\` to read the number from.`,
427
+ );
428
+ }
429
+ }
430
+ return {
431
+ ...base,
432
+ measure: lowered,
433
+ direction: spec.direction ?? "down",
434
+ tolerance: spec.tolerance ?? 0,
435
+ ...(spec.target === undefined ? {} : { target: spec.target }),
436
+ probes: {
437
+ fires: [...(spec.probes?.fires ?? [])],
438
+ ignores: [...(spec.probes?.ignores ?? [])],
439
+ },
440
+ };
441
+ }
382
442
  if (spec.match !== undefined) {
383
443
  return {
384
444
  ...base,
@@ -469,6 +529,15 @@ const lowerCampaign = (
469
529
  `phase "${phase.id}" names an objective "${objectiveId}" the campaign does not declare.`,
470
530
  );
471
531
  }
532
+ const named = objectives.find((one) => one.id === objectiveId);
533
+ if (named?.measure !== undefined && named.target === undefined) {
534
+ refuse(
535
+ `phase "${phase.id}" names the scalar objective "${objectiveId}", which states no ` +
536
+ `\`target\`. A phase's objectives are what a sector must meet to leave it, and a ` +
537
+ `number with no target is never met: give it one, or leave it out of every phase ` +
538
+ `as a standing measure.`,
539
+ );
540
+ }
472
541
  const already = namedBy.get(objectiveId);
473
542
  if (already !== undefined) {
474
543
  refuse(
@@ -564,6 +633,16 @@ const lowerCampaign = (
564
633
  match: objective?.match ?? null,
565
634
  sector: objective?.sector ?? null,
566
635
  until: objective?.until ?? null,
636
+ // Only when present, so a phase with no scalar hashes as it did
637
+ // before scalars existed and no committed plan reads as changed.
638
+ ...(objective?.measure === undefined
639
+ ? {}
640
+ : {
641
+ measure: objective.measure,
642
+ direction: objective.direction ?? null,
643
+ tolerance: objective.tolerance ?? null,
644
+ target: objective.target ?? null,
645
+ }),
567
646
  };
568
647
  }),
569
648
  }),
@@ -184,6 +184,8 @@ const CampaignProbe = Schema.Struct({
184
184
  edges: Schema.optionalKey(Schema.Record(Schema.String, ImportProbeTarget)),
185
185
  files: Schema.optionalKey(Schema.Array(Schema.String)),
186
186
  report: Schema.optionalKey(Schema.Array(ProbeDiagnostic)),
187
+ // A scalar objective's probe: the number the file contributes.
188
+ value: Schema.optionalKey(Schema.Finite),
187
189
  });
188
190
 
189
191
  const CampaignProbes = Schema.Struct({
@@ -207,29 +209,122 @@ const SectorTermSpec = Schema.Union([
207
209
  Schema.Struct({ oneHost: Globs }),
208
210
  ]);
209
211
 
212
+ // Where a scalar objective's number comes from, per file, summed over a
213
+ // sector: `lines` (non-blank), `files` (one each), the diagnostics a
214
+ // `report` puts on the file, or the number an `fn` returns for it.
215
+ const MeasureSourceSpec = Schema.Union([
216
+ Schema.Struct({ lines: Schema.Literal(true) }),
217
+ Schema.Struct({ files: Schema.Literal(true) }),
218
+ Schema.Struct({ report: ReportTerm }),
219
+ Schema.Struct({ fn: Schema.String }),
220
+ ]);
221
+
222
+ // A scalar objective's number: one source, a `ratio` of two (`scale × Σof /
223
+ // Σper`, `scale` defaulting to 1), or a `command` whose output is the number
224
+ // — whole, or the `value` group of `pattern`. A command measures the
225
+ // repository, not a file, so only a campaign with no perimeter may run one.
226
+ const MeasureSpec = Schema.Union([
227
+ MeasureSourceSpec,
228
+ Schema.Struct({
229
+ ratio: Schema.Struct({
230
+ of: MeasureSourceSpec,
231
+ per: MeasureSourceSpec,
232
+ scale: Schema.optionalKey(Schema.Finite),
233
+ }),
234
+ }),
235
+ Schema.Struct({ command: Schema.String, pattern: Schema.optionalKey(Schema.String) }),
236
+ ]);
237
+
238
+ type MeasureSpec = typeof MeasureSpec.Type;
239
+ export type MeasureSourceSpec = typeof MeasureSourceSpec.Type;
240
+
241
+ // Whether any source of the measure is one a probe must prove: a report or
242
+ // a function, which can drift into measuring nothing.
243
+ const measureNeedsProbe = (measure: MeasureSpec): boolean => {
244
+ const sources = "ratio" in measure ? [measure.ratio.of, measure.ratio.per] : [measure];
245
+ return sources.some((one) => "report" in one || "fn" in one);
246
+ };
247
+
210
248
  // An objective: a detector with a ledger that only shrinks on its own. The
211
249
  // `holdout` says what one ledger entry is; `match` is a per-file detector
212
250
  // and `sector` a term over the sector's files, exactly one of them.
213
- // `until` names the phase at which it stops counting.
251
+ // `until` names the phase at which it stops counting. A scalar objective
252
+ // has a `measure` in place of both, a `direction`, a `tolerance` either side
253
+ // of its record, and the `target` at which it is met.
214
254
  const Objective = Schema.Struct({
215
255
  // What a reader at a holdout does about it — the message every hit
216
256
  // carries; falls back to the campaign's.
217
257
  how: Schema.optionalKey(Schema.String),
218
258
  why: Schema.optionalKey(Schema.String),
219
- holdout: Holdout,
259
+ holdout: Schema.optionalKey(Holdout),
220
260
  match: Schema.optionalKey(DetectorRef),
221
261
  sector: Schema.optionalKey(SectorTermSpec),
262
+ measure: Schema.optionalKey(MeasureSpec),
263
+ direction: Schema.optionalKey(Schema.Literals(["down", "up"])),
264
+ tolerance: Schema.optionalKey(Schema.Finite.check(Schema.isGreaterThanOrEqualTo(0))),
265
+ target: Schema.optionalKey(Schema.Finite),
222
266
  until: Schema.optionalKey(KebabId),
223
267
  probes: Schema.optionalKey(CampaignProbes),
224
268
  }).check(
225
269
  Schema.makeFilter((objective) => {
226
270
  const issues: Array<Schema.FilterIssue> = [];
227
- if ((objective.match === undefined) === (objective.sector === undefined)) {
271
+ const named = [objective.match, objective.sector, objective.measure].filter(
272
+ (one) => one !== undefined,
273
+ ).length;
274
+ if (named !== 1) {
228
275
  issues.push(
229
- "an objective names exactly one of `match` (a detector over each file) and `sector` (a term over the sector's files)",
276
+ "an objective names exactly one of `match` (a detector over each file), `sector` (a term over the sector's files) and `measure` (a number per sector)",
230
277
  );
231
278
  }
232
- if (objective.sector !== undefined && objective.holdout !== "sector") {
279
+ if (objective.measure !== undefined) {
280
+ if (objective.holdout !== undefined) {
281
+ issues.push({
282
+ path: ["holdout"],
283
+ issue: "a `measure` objective holds nothing out: it is a number, so drop `holdout`",
284
+ });
285
+ }
286
+ if (objective.direction === undefined) {
287
+ issues.push({
288
+ path: ["direction"],
289
+ issue:
290
+ "a `measure` objective states which way is better: `direction: down` or `direction: up`",
291
+ });
292
+ }
293
+ if ("command" in objective.measure && objective.probes !== undefined) {
294
+ issues.push({
295
+ path: ["probes"],
296
+ issue:
297
+ "a `command` measure is a number about the repository, which no probe of one file can stand in for: drop `probes`",
298
+ });
299
+ } else if (
300
+ measureNeedsProbe(objective.measure) &&
301
+ (objective.probes?.fires.length ?? 0) === 0
302
+ ) {
303
+ issues.push({
304
+ path: ["probes"],
305
+ issue:
306
+ "a `measure` with a `report` or `fn` source carries `probes.fires`: at least one file it must measure above zero",
307
+ });
308
+ }
309
+ return issues;
310
+ }
311
+ for (const key of ["direction", "tolerance", "target"] as const) {
312
+ if (objective[key] !== undefined) {
313
+ issues.push({ path: [key], issue: `\`${key}\` belongs to a \`measure\` objective` });
314
+ }
315
+ }
316
+ if (objective.holdout === undefined) {
317
+ issues.push({
318
+ path: ["holdout"],
319
+ issue:
320
+ "an objective with `match` or `sector` says what one holdout is: `holdout: file`, `declaration`, `match` or `sector`",
321
+ });
322
+ }
323
+ if (
324
+ objective.sector !== undefined &&
325
+ objective.holdout !== undefined &&
326
+ objective.holdout !== "sector"
327
+ ) {
233
328
  issues.push({
234
329
  path: ["holdout"],
235
330
  issue: "a `sector` objective's holdout is the sector: write `holdout: sector`",
@@ -0,0 +1,11 @@
1
+ import type { CampaignPredicateInput } from "./campaign-predicate.js";
2
+
3
+ // The floor of a scalar objective's measure: a function the repository
4
+ // writes, named from the manifest as `module#export` exactly as a `fn`
5
+ // detector term is, and loaded the same way. It is given what the evaluator
6
+ // has about one file and answers the number that file contributes — a
7
+ // count, a size, a weight — which the host sums over each sector. Anything
8
+ // but a finite number of zero or more is a measure that did not answer, and
9
+ // `check` refuses it. Exported as a public type so a referenced module
10
+ // typechecks on its own.
11
+ export type CampaignMeasure = (input: CampaignPredicateInput) => number;