@boxxy/rules 0.1.0 → 0.1.4

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 (44) hide show
  1. package/README.md +8 -7
  2. package/dist/dry-run/dry-run-rules.d.ts +9 -0
  3. package/dist/index.d.ts +4 -1
  4. package/dist/loading/collect-filter-rejections.d.ts +3 -0
  5. package/dist/loading/collect-recipe-rejections.d.ts +3 -0
  6. package/dist/loading/collect-step-rejections.d.ts +3 -0
  7. package/dist/{load-rules.d.ts → loading/load-rules.d.ts} +2 -1
  8. package/dist/loading/to-filter.d.ts +3 -0
  9. package/dist/parse/rules-file-schema.d.ts +84 -0
  10. package/dist/{build-registry.d.ts → registry/build-registry.d.ts} +1 -1
  11. package/dist/{resolve-plugin.d.ts → registry/resolve-plugin.d.ts} +1 -1
  12. package/dist/{resolve-signal-ref.d.ts → registry/resolve-signal-ref.d.ts} +1 -1
  13. package/dist/test-utils/build-example-load-input.d.ts +11 -0
  14. package/dist/test-utils/load-mock-rules.d.ts +7 -0
  15. package/dist/types.d.ts +12 -1
  16. package/example/prompts/build-v1.md +4 -0
  17. package/example/prompts/design-attended-v1.md +4 -0
  18. package/example/prompts/design-v1.md +4 -0
  19. package/example/prompts/review-v1.md +4 -0
  20. package/example/prompts/triage-v1.md +5 -0
  21. package/example/rules.yaml +110 -0
  22. package/package.json +7 -4
  23. package/src/dry-run/dry-run-rules.ts +53 -0
  24. package/src/index.ts +4 -1
  25. package/src/{build-rejection.ts → loading/build-rejection.ts} +1 -1
  26. package/src/loading/collect-filter-rejections.ts +44 -0
  27. package/src/loading/collect-recipe-rejections.ts +60 -0
  28. package/src/loading/collect-step-rejections.ts +131 -0
  29. package/src/{load-rules.ts → loading/load-rules.ts} +112 -74
  30. package/src/loading/to-filter.ts +12 -0
  31. package/src/parse/rules-file-schema.ts +177 -0
  32. package/src/{build-registry.ts → registry/build-registry.ts} +2 -2
  33. package/src/{resolve-plugin.ts → registry/resolve-plugin.ts} +2 -2
  34. package/src/{resolve-signal-ref.ts → registry/resolve-signal-ref.ts} +2 -2
  35. package/src/test-utils/build-example-load-input.ts +90 -0
  36. package/src/test-utils/load-mock-rules.ts +84 -0
  37. package/src/types.ts +30 -1
  38. package/dist/rules-file-schema.d.ts +0 -49
  39. package/src/rules-file-schema.ts +0 -112
  40. /package/dist/{build-rejection.d.ts → loading/build-rejection.d.ts} +0 -0
  41. /package/dist/{parse-concept-entry.d.ts → parse/parse-concept-entry.d.ts} +0 -0
  42. /package/dist/{parse-rules-file.d.ts → parse/parse-rules-file.d.ts} +0 -0
  43. /package/src/{parse-concept-entry.ts → parse/parse-concept-entry.ts} +0 -0
  44. /package/src/{parse-rules-file.ts → parse/parse-rules-file.ts} +0 -0
@@ -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
+ }
@@ -6,25 +6,36 @@ import type {
6
6
  ConceptPin,
7
7
  ItemKindName,
8
8
  KindRules,
9
+ Limit,
9
10
  LoadRejection,
10
11
  LoadResult,
11
- LoadedRecipe,
12
+ PhaseKind,
13
+ PhaseName,
14
+ Pipeline,
15
+ PipelineStep,
12
16
  Pool,
17
+ RecipeMaterial,
13
18
  RecipeName,
19
+ RelationType,
20
+ RelationTypeName,
14
21
  RulesVersion,
15
22
  } from '@boxxy/model';
16
23
  import { brandSchemas } from '@boxxy/model';
17
24
  import invariant from 'tiny-invariant';
18
25
  import { match } from 'ts-pattern';
19
- import { buildRegistry } from './build-registry.ts';
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';
20
34
  import { buildRejection } from './build-rejection.ts';
