@goodbones/campaigns 0.1.1-beta.1 → 0.1.1-beta.3
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.
- package/build/dts/core/campaign-state.d.ts +3 -1
- package/build/dts/core/campaign-state.d.ts.map +1 -1
- package/build/dts/core/campaigns.d.ts +46 -2
- package/build/dts/core/campaigns.d.ts.map +1 -1
- package/build/dts/core/ledger.d.ts +58 -2
- package/build/dts/core/ledger.d.ts.map +1 -1
- package/build/dts/domain/config.d.ts +204 -2
- package/build/dts/domain/config.d.ts.map +1 -1
- package/build/dts/host/campaigns.d.ts +34 -0
- package/build/dts/host/campaigns.d.ts.map +1 -1
- package/build/dts/host/measure-command.d.ts +3 -0
- package/build/dts/host/measure-command.d.ts.map +1 -0
- package/build/dts/index.d.ts +5 -4
- package/build/dts/index.d.ts.map +1 -1
- package/build/dts/load/extension.d.ts +2 -1
- package/build/dts/load/extension.d.ts.map +1 -1
- package/build/dts/manifest/lower.d.ts.map +1 -1
- package/build/dts/manifest/spec.d.ts +209 -3
- package/build/dts/manifest/spec.d.ts.map +1 -1
- package/build/dts/ports/campaign-measure.d.ts +3 -0
- package/build/dts/ports/campaign-measure.d.ts.map +1 -0
- package/build/esm/core/campaign-state.js +35 -2
- package/build/esm/core/campaign-state.js.map +1 -1
- package/build/esm/core/campaigns.js +177 -2
- package/build/esm/core/campaigns.js.map +1 -1
- package/build/esm/core/ledger.js +215 -0
- package/build/esm/core/ledger.js.map +1 -1
- package/build/esm/domain/config.js +40 -4
- package/build/esm/domain/config.js.map +1 -1
- package/build/esm/host/campaigns.js +421 -28
- package/build/esm/host/campaigns.js.map +1 -1
- package/build/esm/host/measure-command.js +35 -0
- package/build/esm/host/measure-command.js.map +1 -0
- package/build/esm/index.js +2 -2
- package/build/esm/index.js.map +1 -1
- package/build/esm/load/extension.js +57 -13
- package/build/esm/load/extension.js.map +1 -1
- package/build/esm/manifest/lower.js +68 -1
- package/build/esm/manifest/lower.js.map +1 -1
- package/build/esm/manifest/spec.js +85 -5
- package/build/esm/manifest/spec.js.map +1 -1
- package/build/esm/ports/campaign-measure.js +2 -0
- package/build/esm/ports/campaign-measure.js.map +1 -0
- package/package.json +3 -3
- package/src/core/campaign-state.ts +49 -2
- package/src/core/campaigns.ts +232 -3
- package/src/core/ledger.ts +264 -2
- package/src/domain/config.ts +46 -4
- package/src/host/campaigns.ts +571 -33
- package/src/host/measure-command.ts +36 -0
- package/src/index.ts +31 -0
- package/src/load/extension.ts +70 -6
- package/src/manifest/lower.ts +80 -1
- package/src/manifest/spec.ts +100 -5
- package/src/ports/campaign-measure.ts +11 -0
package/src/core/campaigns.ts
CHANGED
|
@@ -28,12 +28,16 @@ import type {
|
|
|
28
28
|
CampaignUnit,
|
|
29
29
|
Detector,
|
|
30
30
|
Holdout,
|
|
31
|
+
Measure,
|
|
32
|
+
MeasureDirection,
|
|
33
|
+
MeasureSource,
|
|
31
34
|
ObjectiveRule,
|
|
32
35
|
OnTouch,
|
|
33
36
|
PerimeterRule,
|
|
34
37
|
PhaseRule,
|
|
35
38
|
ReportFormat,
|
|
36
39
|
} from "../domain/config.js";
|
|
40
|
+
import type { CampaignMeasure } from "../ports/campaign-measure.js";
|
|
37
41
|
import type { CampaignPredicate, Range } from "../ports/campaign-predicate.js";
|
|
38
42
|
import type { ReportSource, ReportSpec } from "../ports/report-source.js";
|
|
39
43
|
|
|
@@ -139,19 +143,48 @@ export type CompiledSectorTerm =
|
|
|
139
143
|
| { readonly kind: "oneRoot" }
|
|
140
144
|
| { readonly kind: "oneHost"; readonly hosts: ReadonlyArray<RegExp> };
|
|
141
145
|
|
|
146
|
+
// Where a scalar objective's number comes from, per file. A `report` source
|
|
147
|
+
// is its report term compiled as a detector and counted at `match`, and an
|
|
148
|
+
// `fn` source is an `fn` detector read as a number — so both reach the
|
|
149
|
+
// function loading, the report pre-reading and the syntax parse by the
|
|
150
|
+
// paths every detector does.
|
|
151
|
+
export type CompiledMeasureSource =
|
|
152
|
+
| { readonly kind: "lines" }
|
|
153
|
+
| { readonly kind: "files" }
|
|
154
|
+
| { readonly kind: "report"; readonly detect: CompiledDetector }
|
|
155
|
+
| { readonly kind: "fn"; readonly detect: CompiledDetector & { readonly kind: "fn" } };
|
|
156
|
+
|
|
157
|
+
export type CompiledMeasure =
|
|
158
|
+
| { readonly kind: "sum"; readonly of: CompiledMeasureSource }
|
|
159
|
+
| {
|
|
160
|
+
readonly kind: "ratio";
|
|
161
|
+
readonly of: CompiledMeasureSource;
|
|
162
|
+
readonly per: CompiledMeasureSource;
|
|
163
|
+
readonly scale: number;
|
|
164
|
+
}
|
|
165
|
+
| { readonly kind: "command"; readonly command: string; readonly pattern: RegExp | null };
|
|
166
|
+
|
|
142
167
|
export type CompiledObjective = {
|
|
143
168
|
readonly name: string;
|
|
144
169
|
readonly id: string;
|
|
145
170
|
readonly campaign: string;
|
|
146
171
|
readonly message: string;
|
|
147
172
|
readonly why: string | null;
|
|
148
|
-
|
|
173
|
+
// `null` for a scalar objective, which holds nothing out.
|
|
174
|
+
readonly holdout: Holdout | null;
|
|
149
175
|
// The unit a per-file detector answers at; `declaration` for the
|
|
150
176
|
// objectives no per-file detector answers, whose entries are `file#subject`.
|
|
151
177
|
readonly unit: CampaignUnit;
|
|
152
178
|
readonly detect: CompiledDetector | null;
|
|
153
179
|
readonly sector: CompiledSectorTerm | null;
|
|
154
180
|
readonly endState: { readonly phase: string; readonly family: string } | null;
|
|
181
|
+
// A scalar objective: its number, which way is better, the slack either
|
|
182
|
+
// side of the record, and the value at which it is met (`null` for a
|
|
183
|
+
// standing measure, which only ratchets).
|
|
184
|
+
readonly measure: CompiledMeasure | null;
|
|
185
|
+
readonly direction: MeasureDirection;
|
|
186
|
+
readonly tolerance: number;
|
|
187
|
+
readonly target: number | null;
|
|
155
188
|
readonly until: string | null;
|
|
156
189
|
readonly probes: {
|
|
157
190
|
readonly fires: ReadonlyArray<CampaignProbe>;
|
|
@@ -376,6 +409,51 @@ const compileDetector = (
|
|
|
376
409
|
return Result.succeed({ kind: "fn", name: detector.fn });
|
|
377
410
|
};
|
|
378
411
|
|
|
412
|
+
const compileMeasureSource = (
|
|
413
|
+
name: string,
|
|
414
|
+
source: MeasureSource,
|
|
415
|
+
): Result.Result<CompiledMeasureSource, PatternInvalid> => {
|
|
416
|
+
if ("lines" in source) return Result.succeed({ kind: "lines" });
|
|
417
|
+
if ("files" in source) return Result.succeed({ kind: "files" });
|
|
418
|
+
if ("fn" in source)
|
|
419
|
+
return Result.succeed({ kind: "fn", detect: { kind: "fn", name: source.fn } });
|
|
420
|
+
const detect = compileDetector(name, source);
|
|
421
|
+
return Result.isFailure(detect)
|
|
422
|
+
? Result.fail(detect.failure)
|
|
423
|
+
: Result.succeed({ kind: "report", detect: detect.success });
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
const compileMeasure = (
|
|
427
|
+
name: string,
|
|
428
|
+
measure: Measure,
|
|
429
|
+
): Result.Result<CompiledMeasure, PatternInvalid> => {
|
|
430
|
+
if ("command" in measure) {
|
|
431
|
+
const pattern = compilePatterns(name, "measure.pattern", measure.pattern);
|
|
432
|
+
if (Result.isFailure(pattern)) return Result.fail(pattern.failure);
|
|
433
|
+
return Result.succeed({
|
|
434
|
+
kind: "command",
|
|
435
|
+
command: measure.command,
|
|
436
|
+
pattern: pattern.success[0] ?? null,
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
if ("ratio" in measure) {
|
|
440
|
+
const of = compileMeasureSource(name, measure.ratio.of);
|
|
441
|
+
if (Result.isFailure(of)) return Result.fail(of.failure);
|
|
442
|
+
const per = compileMeasureSource(name, measure.ratio.per);
|
|
443
|
+
if (Result.isFailure(per)) return Result.fail(per.failure);
|
|
444
|
+
return Result.succeed({
|
|
445
|
+
kind: "ratio",
|
|
446
|
+
of: of.success,
|
|
447
|
+
per: per.success,
|
|
448
|
+
scale: measure.ratio.scale,
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
const of = compileMeasureSource(name, measure);
|
|
452
|
+
return Result.isFailure(of)
|
|
453
|
+
? Result.fail(of.failure)
|
|
454
|
+
: Result.succeed({ kind: "sum", of: of.success });
|
|
455
|
+
};
|
|
456
|
+
|
|
379
457
|
export const compileObjective = (
|
|
380
458
|
rule: ObjectiveRule,
|
|
381
459
|
): Result.Result<CompiledObjective, PatternInvalid> => {
|
|
@@ -399,17 +477,28 @@ export const compileObjective = (
|
|
|
399
477
|
sector = { kind: "oneHost", hosts: hosts.success };
|
|
400
478
|
}
|
|
401
479
|
}
|
|
480
|
+
let measure: CompiledMeasure | null = null;
|
|
481
|
+
if (rule.measure !== undefined) {
|
|
482
|
+
const compiled = compileMeasure(rule.name, rule.measure);
|
|
483
|
+
if (Result.isFailure(compiled)) return Result.fail(compiled.failure);
|
|
484
|
+
measure = compiled.success;
|
|
485
|
+
}
|
|
486
|
+
const holdout = rule.holdout ?? null;
|
|
402
487
|
return Result.succeed({
|
|
403
488
|
name: rule.name,
|
|
404
489
|
id: rule.id,
|
|
405
490
|
campaign: rule.campaign,
|
|
406
491
|
message: rule.message,
|
|
407
492
|
why: rule.why ?? null,
|
|
408
|
-
holdout
|
|
409
|
-
unit:
|
|
493
|
+
holdout,
|
|
494
|
+
unit: holdout === null || holdout === "sector" ? "declaration" : holdout,
|
|
410
495
|
detect,
|
|
411
496
|
sector,
|
|
412
497
|
endState: rule.endState ?? null,
|
|
498
|
+
measure,
|
|
499
|
+
direction: rule.direction ?? "down",
|
|
500
|
+
tolerance: rule.tolerance ?? 0,
|
|
501
|
+
target: rule.target ?? null,
|
|
413
502
|
until: rule.until ?? null,
|
|
414
503
|
probes: rule.probes,
|
|
415
504
|
});
|
|
@@ -1087,6 +1176,7 @@ export const reportSpecsOf = (
|
|
|
1087
1176
|
for (const objective of rule.objectives) {
|
|
1088
1177
|
if (objective.detect !== null) walk(objective.detect);
|
|
1089
1178
|
if (objective.sector?.kind === "has") walk(objective.sector.detect);
|
|
1179
|
+
for (const detect of measureDetectorsOf(objective)) walk(detect);
|
|
1090
1180
|
}
|
|
1091
1181
|
if (rule.perimeter?.kind === "match") walk(rule.perimeter.detect);
|
|
1092
1182
|
}
|
|
@@ -1098,6 +1188,119 @@ export const evaluateObjectives = (
|
|
|
1098
1188
|
input: CampaignInput,
|
|
1099
1189
|
): ReadonlyArray<CampaignHit> => selected.flatMap((rule) => evaluateObjective(rule, input));
|
|
1100
1190
|
|
|
1191
|
+
// ---------------------------------------------------------------------------
|
|
1192
|
+
// Scalar objectives: a number per file, summed per sector.
|
|
1193
|
+
|
|
1194
|
+
const measureSourcesOf = (measure: CompiledMeasure): ReadonlyArray<CompiledMeasureSource> =>
|
|
1195
|
+
measure.kind === "sum" ? [measure.of] : measure.kind === "ratio" ? [measure.of, measure.per] : [];
|
|
1196
|
+
|
|
1197
|
+
// The detectors a scalar objective reads a file through: its report and
|
|
1198
|
+
// function sources, so the loader holds its functions, the host reads its
|
|
1199
|
+
// reports ahead and parses the file when either needs the tree.
|
|
1200
|
+
export const measureDetectorsOf = (
|
|
1201
|
+
objective: CompiledObjective,
|
|
1202
|
+
): ReadonlyArray<CompiledDetector> =>
|
|
1203
|
+
objective.measure === null
|
|
1204
|
+
? []
|
|
1205
|
+
: measureSourcesOf(objective.measure).flatMap((source) =>
|
|
1206
|
+
source.kind === "report" || source.kind === "fn" ? [source.detect] : [],
|
|
1207
|
+
);
|
|
1208
|
+
|
|
1209
|
+
// The scalar objectives a host measures file by file: all but a command's,
|
|
1210
|
+
// which measures the repository once.
|
|
1211
|
+
export const perFileMeasuresOf = (rule: CompiledCampaign): ReadonlyArray<CompiledObjective> =>
|
|
1212
|
+
rule.objectives.filter(
|
|
1213
|
+
(objective) => objective.measure !== null && objective.measure.kind !== "command",
|
|
1214
|
+
);
|
|
1215
|
+
|
|
1216
|
+
// Six decimals: enough for a density, and few enough that a ratio reads the
|
|
1217
|
+
// same on every run and the ledger's arithmetic compares exactly.
|
|
1218
|
+
export const roundMeasure = (value: number): number => Math.round(value * 1e6) / 1e6;
|
|
1219
|
+
|
|
1220
|
+
const NON_BLANK = /\S/;
|
|
1221
|
+
|
|
1222
|
+
const measureSource = (source: CompiledMeasureSource, input: CampaignInput): number => {
|
|
1223
|
+
switch (source.kind) {
|
|
1224
|
+
case "lines": {
|
|
1225
|
+
let lines = 0;
|
|
1226
|
+
for (const line of input.text.split("\n")) if (NON_BLANK.test(line)) lines += 1;
|
|
1227
|
+
return lines;
|
|
1228
|
+
}
|
|
1229
|
+
case "files":
|
|
1230
|
+
return 1;
|
|
1231
|
+
case "report":
|
|
1232
|
+
return candidatesOf(source.detect, "match", input).length;
|
|
1233
|
+
case "fn": {
|
|
1234
|
+
// The loader holds whatever the manifest named; a measure reads its
|
|
1235
|
+
// answer as a number, and anything else — a boolean, a list, a
|
|
1236
|
+
// negative or non-finite number — is `NaN`, which `check` refuses.
|
|
1237
|
+
const found = input.functions.get(source.detect.name) as
|
|
1238
|
+
((...args: Parameters<CampaignMeasure>) => unknown) | undefined;
|
|
1239
|
+
if (found === undefined) return Number.NaN;
|
|
1240
|
+
const answer = found({
|
|
1241
|
+
file: input.file,
|
|
1242
|
+
text: input.text,
|
|
1243
|
+
facts: input.facts,
|
|
1244
|
+
syntax: input.syntax,
|
|
1245
|
+
});
|
|
1246
|
+
return typeof answer === "number" && Number.isFinite(answer) && answer >= 0
|
|
1247
|
+
? answer
|
|
1248
|
+
: Number.NaN;
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
};
|
|
1252
|
+
|
|
1253
|
+
// What one file contributes: to the sum, or to a ratio's two sums.
|
|
1254
|
+
export type MeasureParts = { readonly of: number; readonly per: number };
|
|
1255
|
+
|
|
1256
|
+
export const ZERO_PARTS: MeasureParts = { of: 0, per: 0 };
|
|
1257
|
+
|
|
1258
|
+
export const measureFile = (measure: CompiledMeasure, input: CampaignInput): MeasureParts =>
|
|
1259
|
+
measure.kind === "ratio"
|
|
1260
|
+
? { of: measureSource(measure.of, input), per: measureSource(measure.per, input) }
|
|
1261
|
+
: measure.kind === "sum"
|
|
1262
|
+
? { of: measureSource(measure.of, input), per: 0 }
|
|
1263
|
+
: ZERO_PARTS;
|
|
1264
|
+
|
|
1265
|
+
export const addParts = (left: MeasureParts, right: MeasureParts): MeasureParts => ({
|
|
1266
|
+
of: left.of + right.of,
|
|
1267
|
+
per: left.per + right.per,
|
|
1268
|
+
});
|
|
1269
|
+
|
|
1270
|
+
// A sector's value from its files' parts: the sum, or `scale × Σof / Σper`
|
|
1271
|
+
// (0 where the denominator is).
|
|
1272
|
+
export const valueOfParts = (measure: CompiledMeasure, parts: MeasureParts): number => {
|
|
1273
|
+
if (measure.kind !== "ratio") return roundMeasure(parts.of);
|
|
1274
|
+
if (parts.per === 0) return Number.isNaN(parts.of) ? Number.NaN : 0;
|
|
1275
|
+
return roundMeasure((measure.scale * parts.of) / parts.per);
|
|
1276
|
+
};
|
|
1277
|
+
|
|
1278
|
+
// A command's output as a number: the whole of it, trimmed, or the `value`
|
|
1279
|
+
// group of the first match of the pattern. `NaN` when neither reads as one.
|
|
1280
|
+
export const numberFromOutput = (measure: CompiledMeasure, output: string): number => {
|
|
1281
|
+
if (measure.kind !== "command") return Number.NaN;
|
|
1282
|
+
const text =
|
|
1283
|
+
measure.pattern === null ? output.trim() : measure.pattern.exec(output)?.groups?.value;
|
|
1284
|
+
if (text === undefined || text.trim() === "") return Number.NaN;
|
|
1285
|
+
const value = Number(text.trim());
|
|
1286
|
+
return Number.isFinite(value) ? roundMeasure(value) : Number.NaN;
|
|
1287
|
+
};
|
|
1288
|
+
|
|
1289
|
+
// How far a scalar is from being met: the distance to its target in the
|
|
1290
|
+
// direction it improves, 0 once there — and 0 for a standing measure, which
|
|
1291
|
+
// has no target and is never residue. This is the scalar's dimension of the
|
|
1292
|
+
// residue vector, so a phase is left when it reaches 0 as when a holdout
|
|
1293
|
+
// count does.
|
|
1294
|
+
export const distanceToTarget = (objective: CompiledObjective, value: number): number => {
|
|
1295
|
+
if (objective.target === null) return 0;
|
|
1296
|
+
// A number that did not measure holds the sector where it is — `check`
|
|
1297
|
+
// refuses it — rather than letting it pass a phase it has not met.
|
|
1298
|
+
if (Number.isNaN(value)) return 1;
|
|
1299
|
+
const distance =
|
|
1300
|
+
objective.direction === "down" ? value - objective.target : objective.target - value;
|
|
1301
|
+
return Math.max(0, roundMeasure(distance));
|
|
1302
|
+
};
|
|
1303
|
+
|
|
1101
1304
|
// One line per leaf term: how it reads, and what it answered for this file —
|
|
1102
1305
|
// every leaf evaluated, with no short-circuit, since the point is to show
|
|
1103
1306
|
// which one is not saying what the author thinks it says.
|
|
@@ -1239,6 +1442,8 @@ export type FailedProbe = {
|
|
|
1239
1442
|
readonly admittedBy?: string;
|
|
1240
1443
|
// For a probe outside the campaign's own scope.
|
|
1241
1444
|
readonly outOfScope?: boolean;
|
|
1445
|
+
// For a scalar objective's probe: what the file measured.
|
|
1446
|
+
readonly measured?: number;
|
|
1242
1447
|
};
|
|
1243
1448
|
|
|
1244
1449
|
// Every objective must fire on each of its `fires` probes and stay silent
|
|
@@ -1283,6 +1488,30 @@ export const campaignsFailingTheirProbe = (
|
|
|
1283
1488
|
return input;
|
|
1284
1489
|
};
|
|
1285
1490
|
for (const objective of rule.objectives) {
|
|
1491
|
+
const measure = objective.measure;
|
|
1492
|
+
if (measure !== null) {
|
|
1493
|
+
// A scalar's probe is proven on the number the file contributes:
|
|
1494
|
+
// the probe's `value` when it states one, above zero when it does
|
|
1495
|
+
// not, and zero for an `ignores` probe.
|
|
1496
|
+
const prove = (probe: CampaignProbe, expected: "fires" | "ignores"): void => {
|
|
1497
|
+
if (!anyMatches(rule.scope, probe.path)) {
|
|
1498
|
+
failed.push({ name: objective.name, probe, expected, outOfScope: true });
|
|
1499
|
+
return;
|
|
1500
|
+
}
|
|
1501
|
+
const input = probeInputOf(probe, extractor, matcherFor(probe.path), functions);
|
|
1502
|
+
const measured = valueOfParts(measure, measureFile(measure, input));
|
|
1503
|
+
const holds =
|
|
1504
|
+
expected === "ignores"
|
|
1505
|
+
? measured === 0
|
|
1506
|
+
: probe.value === undefined
|
|
1507
|
+
? measured > 0
|
|
1508
|
+
: measured === roundMeasure(probe.value);
|
|
1509
|
+
if (!holds) failed.push({ name: objective.name, probe, expected, measured });
|
|
1510
|
+
};
|
|
1511
|
+
for (const probe of objective.probes.fires) prove(probe, "fires");
|
|
1512
|
+
for (const probe of objective.probes.ignores) prove(probe, "ignores");
|
|
1513
|
+
continue;
|
|
1514
|
+
}
|
|
1286
1515
|
const detect = detectorOf(objective);
|
|
1287
1516
|
if (detect === null) continue;
|
|
1288
1517
|
const unit = objective.sector?.kind === "has" ? "file" : objective.unit;
|
package/src/core/ledger.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
import { type Standing, standingOf } from "@goodbones/core";
|
|
1
2
|
import * as Result from "effect/Result";
|
|
2
3
|
import * as Schema from "effect/Schema";
|
|
3
4
|
|
|
4
|
-
import type { CampaignUnit } from "../domain/config.js";
|
|
5
|
-
import type
|
|
5
|
+
import type { CampaignUnit, MeasureDirection } from "../domain/config.js";
|
|
6
|
+
import { type CompiledCampaign, roundMeasure } from "./campaigns.js";
|
|
6
7
|
import { isDefinedPhase, type SectorPosition } from "./phases.js";
|
|
7
8
|
import { IMPLICIT_SECTOR } from "./sectors.js";
|
|
8
9
|
|
|
@@ -447,6 +448,267 @@ export const isStalled = (
|
|
|
447
448
|
return now - Date.parse(last) > rule.staleAfter;
|
|
448
449
|
};
|
|
449
450
|
|
|
451
|
+
// ---------------------------------------------------------------------------
|
|
452
|
+
// A scalar objective's ledger: a number per sector, the best value `clear`
|
|
453
|
+
// has recorded for it, and every rise conceded. The same file, a second
|
|
454
|
+
// schema — told apart by `kind`, which the holdout ledger does not carry, so
|
|
455
|
+
// every ledger written before scalars existed decodes as it did.
|
|
456
|
+
//
|
|
457
|
+
// - `check` holds each sector in window to `recorded`, within the
|
|
458
|
+
// objective's tolerance: a value worse by more is unrecorded growth,
|
|
459
|
+
// and a value better by more is progress `clear` has not written yet —
|
|
460
|
+
// stale, as a holdout that stopped firing is.
|
|
461
|
+
// - `clear` writes an improvement: `recorded` moves to the value and the
|
|
462
|
+
// difference joins `improved`. `concede` writes a rise, with a reason.
|
|
463
|
+
// - The arithmetic, per sector, in the direction that is worse:
|
|
464
|
+
// `recorded = initial + Σ rise − improved`.
|
|
465
|
+
// - A sector leaving the window is `closed` at the value it left with;
|
|
466
|
+
// nothing after that counts.
|
|
467
|
+
|
|
468
|
+
const MeasureSectorLedger = Schema.Struct({
|
|
469
|
+
entered: Schema.String,
|
|
470
|
+
// The value the day the sector entered the window.
|
|
471
|
+
initial: Schema.Finite,
|
|
472
|
+
// The value the sector is held to.
|
|
473
|
+
recorded: Schema.Finite,
|
|
474
|
+
// Every improvement `clear` recorded, summed, as a magnitude.
|
|
475
|
+
improved: Schema.Finite,
|
|
476
|
+
// The value the sector left the window with, or `null` while inside it.
|
|
477
|
+
closed: Schema.NullOr(Schema.Finite),
|
|
478
|
+
// When `recorded` last improved — what the stall clock reads.
|
|
479
|
+
lastImproved: Schema.String,
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
// A rise conceded with a reason, or a re-baseline a phase concession
|
|
483
|
+
// authorized: the recorded value before and after.
|
|
484
|
+
const MeasureConcession = Schema.Struct({
|
|
485
|
+
sector: Schema.String,
|
|
486
|
+
at: Schema.String,
|
|
487
|
+
by: Schema.String,
|
|
488
|
+
from: Schema.Finite,
|
|
489
|
+
to: Schema.Finite,
|
|
490
|
+
reason: Schema.String,
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
export const MeasureLedger = Schema.Struct({
|
|
494
|
+
version: Schema.Literal(2),
|
|
495
|
+
kind: Schema.Literal("measure"),
|
|
496
|
+
campaign: Schema.String,
|
|
497
|
+
objective: Schema.String,
|
|
498
|
+
created: Schema.String,
|
|
499
|
+
direction: Schema.Literals(["down", "up"]),
|
|
500
|
+
sectors: Schema.Record(Schema.String, MeasureSectorLedger),
|
|
501
|
+
concessions: Schema.Array(MeasureConcession),
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
export type MeasureLedger = typeof MeasureLedger.Type;
|
|
505
|
+
export type MeasureSectorLedger = typeof MeasureSectorLedger.Type;
|
|
506
|
+
export type MeasureConcession = typeof MeasureConcession.Type;
|
|
507
|
+
|
|
508
|
+
const decodeMeasure = Schema.decodeUnknownResult(MeasureLedger, {
|
|
509
|
+
errors: "all",
|
|
510
|
+
onExcessProperty: "error",
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
export const isMeasureLedger = (raw: unknown): boolean =>
|
|
514
|
+
typeof raw === "object" &&
|
|
515
|
+
raw !== null &&
|
|
516
|
+
(raw as { readonly kind?: unknown }).kind === "measure";
|
|
517
|
+
|
|
518
|
+
export const decodeMeasureLedger = (raw: unknown): Result.Result<MeasureLedger, string> => {
|
|
519
|
+
const decoded = decodeMeasure(raw);
|
|
520
|
+
return Result.isFailure(decoded)
|
|
521
|
+
? Result.fail(String(decoded.failure.issue))
|
|
522
|
+
: Result.succeed(decoded.success);
|
|
523
|
+
};
|
|
524
|
+
|
|
525
|
+
export const EMPTY_MEASURE_LEDGER = (
|
|
526
|
+
campaign: string,
|
|
527
|
+
objective: string,
|
|
528
|
+
direction: MeasureDirection,
|
|
529
|
+
now: number,
|
|
530
|
+
): MeasureLedger => ({
|
|
531
|
+
version: 2,
|
|
532
|
+
kind: "measure",
|
|
533
|
+
campaign,
|
|
534
|
+
objective,
|
|
535
|
+
created: new Date(now).toISOString(),
|
|
536
|
+
direction,
|
|
537
|
+
sectors: {},
|
|
538
|
+
concessions: [],
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
export const serializeMeasureLedger = (ledger: MeasureLedger): string =>
|
|
542
|
+
`${JSON.stringify(ledger, null, 2)}\n`;
|
|
543
|
+
|
|
544
|
+
// How much worse `to` is than `from`, in the ledger's direction.
|
|
545
|
+
const worseBy = (direction: MeasureDirection, from: number, to: number): number =>
|
|
546
|
+
direction === "down" ? to - from : from - to;
|
|
547
|
+
|
|
548
|
+
// Sums of rounded numbers carry float error; the arithmetic is exact to
|
|
549
|
+
// well under the six decimals a value is measured to.
|
|
550
|
+
const EPSILON = 1e-6;
|
|
551
|
+
|
|
552
|
+
export const measureArithmeticHolds = (ledger: MeasureLedger, sector: string): boolean => {
|
|
553
|
+
const risen = ledger.concessions
|
|
554
|
+
.filter((one) => one.sector === sector)
|
|
555
|
+
.reduce((sum, one) => sum + worseBy(ledger.direction, one.from, one.to), 0);
|
|
556
|
+
const own = ledger.sectors[sector];
|
|
557
|
+
if (own === undefined) return Math.abs(risen) < EPSILON;
|
|
558
|
+
const expected = worseBy(ledger.direction, own.initial, own.recorded);
|
|
559
|
+
return Math.abs(expected - (risen - own.improved)) < EPSILON;
|
|
560
|
+
};
|
|
561
|
+
|
|
562
|
+
export const measureLedgerArithmeticHolds = (ledger: MeasureLedger): boolean =>
|
|
563
|
+
[
|
|
564
|
+
...new Set([...Object.keys(ledger.sectors), ...ledger.concessions.map((one) => one.sector)]),
|
|
565
|
+
].every((sector) => measureArithmeticHolds(ledger, sector));
|
|
566
|
+
|
|
567
|
+
// Where a sector's value stands against what its ledger holds it to.
|
|
568
|
+
export const measureStandingOf = (
|
|
569
|
+
ledger: MeasureLedger,
|
|
570
|
+
sector: string,
|
|
571
|
+
value: number,
|
|
572
|
+
tolerance: number,
|
|
573
|
+
): Standing | "unrecorded" => {
|
|
574
|
+
const own = ledger.sectors[sector];
|
|
575
|
+
if (own === undefined || own.closed !== null) return "unrecorded";
|
|
576
|
+
return standingOf({ direction: ledger.direction, limit: own.recorded, tolerance }, value);
|
|
577
|
+
};
|
|
578
|
+
|
|
579
|
+
// `clear` for one sector, in one of three positions: entering the window
|
|
580
|
+
// (recorded at its value), inside it (an improvement past the tolerance
|
|
581
|
+
// becomes the record), or past it (closed at the value it left with).
|
|
582
|
+
export const clearedMeasure = (
|
|
583
|
+
ledger: MeasureLedger,
|
|
584
|
+
sector: string,
|
|
585
|
+
value: number,
|
|
586
|
+
tolerance: number,
|
|
587
|
+
now: number,
|
|
588
|
+
window: "inside" | "outside",
|
|
589
|
+
by = "objectives clear",
|
|
590
|
+
): MeasureLedger => {
|
|
591
|
+
const at = new Date(now).toISOString();
|
|
592
|
+
const own = ledger.sectors[sector];
|
|
593
|
+
if (window === "outside") {
|
|
594
|
+
if (own === undefined || own.closed !== null) return ledger;
|
|
595
|
+
return {
|
|
596
|
+
...ledger,
|
|
597
|
+
sectors: {
|
|
598
|
+
...ledger.sectors,
|
|
599
|
+
[sector]: { ...own, closed: Number.isNaN(value) ? own.recorded : value },
|
|
600
|
+
},
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
if (Number.isNaN(value)) return ledger;
|
|
604
|
+
if (own === undefined) {
|
|
605
|
+
return {
|
|
606
|
+
...ledger,
|
|
607
|
+
sectors: {
|
|
608
|
+
...ledger.sectors,
|
|
609
|
+
[sector]: {
|
|
610
|
+
entered: at,
|
|
611
|
+
initial: value,
|
|
612
|
+
recorded: value,
|
|
613
|
+
improved: 0,
|
|
614
|
+
closed: null,
|
|
615
|
+
lastImproved: at,
|
|
616
|
+
},
|
|
617
|
+
},
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
if (own.closed !== null) {
|
|
621
|
+
// A sector born again after it was closed — its marker restored, say —
|
|
622
|
+
// enters again at its value, and the move from its old record is a
|
|
623
|
+
// concession, so the arithmetic still holds and the jump is in the file.
|
|
624
|
+
return {
|
|
625
|
+
...ledger,
|
|
626
|
+
sectors: { ...ledger.sectors, [sector]: { ...own, recorded: value, closed: null } },
|
|
627
|
+
concessions:
|
|
628
|
+
own.recorded === value
|
|
629
|
+
? ledger.concessions
|
|
630
|
+
: [
|
|
631
|
+
...ledger.concessions,
|
|
632
|
+
{ sector, at, by, from: own.recorded, to: value, reason: "entered the window again" },
|
|
633
|
+
],
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
if (
|
|
637
|
+
standingOf({ direction: ledger.direction, limit: own.recorded, tolerance }, value) !==
|
|
638
|
+
"surpassed"
|
|
639
|
+
) {
|
|
640
|
+
return ledger;
|
|
641
|
+
}
|
|
642
|
+
return {
|
|
643
|
+
...ledger,
|
|
644
|
+
sectors: {
|
|
645
|
+
...ledger.sectors,
|
|
646
|
+
[sector]: {
|
|
647
|
+
...own,
|
|
648
|
+
recorded: value,
|
|
649
|
+
improved: roundMeasure(own.improved - worseBy(ledger.direction, own.recorded, value)),
|
|
650
|
+
lastImproved: at,
|
|
651
|
+
},
|
|
652
|
+
},
|
|
653
|
+
};
|
|
654
|
+
};
|
|
655
|
+
|
|
656
|
+
// `concede`, or a re-baseline: the sector is held to its value from now,
|
|
657
|
+
// and a concession records the move with its reason. A sector the ledger
|
|
658
|
+
// has not recorded is `clear`'s to enter, not a concession's.
|
|
659
|
+
export const concededMeasure = (
|
|
660
|
+
ledger: MeasureLedger,
|
|
661
|
+
sector: string,
|
|
662
|
+
value: number,
|
|
663
|
+
record: ConcessionRecord,
|
|
664
|
+
): MeasureLedger => {
|
|
665
|
+
const own = ledger.sectors[sector];
|
|
666
|
+
if (own === undefined || own.closed !== null || Number.isNaN(value)) return ledger;
|
|
667
|
+
if (own.recorded === value) return ledger;
|
|
668
|
+
return {
|
|
669
|
+
...ledger,
|
|
670
|
+
sectors: { ...ledger.sectors, [sector]: { ...own, recorded: value } },
|
|
671
|
+
concessions: [
|
|
672
|
+
...ledger.concessions,
|
|
673
|
+
{
|
|
674
|
+
sector,
|
|
675
|
+
at: new Date(record.at).toISOString(),
|
|
676
|
+
by: record.by,
|
|
677
|
+
from: own.recorded,
|
|
678
|
+
to: value,
|
|
679
|
+
reason: record.reason,
|
|
680
|
+
},
|
|
681
|
+
],
|
|
682
|
+
};
|
|
683
|
+
};
|
|
684
|
+
|
|
685
|
+
// What the ledger holds the open sectors to, summed.
|
|
686
|
+
export const recordedOf = (ledger: MeasureLedger): number =>
|
|
687
|
+
roundMeasure(
|
|
688
|
+
Object.values(ledger.sectors)
|
|
689
|
+
.filter((one) => one.closed === null)
|
|
690
|
+
.reduce((sum, one) => sum + one.recorded, 0),
|
|
691
|
+
);
|
|
692
|
+
|
|
693
|
+
export const lastImprovedOf = (ledger: MeasureLedger): string | null => {
|
|
694
|
+
const stamps = Object.values(ledger.sectors).map((one) => one.lastImproved);
|
|
695
|
+
return stamps.length === 0 ? null : stamps.reduce((a, b) => (a > b ? a : b));
|
|
696
|
+
};
|
|
697
|
+
|
|
698
|
+
// How far the open sectors have come from where they entered toward the
|
|
699
|
+
// target: `1` at it, `0` where they started. A standing measure has no
|
|
700
|
+
// target to come toward, and reads as its improvement over its initial.
|
|
701
|
+
export const measureProgressOf = (ledger: MeasureLedger, target: number | null): number => {
|
|
702
|
+
const open = Object.values(ledger.sectors).filter((one) => one.closed === null);
|
|
703
|
+
if (open.length === 0) return 1;
|
|
704
|
+
const initial = open.reduce((sum, one) => sum + one.initial, 0);
|
|
705
|
+
const recorded = open.reduce((sum, one) => sum + one.recorded, 0);
|
|
706
|
+
const goal = target === null ? 0 : target * open.length;
|
|
707
|
+
const span = worseBy(ledger.direction, goal, initial);
|
|
708
|
+
if (span <= 0) return 1;
|
|
709
|
+
return Math.min(1, Math.max(0, 1 - worseBy(ledger.direction, goal, recorded) / span));
|
|
710
|
+
};
|
|
711
|
+
|
|
450
712
|
// ---------------------------------------------------------------------------
|
|
451
713
|
// The per-sector record: what derivation cannot say about a sector — the
|
|
452
714
|
// furthest phase it has reached, the phases attested for it, and the notes
|
package/src/domain/config.ts
CHANGED
|
@@ -182,6 +182,9 @@ export const CampaignProbe = Schema.Struct({
|
|
|
182
182
|
edges: Schema.optionalKey(Schema.Record(Schema.String, ImportProbeTarget)),
|
|
183
183
|
files: Schema.optionalKey(Schema.Array(Schema.String)),
|
|
184
184
|
report: Schema.optionalKey(Schema.Array(ProbeDiagnostic)),
|
|
185
|
+
// A scalar objective's probe only: the number the file contributes. A
|
|
186
|
+
// `fires` probe without one asserts only that it is above zero.
|
|
187
|
+
value: Schema.optionalKey(Schema.Finite),
|
|
185
188
|
});
|
|
186
189
|
|
|
187
190
|
export const CampaignProbes = Schema.Struct({
|
|
@@ -189,12 +192,43 @@ export const CampaignProbes = Schema.Struct({
|
|
|
189
192
|
ignores: Schema.Array(CampaignProbe),
|
|
190
193
|
});
|
|
191
194
|
|
|
195
|
+
// Where a scalar objective's number comes from, per file: its non-blank
|
|
196
|
+
// lines, the file itself (so a sector's value is its file count), the
|
|
197
|
+
// diagnostics a report puts on it, or what a `module#export` function
|
|
198
|
+
// answers for it. A sector's value is the sum over its files.
|
|
199
|
+
export const MeasureSource = Schema.Union([
|
|
200
|
+
Schema.Struct({ lines: Schema.Literal(true) }),
|
|
201
|
+
Schema.Struct({ files: Schema.Literal(true) }),
|
|
202
|
+
Schema.Struct({ report: ReportTerm }),
|
|
203
|
+
Schema.Struct({ fn: Schema.String }),
|
|
204
|
+
]);
|
|
205
|
+
export type MeasureSource = (typeof MeasureSource)["Type"];
|
|
206
|
+
|
|
207
|
+
// A scalar objective's number: one source summed, a ratio of two sums
|
|
208
|
+
// (`scale × Σof / Σper`, 0 where `per` sums to 0), or — for a campaign with
|
|
209
|
+
// no perimeter, whose scope is its one sector — a command whose output is
|
|
210
|
+
// the number, read whole or through a `pattern` with a `value` group.
|
|
211
|
+
export const Measure = Schema.Union([
|
|
212
|
+
MeasureSource,
|
|
213
|
+
Schema.Struct({
|
|
214
|
+
ratio: Schema.Struct({ of: MeasureSource, per: MeasureSource, scale: Schema.Finite }),
|
|
215
|
+
}),
|
|
216
|
+
Schema.Struct({ command: Schema.String, pattern: Schema.optionalKey(Schema.String) }),
|
|
217
|
+
]);
|
|
218
|
+
export type Measure = (typeof Measure)["Type"];
|
|
219
|
+
|
|
220
|
+
// Which way a scalar objective's number is better.
|
|
221
|
+
export const MeasureDirection = Schema.Literals(["down", "up"]);
|
|
222
|
+
export type MeasureDirection = (typeof MeasureDirection)["Type"];
|
|
223
|
+
|
|
192
224
|
// An objective: a detector, a granularity, probes, and a ledger that only
|
|
193
225
|
// shrinks on its own. Owned by one campaign; named by at most one phase (an
|
|
194
226
|
// objective no phase names is in window in every phase). Exactly one of
|
|
195
|
-
// `match` (a per-file detector, evaluated by both hosts)
|
|
196
|
-
//
|
|
197
|
-
//
|
|
227
|
+
// `match` (a per-file detector, evaluated by both hosts), `sector` (a term
|
|
228
|
+
// over the sector's files, evaluated by the CLI) and `measure` (a number
|
|
229
|
+
// per sector, evaluated by the CLI and read off its ledger by the plugin) —
|
|
230
|
+
// or, for the objectives an `endState` expands into, `endState` naming the
|
|
231
|
+
// family.
|
|
198
232
|
export const ObjectiveRule = Schema.Struct({
|
|
199
233
|
// `campaign/<campaign>/<objective>`, the rule name a violation carries.
|
|
200
234
|
name: Schema.String,
|
|
@@ -203,9 +237,17 @@ export const ObjectiveRule = Schema.Struct({
|
|
|
203
237
|
// The `how`: what a reader at a holdout does about it.
|
|
204
238
|
message: Schema.String,
|
|
205
239
|
why: Schema.optionalKey(Schema.String),
|
|
206
|
-
|
|
240
|
+
// Absent for a scalar objective, which holds nothing out.
|
|
241
|
+
holdout: Schema.optionalKey(Holdout),
|
|
207
242
|
match: Schema.optionalKey(Detector),
|
|
208
243
|
sector: Schema.optionalKey(SectorTerm),
|
|
244
|
+
// A scalar objective: a number per sector with a direction, held to the
|
|
245
|
+
// value its ledger last recorded within `tolerance`, and met — for the
|
|
246
|
+
// phase that names it — at `target`.
|
|
247
|
+
measure: Schema.optionalKey(Measure),
|
|
248
|
+
direction: Schema.optionalKey(MeasureDirection),
|
|
249
|
+
tolerance: Schema.optionalKey(Schema.Finite),
|
|
250
|
+
target: Schema.optionalKey(Schema.Finite),
|
|
209
251
|
endState: Schema.optionalKey(
|
|
210
252
|
Schema.Struct({
|
|
211
253
|
phase: Schema.String,
|