@goodbones/campaigns 0.1.0 → 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.
- 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/diff.d.ts +1 -0
- package/build/dts/host/diff.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 +423 -29
- package/build/esm/host/campaigns.js.map +1 -1
- package/build/esm/host/diff.js +24 -0
- package/build/esm/host/diff.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 +2 -2
- 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 +573 -33
- package/src/host/diff.ts +29 -0
- 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/host/diff.ts
CHANGED
|
@@ -26,10 +26,38 @@ export type Diff = {
|
|
|
26
26
|
readonly added: ReadonlyArray<string>;
|
|
27
27
|
};
|
|
28
28
|
|
|
29
|
+
// git exports GIT_DIR, GIT_WORK_TREE, GIT_INDEX_FILE and friends into the
|
|
30
|
+
// environment of every hook it runs, and those WIN OVER `cwd`. Everything here
|
|
31
|
+
// is told which repository to read — `repoRoot` — so inheriting them would make
|
|
32
|
+
// the nudge silently read whichever repository invoked the hook rather than the
|
|
33
|
+
// one it was pointed at. That is not theoretical: `campaigns status --changed`
|
|
34
|
+
// is designed to run from a pre-commit hook.
|
|
35
|
+
//
|
|
36
|
+
// Stripped, so `cwd` governs. In the ordinary case — a hook on the same
|
|
37
|
+
// repository the tool was pointed at — this changes nothing.
|
|
38
|
+
const AMBIENT_GIT = [
|
|
39
|
+
"GIT_DIR",
|
|
40
|
+
"GIT_WORK_TREE",
|
|
41
|
+
"GIT_INDEX_FILE",
|
|
42
|
+
"GIT_COMMON_DIR",
|
|
43
|
+
"GIT_OBJECT_DIRECTORY",
|
|
44
|
+
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
|
|
45
|
+
"GIT_NAMESPACE",
|
|
46
|
+
"GIT_PREFIX",
|
|
47
|
+
"GIT_CEILING_DIRECTORIES",
|
|
48
|
+
"GIT_QUARANTINE_PATH",
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
export const gitEnv = (): NodeJS.ProcessEnv =>
|
|
52
|
+
Object.fromEntries(
|
|
53
|
+
Object.entries(process.env).filter(([key]) => !AMBIENT_GIT.includes(key)),
|
|
54
|
+
);
|
|
55
|
+
|
|
29
56
|
const git = (repoRoot: string, args: ReadonlyArray<string>): string =>
|
|
30
57
|
execFileSync("git", args, {
|
|
31
58
|
cwd: repoRoot,
|
|
32
59
|
encoding: "utf8",
|
|
60
|
+
env: gitEnv(),
|
|
33
61
|
stdio: ["ignore", "pipe", "ignore"],
|
|
34
62
|
maxBuffer: 256 * 1024 * 1024,
|
|
35
63
|
});
|
|
@@ -149,6 +177,7 @@ export const materializeTree = (
|
|
|
149
177
|
["-c", `git archive ${JSON.stringify(ref)} | tar -x -C ${JSON.stringify(root)}`],
|
|
150
178
|
{
|
|
151
179
|
cwd: repoRoot,
|
|
180
|
+
env: gitEnv(),
|
|
152
181
|
stdio: ["ignore", "ignore", "ignore"],
|
|
153
182
|
},
|
|
154
183
|
);
|
|
@@ -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,
|
package/src/load/extension.ts
CHANGED
|
@@ -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
|
|
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.
|
|
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,
|
package/src/manifest/lower.ts
CHANGED
|
@@ -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
|
}),
|
package/src/manifest/spec.ts
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
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.
|
|
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;
|