21
- import { rulesFileName } from './constants.ts';
22
- import { parseConceptEntry } from './parse-concept-entry.ts';
23
- import { parseRulesFile } from './parse-rules-file.ts';
24
- import { resolvePlugin } from './resolve-plugin.ts';
25
- import { resolveSignalRef } from './resolve-signal-ref.ts';
26
- import type { ParsedRulesFile } from './rules-file-schema.ts';
27
- import type { Checked, PinnedConcept, ResolvedSignal } from './types.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';
28
39
 
29
40
  interface LoadRulesInput {
30
41
  readonly checkout: {
@@ -33,6 +44,9 @@ interface LoadRulesInput {
33
44
  };
34
45
  readonly concepts: readonly Concept[];
35
46
  readonly pins: ReadonlyMap<ConceptName, ConceptPin>;
47
+
48
+ // the phase kinds the server registers, as it registers the concepts
49
+ readonly phases: readonly PhaseKind[];
36
50
  }
37
51
 
38
52
  export function loadRules(input: LoadRulesInput): LoadResult {
@@ -66,6 +80,7 @@ function buildLoadResult(
66
80
  );
67
81
 
68
82
  const registry = built.value;
83
+ const phases = buildPhases(input.phases);
69
84
  const lead = file.lead.map((ref, index) => resolveSignalRef(ref, ['lead', index], registry));
70
85
 
71
86
  const pools = file.pools.map((pool, poolIndex) => ({
@@ -93,7 +108,10 @@ function buildLoadResult(
93
108
  ...built.rejections,
94
109
  ...[...signals, ...gates].flatMap((checked) => checked.rejections),
95
110
  ...collectPropagationRejections(file, propagation),
96
- ...collectMissingRecipes(file),
111
+ ...collectStepRejections(file, phases),
112
+ ...collectRecipeRejections(file),
113
+ ...collectFilterRejections(file, registry),
114
+ ...collectSleepRejections(file),
97
115
  ...prompts.rejections,
98
116
  ];
99
117
 
@@ -101,9 +119,9 @@ function buildLoadResult(
101
119
  return { kind: 'rejected', rejections };
102
120
  }
103
121
 
104
- const version = buildRulesVersion(
105
- input.checkout.commit,
106
- [
122
+ const version = buildRulesVersion({
123
+ commit: input.checkout.commit,
124
+ files: [
107
125
  { path: rulesFileName, bytes: rulesBytes, executable: false },
108
126
  ...prompts.value.map((prompt) => ({
109
127
  path: prompt.recipe.prompt,
@@ -111,11 +129,11 @@ function buildLoadResult(
111
129
  executable: false,
112
130
  })),
113
131
  ],
114
- registry.pins,
115
- );
132
+ pins: registry.pins,
133
+ phases: input.phases,
134
+ });
116
135
 
117
136
  const leadKeys = lead.map((checked) => getValue(checked).key);
118
- const kinds = buildKinds(file);
119
137
 
120
138
  return {
121
139
  kind: 'loaded',
@@ -127,22 +145,21 @@ function buildLoadResult(
127
145
  signals.map((checked) => [getValue(checked).key, getValue(checked).resolved]),
128
146
  ),
129
147
  gates: gates.map((checked) => getValue(checked)),
130
- kinds,
131
- maxAttemptsByKind: new Map(
132
- [...kinds].map(([kind, kindRules]) => [
133
- kind,
134
- new Map(kindRules.pipeline.steps.map((step) => [step.phase, step.maxAttempts])),
135
- ]),
136
- ),
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
+ })),
137
155
  pools: pools.map((entry): Pool => ({
138
156
  name: entry.pool.name,
139
- slots: entry.pool.slots,
140
- takes: buildTakes(entry.pool.takes),
157
+ takes: toFilter(entry.pool.takes),
141
158
  order: [...new Set([...leadKeys, ...entry.order.map((checked) => getValue(checked).key)])],
142
159
  })),
143
160
  propagation: file.propagation,
144
161
  recipes: new Map(
145
- prompts.value.map((prompt): [RecipeName, LoadedRecipe] => [
162
+ prompts.value.map((prompt): [RecipeName, RecipeMaterial] => [
146
163
  prompt.name,
147
164
  {
148
165
  name: prompt.name,
@@ -152,6 +169,11 @@ function buildLoadResult(
152
169
  },
153
170
  ]),
154
171
  ),
172
+ sleep: file.sleep,
173
+ skills: file.skills ?? null,
174
+ relationTypes: new Map(
175
+ file.relationTypes.map((name): [RelationTypeName, RelationType] => [name, { name }]),
176
+ ),
155
177
  },
156
178
  warnings: [],
157
179
  };
@@ -192,6 +214,14 @@ function getPin(pins: ReadonlyMap<ConceptName, ConceptPin>, concept: Concept): C
192
214
  return pin;
193
215
  }
194
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
+
195
225
  interface RecipePrompt {
196
226
  readonly name: RecipeName;
197
227
  readonly recipe: ParsedRulesFile['recipes'][RecipeName];
@@ -223,21 +253,21 @@ function collectPrompts(
223
253
  return { value: prompts, rejections };
224
254
  }
225
255
 
226
- const knownLinkTypes: ReadonlySet<string> = new Set(['blocks']);
227
-
228
256
  function collectPropagationRejections(
229
257
  file: ParsedRulesFile,
230
258
  signals: readonly Checked<ResolvedSignal>[],
231
259
  ): LoadRejection[] {
260
+ const relationTypes: ReadonlySet<string> = new Set(file.relationTypes);
261
+
232
262
  return file.propagation.flatMap((line, index) => {
233
263
  const rejections: LoadRejection[] = [];
234
264
  const signal = signals[index]?.value ?? null;
235
265
 
236
- if (!knownLinkTypes.has(line.over)) {
266
+ if (!relationTypes.has(line.over)) {
237
267
  rejections.push(
238
268
  buildRejection(['propagation', index, 'over'], {
239
- kind: 'unknown-link-type',
240
- link: line.over,
269
+ kind: 'unknown-relation-type',
270
+ relation: line.over,
241
271
  }),
242
272
  );
243
273
  }
@@ -255,34 +285,35 @@ function collectPropagationRejections(
255
285
  });
256
286
  }
257
287
 
258
- function collectMissingRecipes(file: ParsedRulesFile): LoadRejection[] {
259
- return Object.entries(file.kinds).flatMap(([itemKind, kind]) =>
260
- kind.pipeline
261
- .filter(
262
- (phase) =>
263
- !Object.hasOwn(kind.recipes, phase) ||
264
- !Object.hasOwn(file.recipes, kind.recipes[phase] ?? ''),
265
- )
266
- .map((phase) =>
267
- buildRejection(['kinds', itemKind, 'recipes', phase], {
268
- kind: 'missing-recipe',
269
- itemKind,
270
- phase,
271
- }),
272
- ),
273
- );
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[];
274
306
  }
275
307
 
276
- function buildRulesVersion(
277
- commit: string,
278
- files: readonly TreeFile[],
279
- pins: readonly ConceptPin[],
280
- ): RulesVersion {
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 {
281
311
  return brandSchemas.rulesVersion.parse(
282
312
  contentHash({
283
- commit,
284
- files: hashTree(files),
285
- pins: pins.map((pin) => ({ ref: pin.ref, sourceHash: pin.sourceHash })),
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(),
286
317
  }),
287
318
  );
288
319
  }
@@ -294,27 +325,34 @@ function getValue<T>(checked: Checked<T>): T {
294
325
  }
295
326
 
296
327
  function buildKinds(file: ParsedRulesFile): ReadonlyMap<ItemKindName, KindRules> {
297
- return new Map(
298
- Object.entries(file.kinds).map(([name, kind]): [ItemKindName, KindRules] => [
299
- brandSchemas.itemKindName.parse(name),
300
- {
301
- pipeline: {
302
- name: brandSchemas.pipelineName.parse(name),
303
- steps: kind.steps,
304
- },
305
- recipes: kind.recipes,
306
- },
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)) },
307
332
  ]),
308
333
  );
309
- }
310
334
 
311
- function buildTakes(
312
- takes: ParsedRulesFile['pools'][number]['takes'],
313
- ): Readonly<Record<string, readonly string[]>> {
314
- return Object.fromEntries(
315
- Object.entries(takes).map(([field, value]) => [
316
- field,
317
- typeof value === 'string' ? [value] : value,
318
- ]),
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
+ }),
319
346
  );
320
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,177 @@
1
+ import type { Effort } from '@boxxy/model';
2
+ import { brandSchemas } from '@boxxy/model';
3
+ import { z } from 'zod';
4
+ import { parseConceptEntry } from './parse-concept-entry.ts';
5
+
6
+ const nameSchema = z.string().min(1);
7
+
8
+ const pluginRefSchema = z.union([
9
+ nameSchema,
10
+ z.record(nameSchema, z.json()).refine((ref) => Object.keys(ref).length === 1, {
11
+ message: 'a plug-in reference with parameters is a map with one key, the plug-in name',
12
+ }),
13
+ ]);
14
+
15
+ const edgeSchema = z.union([
16
+ z.literal('next'),
17
+ z.literal('again'),
18
+ z.strictObject({
19
+ to: brandSchemas.phaseName,
20
+ max: z.int().positive().exactOptional(),
21
+ resume: z.boolean().exactOptional(),
22
+ }),
23
+ z.strictObject({ end: nameSchema }),
24
+ z.strictObject({ hold: brandSchemas.rulesHoldReason }),
25
+ ]);
26
+
27
+ // `timeoutMinutes` is optional here so that the loader refuses a step without it by its own reason
28
+ const stepSchema = z.strictObject({
29
+ phase: brandSchemas.phaseName,
30
+ maxAttempts: z.int().positive(),
31
+ timeoutMinutes: z.int().positive().optional(),
32
+ on: z.record(nameSchema, edgeSchema),
33
+ });
34
+
35
+ const kindSchema = z.strictObject({
36
+ pipeline: brandSchemas.pipelineName,
37
+
38
+ // by phase, and by `<phase>/attended` for an attended session of the phase
39
+ recipes: z.record(nameSchema, brandSchemas.recipeName),
40
+ });
41
+
42
+ const filterSchema = z.record(nameSchema, z.union([nameSchema, z.array(nameSchema).min(1)]));
43
+
44
+ const limitSchema = z.strictObject({
45
+ name: brandSchemas.limitName,
46
+ max: z.int().positive(),
47
+ counts: filterSchema,
48
+ });
49
+
50
+ const poolSchema = z.strictObject({
51
+ name: brandSchemas.poolName,
52
+ slots: z.never({ error: 'a pool has no slot count; a limit caps its attempts' }).optional(),
53
+ takes: filterSchema,
54
+ order: z.array(pluginRefSchema),
55
+ });
56
+
57
+ const efforts = ['low', 'medium', 'high', 'xhigh', 'max'] as const satisfies readonly Effort[];
58
+
59
+ const recipeSchema = z.strictObject({
60
+ agent: nameSchema,
61
+ model: nameSchema,
62
+ effort: z.enum(efforts),
63
+ prompt: nameSchema.refine(isCheckoutPath, {
64
+ message: 'a prompt path runs from the checkout root, with no empty, "." or ".." folder',
65
+ }),
66
+ source: brandSchemas.sourceName,
67
+ skills: z.array(nameSchema),
68
+ });
69
+
70
+ const skillsPinSchema = z.strictObject({
71
+ source: brandSchemas.repoName,
72
+ ref: brandSchemas.commitSha,
73
+ });
74
+
75
+ const conceptEntrySchema = z.templateLiteral([nameSchema, '@', z.int().positive()]);
76
+
77
+ const propagationLineSchema = z.strictObject({
78
+ value: nameSchema,
79
+ over: brandSchemas.relationTypeName,
80
+ toward: z.enum(['source', 'target']),
81
+ });
82
+
83
+ const minutesSchema = z.int().positive();
84
+
85
+ export const rulesFileSchema = z
86
+ .strictObject({
87
+ version: z.literal(1),
88
+ concepts: z.array(conceptEntrySchema),
89
+ pipelines: z.record(brandSchemas.pipelineName, z.array(stepSchema).min(1)),
90
+ kinds: z.record(brandSchemas.itemKindName, kindSchema),
91
+ skills: skillsPinSchema.optional(),
92
+ relationTypes: z.array(brandSchemas.relationTypeName),
93
+ recipes: z.record(brandSchemas.recipeName, recipeSchema),
94
+ limits: z.array(limitSchema),
95
+ pools: z.array(poolSchema),
96
+ lead: z.array(pluginRefSchema).default([]),
97
+ propagation: z.array(propagationLineSchema).default([]),
98
+ sleep: z.strictObject({
99
+ afterIdleMinutes: minutesSchema,
100
+ afterQuietMinutes: minutesSchema,
101
+ afterUnattendedMinutes: minutesSchema,
102
+ }),
103
+ })
104
+ .superRefine((file, ctx) => {
105
+ for (const issue of [
106
+ ...collectRepeats(
107
+ file.concepts.map((entry) => parseConceptEntry(entry).name),
108
+ ['concepts'],
109
+ [],
110
+ (name) => `the concept ${name} is pinned twice`,
111
+ ),
112
+ ...collectRepeats(
113
+ file.pools.map((pool) => pool.name),
114
+ ['pools'],
115
+ ['name'],
116
+ (name) => `the pool name ${name} is used twice`,
117
+ ),
118
+ ...collectRepeats(
119
+ file.limits.map((limit) => limit.name),
120
+ ['limits'],
121
+ ['name'],
122
+ (name) => `the limit name ${name} is used twice`,
123
+ ),
124
+ ...collectRepeats(
125
+ file.relationTypes,
126
+ ['relationTypes'],
127
+ [],
128
+ (name) => `the relation type ${name} is listed twice`,
129
+ ),
130
+ ...Object.entries(file.pipelines).flatMap(([pipeline, steps]) =>
131
+ collectRepeats(
132
+ steps.map((step) => step.phase),
133
+ ['pipelines', pipeline],
134
+ ['phase'],
135
+ (phase) => `the pipeline ${pipeline} names the phase ${phase} twice`,
136
+ ),
137
+ ),
138
+ ...Object.entries(file.kinds).flatMap(([kind, rules]) =>
139
+ Object.hasOwn(file.pipelines, rules.pipeline)
140
+ ? []
141
+ : [
142
+ {
143
+ path: ['kinds', kind, 'pipeline'],
144
+ message: `the file has no pipeline ${rules.pipeline}`,
145
+ },
146
+ ],
147
+ ),
148
+ ]) {
149
+ ctx.addIssue({ code: 'custom', ...issue });
150
+ }
151
+ });
152
+
153
+ export type ParsedRulesFile = z.output<typeof rulesFileSchema>;
154
+
155
+ export type ParsedStep = z.output<typeof stepSchema>;
156
+
157
+ function isCheckoutPath(path: string): boolean {
158
+ return path.split('/').every((segment) => segment !== '' && segment !== '.' && segment !== '..');
159
+ }
160
+
161
+ interface FileIssue {
162
+ readonly path: PropertyKey[];
163
+ readonly message: string;
164
+ }
165
+
166
+ function collectRepeats(
167
+ names: readonly string[],
168
+ listPath: readonly PropertyKey[],
169
+ itemPath: readonly PropertyKey[],
170
+ formatMessage: (name: string) => string,
171
+ ): FileIssue[] {
172
+ return names.flatMap((name, index) =>
173
+ names.indexOf(name) === index
174
+ ? []
175
+ : [{ path: [...listPath, index, ...itemPath], message: formatMessage(name) }],
176
+ );
177
+ }
@@ -1,6 +1,6 @@
1
1
  import type { ConceptPin, LoadRejection, Registry } from '@boxxy/model';
2
- import { buildRejection } from './build-rejection.ts';
3
- import type { PinnedConcept } from './types.ts';
2
+ import { buildRejection } from '../loading/build-rejection.ts';
3
+ import type { PinnedConcept } from '../types.ts';
4
4
 
5
5
  export function buildRegistry(
6
6
  pinned: readonly PinnedConcept[],
@@ -2,8 +2,8 @@ import type { JsonValue } from '@boxxy/content-hash';
2
2
  import type { ParamKindName, Registered, Resolved } from '@boxxy/model';
3
3
  import { formatKeyPath } from '@boxxy/model';
4
4
  import type { z } from 'zod';
5
- import { buildRejection } from './build-rejection.ts';
6
- import type { Checked } from './types.ts';
5
+ import { buildRejection } from '../loading/build-rejection.ts';
6
+ import type { Checked } from '../types.ts';
7
7
 
8
8
  export function resolvePlugin<K extends ParamKindName>(
9
9
  registered: Registered<K>,
@@ -2,9 +2,9 @@ import type { JsonValue } from '@boxxy/content-hash';
2
2
  import type { Registry, SignalRef } from '@boxxy/model';
3
3
  import { signalKey } from '@boxxy/model';
4
4
  import invariant from 'tiny-invariant';
5
- import { buildRejection } from './build-rejection.ts';
5
+ import { buildRejection } from '../loading/build-rejection.ts';
6
+ import type { Checked, ResolvedSignal } from '../types.ts';
6
7
  import { resolvePlugin } from './resolve-plugin.ts';
7
- import type { Checked, ResolvedSignal } from './types.ts';
8
8
 
9
9
  export function resolveSignalRef(
10
10
  ref: SignalRef,