@boxxy/rules 0.0.0-stage → 0.1.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.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +41 -2
  3. package/dist/constants.d.ts +1 -0
  4. package/dist/dry-run/dry-run-rules.d.ts +9 -0
  5. package/dist/index.d.ts +4 -0
  6. package/dist/loading/build-rejection.d.ts +2 -0
  7. package/dist/loading/collect-filter-rejections.d.ts +3 -0
  8. package/dist/loading/collect-recipe-rejections.d.ts +3 -0
  9. package/dist/loading/collect-step-rejections.d.ts +3 -0
  10. package/dist/loading/load-rules.d.ts +12 -0
  11. package/dist/loading/to-filter.d.ts +3 -0
  12. package/dist/parse/parse-concept-entry.d.ts +5 -0
  13. package/dist/parse/parse-rules-file.d.ts +11 -0
  14. package/dist/parse/rules-file-schema.d.ts +84 -0
  15. package/dist/registry/build-registry.d.ts +6 -0
  16. package/dist/registry/resolve-plugin.d.ts +4 -0
  17. package/dist/registry/resolve-signal-ref.d.ts +3 -0
  18. package/dist/test-utils/build-example-load-input.d.ts +11 -0
  19. package/dist/test-utils/load-mock-rules.d.ts +7 -0
  20. package/dist/types.d.ts +24 -0
  21. package/example/prompts/build-v1.md +4 -0
  22. package/example/prompts/design-attended-v1.md +4 -0
  23. package/example/prompts/design-v1.md +4 -0
  24. package/example/prompts/review-v1.md +4 -0
  25. package/example/prompts/triage-v1.md +5 -0
  26. package/example/rules.yaml +110 -0
  27. package/package.json +45 -4
  28. package/src/constants.ts +1 -0
  29. package/src/dry-run/dry-run-rules.ts +53 -0
  30. package/src/index.ts +4 -0
  31. package/src/loading/build-rejection.ts +10 -0
  32. package/src/loading/collect-filter-rejections.ts +44 -0
  33. package/src/loading/collect-recipe-rejections.ts +60 -0
  34. package/src/loading/collect-step-rejections.ts +131 -0
  35. package/src/loading/load-rules.ts +358 -0
  36. package/src/loading/to-filter.ts +12 -0
  37. package/src/parse/parse-concept-entry.ts +10 -0
  38. package/src/parse/parse-rules-file.ts +109 -0
  39. package/src/parse/rules-file-schema.ts +177 -0
  40. package/src/registry/build-registry.ts +67 -0
  41. package/src/registry/resolve-plugin.ts +51 -0
  42. package/src/registry/resolve-signal-ref.ts +43 -0
  43. package/src/test-utils/build-example-load-input.ts +90 -0
  44. package/src/test-utils/load-mock-rules.ts +84 -0
  45. package/src/types.ts +45 -0
@@ -0,0 +1,60 @@
1
+ import type { LoadRejection } from '@boxxy/model';
2
+ import { brandSchemas } from '@boxxy/model';
3
+ import invariant from 'tiny-invariant';
4
+ import type { ParsedRulesFile } from '../parse/rules-file-schema.ts';
5
+ import { buildRejection } from './build-rejection.ts';
6
+
7
+ export function collectRecipeRejections(file: ParsedRulesFile): LoadRejection[] {
8
+ return [
9
+ ...collectUnmappedSteps(file),
10
+ ...collectUndefinedRecipes(file),
11
+ ...collectSkillsRejections(file),
12
+ ];
13
+ }
14
+
15
+ function collectUnmappedSteps(file: ParsedRulesFile): LoadRejection[] {
16
+ return Object.entries(file.kinds).flatMap(([itemKind, kind]) => {
17
+ const steps = file.pipelines[kind.pipeline];
18
+
19
+ invariant(
20
+ steps,
21
+ `the rules file schema refuses the kind ${itemKind}, whose pipeline is missing`,
22
+ );
23
+
24
+ return steps
25
+ .filter((step) => !Object.hasOwn(kind.recipes, step.phase))
26
+ .map((step) =>
27
+ buildRejection(['kinds', itemKind, 'recipes', step.phase], {
28
+ kind: 'step-without-recipe',
29
+ itemKind: brandSchemas.itemKindName.parse(itemKind),
30
+ phase: step.phase,
31
+ }),
32
+ );
33
+ });
34
+ }
35
+
36
+ function collectUndefinedRecipes(file: ParsedRulesFile): LoadRejection[] {
37
+ return Object.entries(file.kinds).flatMap(([itemKind, kind]) =>
38
+ Object.entries(kind.recipes)
39
+ .filter(([, recipe]) => !Object.hasOwn(file.recipes, recipe))
40
+ .map(([phase]) =>
41
+ buildRejection(['kinds', itemKind, 'recipes', phase], {
42
+ kind: 'missing-recipe',
43
+ itemKind,
44
+ phase,
45
+ }),
46
+ ),
47
+ );
48
+ }
49
+
50
+ function collectSkillsRejections(file: ParsedRulesFile): LoadRejection[] {
51
+ if (file.skills !== undefined) {
52
+ return [];
53
+ }
54
+
55
+ return Object.entries(file.recipes)
56
+ .filter(([, recipe]) => recipe.skills.length > 0)
57
+ .map(([recipe]) =>
58
+ buildRejection(['recipes', recipe, 'skills'], { kind: 'skills-without-pin', recipe }),
59
+ );
60
+ }
@@ -0,0 +1,131 @@
1
+ import type { LoadRejection, PhaseKind, PhaseName } from '@boxxy/model';
2
+ import { engineHoldReasons, engineOutcomes } from '@boxxy/model';
3
+ import { P, match } from 'ts-pattern';
4
+ import type { ParsedRulesFile, ParsedStep } from '../parse/rules-file-schema.ts';
5
+ import { buildRejection } from './build-rejection.ts';
6
+
7
+ export function collectStepRejections(
8
+ file: ParsedRulesFile,
9
+ phases: ReadonlyMap<PhaseName, PhaseKind>,
10
+ ): LoadRejection[] {
11
+ return Object.entries(file.pipelines).flatMap(([pipeline, steps]) =>
12
+ steps.flatMap((step, index) => {
13
+ const path = ['pipelines', pipeline, index];
14
+ const phase = phases.get(step.phase);
15
+
16
+ return [
17
+ ...(phase === undefined
18
+ ? [buildRejection([...path, 'phase'], { kind: 'unknown-phase', phase: step.phase })]
19
+ : collectOutcomeRejections(step, phase, path)),
20
+ ...(step.timeoutMinutes === undefined
21
+ ? [buildRejection(path, { kind: 'step-without-timeout', phase: step.phase })]
22
+ : []),
23
+ ...Object.entries(step.on).flatMap(([outcome, edge]) =>
24
+ collectEdgeRejections(edge, {
25
+ steps,
26
+ index,
27
+ phase: step.phase,
28
+ outcome,
29
+ path: [...path, 'on', outcome],
30
+ }),
31
+ ),
32
+ ];
33
+ }),
34
+ );
35
+ }
36
+
37
+ function collectOutcomeRejections(
38
+ step: ParsedStep,
39
+ phase: PhaseKind,
40
+ path: readonly PropertyKey[],
41
+ ): LoadRejection[] {
42
+ const listed: readonly string[] = phase.outcomes;
43
+
44
+ return [
45
+ ...engineOutcomes
46
+ .filter((outcome) => listed.includes(outcome))
47
+ .map((outcome) =>
48
+ buildRejection([...path, 'phase'], {
49
+ kind: 'engine-outcome-listed',
50
+ phase: step.phase,
51
+ outcome,
52
+ }),
53
+ ),
54
+ ...listed
55
+ .filter((outcome) => !Object.hasOwn(step.on, outcome))
56
+ .map((outcome) =>
57
+ buildRejection([...path, 'on'], { kind: 'unmapped-outcome', phase: step.phase, outcome }),
58
+ ),
59
+ ...Object.keys(step.on)
60
+ .filter((outcome) => !listed.includes(outcome))
61
+ .map((outcome) =>
62
+ buildRejection([...path, 'on', outcome], {
63
+ kind: 'unknown-outcome',
64
+ phase: step.phase,
65
+ outcome,
66
+ }),
67
+ ),
68
+ ];
69
+ }
70
+
71
+ type ParsedEdge = ParsedStep['on'][string];
72
+
73
+ interface EdgeContext {
74
+ readonly steps: readonly ParsedStep[];
75
+ readonly index: number;
76
+ readonly phase: PhaseName;
77
+ readonly outcome: string;
78
+ readonly path: readonly PropertyKey[];
79
+ }
80
+
81
+ function collectEdgeRejections(edge: ParsedEdge, context: EdgeContext): LoadRejection[] {
82
+ const engineReasons: readonly string[] = engineHoldReasons;
83
+
84
+ return match(edge)
85
+ .with(P.string, { end: P.string }, () => [])
86
+ .with({ hold: P.string }, (held) =>
87
+ engineReasons.includes(held.hold)
88
+ ? [
89
+ buildRejection([...context.path, 'hold'], {
90
+ kind: 'engine-hold-reason',
91
+ phase: context.phase,
92
+ outcome: context.outcome,
93
+ reason: held.hold,
94
+ }),
95
+ ]
96
+ : [],
97
+ )
98
+ .with({ to: P.string }, (jump) => collectJumpRejections(jump, context))
99
+ .exhaustive();
100
+ }
101
+
102
+ function collectJumpRejections(
103
+ jump: Extract<ParsedEdge, { to: string }>,
104
+ context: EdgeContext,
105
+ ): LoadRejection[] {
106
+ const target = context.steps.findIndex((step) => step.phase === jump.to);
107
+ const edge = { phase: context.phase, outcome: context.outcome };
108
+
109
+ if (target === -1) {
110
+ return [
111
+ buildRejection(context.path, {
112
+ kind: 'edge-to-unknown-phase',
113
+ phase: context.phase,
114
+ to: jump.to,
115
+ }),
116
+ ];
117
+ }
118
+
119
+ // an edge to its own step loops, as a backward edge does
120
+ const isBack = target <= context.index;
121
+
122
+ if (isBack && jump.max === undefined) {
123
+ return [buildRejection(context.path, { kind: 'open-loop', ...edge })];
124
+ }
125
+
126
+ if (!isBack && jump.resume === true) {
127
+ return [buildRejection(context.path, { kind: 'resume-on-forward-edge', ...edge })];
128
+ }
129
+
130
+ return [];
131
+ }
@@ -0,0 +1,358 @@
1
+ import type { TreeFile } from '@boxxy/content-hash';
2
+ import { contentHash, hashBytes, hashTree } from '@boxxy/content-hash';
3
+ import type {
4
+ Concept,
5
+ ConceptName,
6
+ ConceptPin,
7
+ ItemKindName,
8
+ KindRules,
9
+ Limit,
10
+ LoadRejection,
11
+ LoadResult,
12
+ PhaseKind,
13
+ PhaseName,
14
+ Pipeline,
15
+ PipelineStep,
16
+ Pool,
17
+ RecipeMaterial,
18
+ RecipeName,
19
+ RelationType,
20
+ RelationTypeName,
21
+ RulesVersion,
22
+ } from '@boxxy/model';
23
+ import { brandSchemas } from '@boxxy/model';
24
+ import invariant from 'tiny-invariant';
25
+ import { match } from 'ts-pattern';
26
+ import { rulesFileName } from '../constants.ts';
27
+ import { parseConceptEntry } from '../parse/parse-concept-entry.ts';
28
+ import { parseRulesFile } from '../parse/parse-rules-file.ts';
29
+ import type { ParsedRulesFile, ParsedStep } from '../parse/rules-file-schema.ts';
30
+ import { buildRegistry } from '../registry/build-registry.ts';
31
+ import { resolvePlugin } from '../registry/resolve-plugin.ts';
32
+ import { resolveSignalRef } from '../registry/resolve-signal-ref.ts';
33
+ import type { Checked, PinnedConcept, ResolvedSignal } from '../types.ts';
34
+ import { buildRejection } from './build-rejection.ts';
35
+ import { collectFilterRejections } from './collect-filter-rejections.ts';
36
+ import { collectRecipeRejections } from './collect-recipe-rejections.ts';
37
+ import { collectStepRejections } from './collect-step-rejections.ts';
38
+ import { toFilter } from './to-filter.ts';
39
+
40
+ interface LoadRulesInput {
41
+ readonly checkout: {
42
+ readonly commit: string;
43
+ readonly files: ReadonlyMap<string, Uint8Array>;
44
+ };
45
+ readonly concepts: readonly Concept[];
46
+ readonly pins: ReadonlyMap<ConceptName, ConceptPin>;
47
+
48
+ // the phase kinds the server registers, as it registers the concepts
49
+ readonly phases: readonly PhaseKind[];
50
+ }
51
+
52
+ export function loadRules(input: LoadRulesInput): LoadResult {
53
+ const rulesBytes = input.checkout.files.get(rulesFileName);
54
+
55
+ if (rulesBytes === undefined) {
56
+ return {
57
+ kind: 'rejected',
58
+ rejections: [
59
+ buildRejection([], { kind: 'invalid', message: `the checkout has no ${rulesFileName}` }),
60
+ ],
61
+ };
62
+ }
63
+
64
+ return match(parseRulesFile(rulesFileName, rulesBytes))
65
+ .with({ kind: 'rejected' }, (rejected): LoadResult => rejected)
66
+ .with({ kind: 'parsed' }, (parsed) => buildLoadResult(parsed.file, rulesBytes, input))
67
+ .exhaustive();
68
+ }
69
+
70
+ function buildLoadResult(
71
+ file: ParsedRulesFile,
72
+ rulesBytes: Uint8Array,
73
+ input: LoadRulesInput,
74
+ ): LoadResult {
75
+ const pinned = collectPinnedConcepts(file, input.concepts);
76
+
77
+ const built = buildRegistry(
78
+ pinned.value,
79
+ pinned.value.map((entry) => getPin(input.pins, entry.concept)),
80
+ );
81
+
82
+ const registry = built.value;
83
+ const phases = buildPhases(input.phases);
84
+ const lead = file.lead.map((ref, index) => resolveSignalRef(ref, ['lead', index], registry));
85
+
86
+ const pools = file.pools.map((pool, poolIndex) => ({
87
+ pool,
88
+ order: pool.order.map((ref, index) =>
89
+ resolveSignalRef(ref, ['pools', poolIndex, 'order', index], registry),
90
+ ),
91
+ }));
92
+
93
+ const propagation = file.propagation.map((line, index) =>
94
+ resolveSignalRef(line.value, ['propagation', index, 'value'], registry),
95
+ );
96
+
97
+ const gates = pinned.value.flatMap((entry) =>
98
+ (entry.concept.gates ?? []).map((gate) =>
99
+ resolvePlugin(gate, {}, gate.name, ['concepts', entry.entryIndex]),
100
+ ),
101
+ );
102
+
103
+ const prompts = collectPrompts(file, input.checkout.files);
104
+ const signals = [...lead, ...pools.flatMap((pool) => pool.order), ...propagation];
105
+
106
+ const rejections = [
107
+ ...pinned.rejections,
108
+ ...built.rejections,
109
+ ...[...signals, ...gates].flatMap((checked) => checked.rejections),
110
+ ...collectPropagationRejections(file, propagation),
111
+ ...collectStepRejections(file, phases),
112
+ ...collectRecipeRejections(file),
113
+ ...collectFilterRejections(file, registry),
114
+ ...collectSleepRejections(file),
115
+ ...prompts.rejections,
116
+ ];
117
+
118
+ if (rejections.length > 0) {
119
+ return { kind: 'rejected', rejections };
120
+ }
121
+
122
+ const version = buildRulesVersion({
123
+ commit: input.checkout.commit,
124
+ files: [
125
+ { path: rulesFileName, bytes: rulesBytes, executable: false },
126
+ ...prompts.value.map((prompt) => ({
127
+ path: prompt.recipe.prompt,
128
+ bytes: prompt.bytes,
129
+ executable: false,
130
+ })),
131
+ ],
132
+ pins: registry.pins,
133
+ phases: input.phases,
134
+ });
135
+
136
+ const leadKeys = lead.map((checked) => getValue(checked).key);
137
+
138
+ return {
139
+ kind: 'loaded',
140
+ rules: {
141
+ version,
142
+ commit: input.checkout.commit,
143
+ registry,
144
+ signals: new Map(
145
+ signals.map((checked) => [getValue(checked).key, getValue(checked).resolved]),
146
+ ),
147
+ gates: gates.map((checked) => getValue(checked)),
148
+ kinds: buildKinds(file),
149
+ phases,
150
+ limits: file.limits.map((limit): Limit => ({
151
+ name: limit.name,
152
+ max: limit.max,
153
+ counts: toFilter(limit.counts),
154
+ })),
155
+ pools: pools.map((entry): Pool => ({
156
+ name: entry.pool.name,
157
+ takes: toFilter(entry.pool.takes),
158
+ order: [...new Set([...leadKeys, ...entry.order.map((checked) => getValue(checked).key)])],
159
+ })),
160
+ propagation: file.propagation,
161
+ recipes: new Map(
162
+ prompts.value.map((prompt): [RecipeName, RecipeMaterial] => [
163
+ prompt.name,
164
+ {
165
+ name: prompt.name,
166
+ recipe: prompt.recipe,
167
+ promptHash: hashBytes(prompt.bytes),
168
+ rulesVersion: version,
169
+ },
170
+ ]),
171
+ ),
172
+ sleep: file.sleep,
173
+ skills: file.skills ?? null,
174
+ relationTypes: new Map(
175
+ file.relationTypes.map((name): [RelationTypeName, RelationType] => [name, { name }]),
176
+ ),
177
+ },
178
+ warnings: [],
179
+ };
180
+ }
181
+
182
+ function collectPinnedConcepts(
183
+ file: ParsedRulesFile,
184
+ concepts: readonly Concept[],
185
+ ): { readonly value: readonly PinnedConcept[]; readonly rejections: readonly LoadRejection[] } {
186
+ const pinned: PinnedConcept[] = [];
187
+ const rejections: LoadRejection[] = [];
188
+
189
+ for (const [entryIndex, entry] of file.concepts.entries()) {
190
+ const ref = parseConceptEntry(entry);
191
+
192
+ const concept = concepts.find(
193
+ (candidate) => candidate.name === ref.name && candidate.version === ref.version,
194
+ );
195
+
196
+ if (concept === undefined) {
197
+ rejections.push(
198
+ buildRejection(['concepts', entryIndex], { kind: 'unknown-concept', ref: entry }),
199
+ );
200
+ } else {
201
+ pinned.push({ concept, entryIndex });
202
+ }
203
+ }
204
+
205
+ return { value: pinned, rejections };
206
+ }
207
+
208
+ function getPin(pins: ReadonlyMap<ConceptName, ConceptPin>, concept: Concept): ConceptPin {
209
+ const ref = `${concept.name}@${concept.version}` as const;
210
+ const pin = pins.get(concept.name);
211
+
212
+ invariant(pin?.ref === ref, `the server passes a pin for each concept, and has none for ${ref}`);
213
+
214
+ return pin;
215
+ }
216
+
217
+ function buildPhases(phases: readonly PhaseKind[]): ReadonlyMap<PhaseName, PhaseKind> {
218
+ const byName = new Map(phases.map((phase) => [phase.name, phase]));
219
+
220
+ invariant(byName.size === phases.length, 'the server registers each phase kind once');
221
+
222
+ return byName;
223
+ }
224
+
225
+ interface RecipePrompt {
226
+ readonly name: RecipeName;
227
+ readonly recipe: ParsedRulesFile['recipes'][RecipeName];
228
+ readonly bytes: Uint8Array;
229
+ }
230
+
231
+ function collectPrompts(
232
+ file: ParsedRulesFile,
233
+ files: ReadonlyMap<string, Uint8Array>,
234
+ ): { readonly value: readonly RecipePrompt[]; readonly rejections: readonly LoadRejection[] } {
235
+ const prompts: RecipePrompt[] = [];
236
+ const rejections: LoadRejection[] = [];
237
+
238
+ for (const [name, recipe] of Object.entries(file.recipes)) {
239
+ const bytes = files.get(recipe.prompt);
240
+
241
+ if (bytes === undefined) {
242
+ rejections.push(
243
+ buildRejection(['recipes', name, 'prompt'], {
244
+ kind: 'missing-prompt',
245
+ path: recipe.prompt,
246
+ }),
247
+ );
248
+ } else {
249
+ prompts.push({ name: brandSchemas.recipeName.parse(name), recipe, bytes });
250
+ }
251
+ }
252
+
253
+ return { value: prompts, rejections };
254
+ }
255
+
256
+ function collectPropagationRejections(
257
+ file: ParsedRulesFile,
258
+ signals: readonly Checked<ResolvedSignal>[],
259
+ ): LoadRejection[] {
260
+ const relationTypes: ReadonlySet<string> = new Set(file.relationTypes);
261
+
262
+ return file.propagation.flatMap((line, index) => {
263
+ const rejections: LoadRejection[] = [];
264
+ const signal = signals[index]?.value ?? null;
265
+
266
+ if (!relationTypes.has(line.over)) {
267
+ rejections.push(
268
+ buildRejection(['propagation', index, 'over'], {
269
+ kind: 'unknown-relation-type',
270
+ relation: line.over,
271
+ }),
272
+ );
273
+ }
274
+
275
+ if (signal !== null && !signal.resolved.open((plugin) => plugin.combine !== undefined)) {
276
+ rejections.push(
277
+ buildRejection(['propagation', index, 'value'], {
278
+ kind: 'signal-without-combine',
279
+ signal: line.value,
280
+ }),
281
+ );
282
+ }
283
+
284
+ return rejections;
285
+ });
286
+ }
287
+
288
+ function collectSleepRejections(file: ParsedRulesFile): LoadRejection[] {
289
+ const sleep = file.sleep;
290
+
291
+ return [
292
+ ...(sleep.afterQuietMinutes < sleep.afterIdleMinutes
293
+ ? [buildRejection(['sleep', 'afterQuietMinutes'], { kind: 'quiet-below-idle' })]
294
+ : []),
295
+ ...(sleep.afterUnattendedMinutes < sleep.afterQuietMinutes
296
+ ? [buildRejection(['sleep', 'afterUnattendedMinutes'], { kind: 'unattended-below-quiet' })]
297
+ : []),
298
+ ];
299
+ }
300
+
301
+ interface RulesVersionInput {
302
+ readonly commit: string;
303
+ readonly files: readonly TreeFile[];
304
+ readonly pins: readonly ConceptPin[];
305
+ readonly phases: readonly PhaseKind[];
306
+ }
307
+
308
+ // A replay needs the exact plug-in code, so each concept pin and each phase kind's version are
309
+ // part of the version.
310
+ function buildRulesVersion(input: RulesVersionInput): RulesVersion {
311
+ return brandSchemas.rulesVersion.parse(
312
+ contentHash({
313
+ commit: input.commit,
314
+ files: hashTree(input.files),
315
+ pins: input.pins.map((pin) => ({ ref: pin.ref, sourceHash: pin.sourceHash })),
316
+ phases: input.phases.map((phase) => `${phase.name}@${phase.version}`).toSorted(),
317
+ }),
318
+ );
319
+ }
320
+
321
+ function getValue<T>(checked: Checked<T>): T {
322
+ invariant(checked.value !== null, 'the loader builds rules only when every check passed');
323
+
324
+ return checked.value;
325
+ }
326
+
327
+ function buildKinds(file: ParsedRulesFile): ReadonlyMap<ItemKindName, KindRules> {
328
+ const pipelines = new Map(
329
+ Object.entries(file.pipelines).map(([name, steps]): [string, Pipeline] => [
330
+ name,
331
+ { name: brandSchemas.pipelineName.parse(name), steps: steps.map((step) => buildStep(step)) },
332
+ ]),
333
+ );
334
+
335
+ return new Map(
336
+ Object.entries(file.kinds).map(([name, kind]): [ItemKindName, KindRules] => {
337
+ const pipeline = pipelines.get(kind.pipeline);
338
+
339
+ invariant(
340
+ pipeline,
341
+ `the rules file schema refuses the kind ${name}, whose pipeline is missing`,
342
+ );
343
+
344
+ return [brandSchemas.itemKindName.parse(name), { pipeline, recipes: kind.recipes }];
345
+ }),
346
+ );
347
+ }
348
+
349
+ function buildStep(step: ParsedStep): PipelineStep {
350
+ invariant(step.timeoutMinutes !== undefined, 'the loader refuses a step with no time limit');
351
+
352
+ return {
353
+ phase: step.phase,
354
+ maxAttempts: step.maxAttempts,
355
+ timeoutMinutes: step.timeoutMinutes,
356
+ on: step.on,
357
+ };
358
+ }
@@ -0,0 +1,12 @@
1
+ import type { Filter } from '@boxxy/model';
2
+ import type { ParsedRulesFile } from '../parse/rules-file-schema.ts';
3
+
4
+ // a filter in the rules file may give one value as a bare string; a loaded filter always holds a list
5
+ export function toFilter(filter: ParsedRulesFile['pools'][number]['takes']): Filter {
6
+ return Object.fromEntries(
7
+ Object.entries(filter).map(([field, value]) => [
8
+ field,
9
+ typeof value === 'string' ? [value] : value,
10
+ ]),
11
+ );
12
+ }
@@ -0,0 +1,10 @@
1
+ import type { ConceptEntry, ConceptName } from '@boxxy/model';
2
+
3
+ export function parseConceptEntry(entry: ConceptEntry): {
4
+ readonly name: ConceptName;
5
+ readonly version: number;
6
+ } {
7
+ const at = entry.lastIndexOf('@');
8
+
9
+ return { name: entry.slice(0, at), version: Number(entry.slice(at + 1)) };
10
+ }
@@ -0,0 +1,109 @@
1
+ import type { LoadRejection } from '@boxxy/model';
2
+ import { formatKeyPath } from '@boxxy/model';
3
+ import { match } from 'ts-pattern';
4
+ import type { Document } from 'yaml';
5
+ import { parseDocument } from 'yaml';
6
+ import type { ParsedRulesFile } from './rules-file-schema.ts';
7
+ import { rulesFileSchema } from './rules-file-schema.ts';
8
+
9
+ type RulesFileParse =
10
+ | { readonly kind: 'parsed'; readonly file: ParsedRulesFile }
11
+ | { readonly kind: 'rejected'; readonly rejections: readonly LoadRejection[] };
12
+
13
+ export function parseRulesFile(fileName: string, bytes: Uint8Array): RulesFileParse {
14
+ return match(toText(bytes))
15
+ .with({ kind: 'refused' }, (refused): RulesFileParse => ({
16
+ kind: 'rejected',
17
+ rejections: [buildInvalid(fileName, '', refused.message)],
18
+ }))
19
+ .with({ kind: 'converted' }, (text) => parseRulesText(fileName, text.value))
20
+ .exhaustive();
21
+ }
22
+
23
+ type Converted<T> =
24
+ | { readonly kind: 'converted'; readonly value: T }
25
+ | { readonly kind: 'refused'; readonly message: string };
26
+
27
+ function toText(bytes: Uint8Array): Converted<string> {
28
+ // TextDecoder reports bytes that aren't UTF-8 only by throwing, in its fatal mode
29
+ try {
30
+ return { kind: 'converted', value: new TextDecoder('utf-8', { fatal: true }).decode(bytes) };
31
+ } catch {
32
+ return { kind: 'refused', message: 'the file is not valid UTF-8' };
33
+ }
34
+ }
35
+
36
+ function buildInvalid(file: string, path: string, message: string): LoadRejection {
37
+ return { file, path, reason: { kind: 'invalid', message } };
38
+ }
39
+
40
+ function parseRulesText(fileName: string, text: string): RulesFileParse {
41
+ const document = parseDocument(text);
42
+
43
+ if (document.errors.length > 0) {
44
+ return {
45
+ kind: 'rejected',
46
+ rejections: document.errors.map((error) => buildInvalid(fileName, '', error.message)),
47
+ };
48
+ }
49
+
50
+ return match(toPlainValue(document))
51
+ .with({ kind: 'refused' }, (refused): RulesFileParse => ({
52
+ kind: 'rejected',
53
+ rejections: [buildInvalid(fileName, '', refused.message)],
54
+ }))
55
+ .with({ kind: 'converted' }, (plain) => parseRulesValue(fileName, plain.value))
56
+ .exhaustive();
57
+ }
58
+
59
+ function toPlainValue(document: Document): Converted<unknown> {
60
+ // yaml throws, and returns no error, on a document whose aliases expand past its alias limit
61
+ try {
62
+ const value: unknown = document.toJS();
63
+
64
+ return { kind: 'converted', value };
65
+ } catch (error) {
66
+ return { kind: 'refused', message: error instanceof Error ? error.message : String(error) };
67
+ }
68
+ }
69
+
70
+ function parseRulesValue(fileName: string, value: unknown): RulesFileParse {
71
+ // zod's record schema drops a `__proto__` key with no issue, so the loader would miss its value
72
+ const protoKeyPaths = collectProtoKeyPaths(value, []);
73
+
74
+ if (protoKeyPaths.length > 0) {
75
+ return {
76
+ kind: 'rejected',
77
+ rejections: protoKeyPaths.map((path) =>
78
+ buildInvalid(fileName, formatKeyPath(path), 'the key __proto__ is reserved'),
79
+ ),
80
+ };
81
+ }
82
+
83
+ const checked = rulesFileSchema.safeParse(value);
84
+
85
+ if (!checked.success) {
86
+ return {
87
+ kind: 'rejected',
88
+ rejections: checked.error.issues.map((issue) =>
89
+ buildInvalid(fileName, formatKeyPath(issue.path), issue.message),
90
+ ),
91
+ };
92
+ }
93
+
94
+ return { kind: 'parsed', file: checked.data };
95
+ }
96
+
97
+ function collectProtoKeyPaths(value: unknown, path: readonly PropertyKey[]): PropertyKey[][] {
98
+ if (Array.isArray(value)) {
99
+ return value.flatMap((item: unknown, index) => collectProtoKeyPaths(item, [...path, index]));
100
+ }
101
+
102
+ if (typeof value !== 'object' || value === null) {
103
+ return [];
104
+ }
105
+
106
+ return Object.entries(value).flatMap(([key, item]: [string, unknown]) =>
107
+ key === '__proto__' ? [[...path, key]] : collectProtoKeyPaths(item, [...path, key]),
108
+ );
109
+ }