@boxxy/rules 0.1.0 → 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 (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
package/README.md CHANGED
@@ -11,8 +11,9 @@
11
11
  </p>
12
12
  </div>
13
13
 
14
- boxxy's rules loader. `loadRules` checks a rules checkout's `rules.yaml` against the concepts boxxy
15
- compiles in, builds the plug-in registry, and gives the loaded rules a version.
14
+ boxxy's rules loader. `loadRules` checks a rules checkout's `rules.yaml` against the concepts and
15
+ the phase kinds boxxy compiles in, builds the plug-in registry, and gives the loaded rules a
16
+ version. [`example/`](./example/) holds the v1 rules file and its prompts.
16
17
 
17
18
  The package does no I/O: the caller reads the checkout and passes in its commit hash and its files'
18
19
  bytes.
@@ -30,12 +31,12 @@ The package runs on [Bun](https://bun.sh) 1.3 or newer.
30
31
  ## Features
31
32
 
32
33
  - `loadRules`, which returns the loaded rules, or every rejection with its file, its path in the
33
- file and its reason ([rules for slice 1](../../docs/design/boxxy/slice-1.md#rules-for-slice-1)).
34
- - The rules version, a content hash of the commit, the files the loader read and each concept pin
35
- ([the rules version](../../docs/design/boxxy/rules.md#the-rules-version)).
34
+ file and its reason ([the pipeline](../../docs/design/boxxy/v1.md#the-pipeline)).
35
+ - The rules version, a content hash of the commit, the files the loader read, each concept pin and
36
+ each phase kind ([the rules version](../../docs/design/boxxy/rules.md#the-rules-version)).
36
37
 
37
38
  ## Documentation
38
39
 
39
40
  The [rules design](../../docs/design/boxxy/rules.md) says why rules live in git, and
40
- [`01-registries.ts`](../../docs/design/boxxy/sketches/01-registries.ts) gives the shape of the
41
- loaded rules.
41
+ [`02-types.ts`](../../docs/design/boxxy/sketches/v1/core/02-types.ts) gives the shape of the loaded
42
+ rules.
@@ -0,0 +1,9 @@
1
+ import type { LoadedRules } from '@boxxy/model';
2
+ import type { DryRunItem, RulesDryRunLine } from '../types.ts';
3
+ interface DryRunInput {
4
+ readonly current: LoadedRules;
5
+ readonly proposed: LoadedRules;
6
+ readonly items: readonly DryRunItem[];
7
+ }
8
+ export declare function dryRunRules(input: DryRunInput): RulesDryRunLine[];
9
+ export {};
package/dist/index.d.ts CHANGED
@@ -1 +1,4 @@
1
- export { loadRules } from './load-rules.ts';
1
+ export { rulesFileName } from './constants.ts';
2
+ export { loadRules } from './loading/load-rules.ts';
3
+ export { dryRunRules } from './dry-run/dry-run-rules.ts';
4
+ export type { DryRunItem, RulesDryRunLine } from './types.ts';
@@ -0,0 +1,3 @@
1
+ import type { LoadRejection, Registry } from '@boxxy/model';
2
+ import type { ParsedRulesFile } from '../parse/rules-file-schema.ts';
3
+ export declare function collectFilterRejections(file: ParsedRulesFile, registry: Registry): LoadRejection[];
@@ -0,0 +1,3 @@
1
+ import type { LoadRejection } from '@boxxy/model';
2
+ import type { ParsedRulesFile } from '../parse/rules-file-schema.ts';
3
+ export declare function collectRecipeRejections(file: ParsedRulesFile): LoadRejection[];
@@ -0,0 +1,3 @@
1
+ import type { LoadRejection, PhaseKind, PhaseName } from '@boxxy/model';
2
+ import type { ParsedRulesFile } from '../parse/rules-file-schema.ts';
3
+ export declare function collectStepRejections(file: ParsedRulesFile, phases: ReadonlyMap<PhaseName, PhaseKind>): LoadRejection[];
@@ -1,4 +1,4 @@
1
- import type { Concept, ConceptName, ConceptPin, LoadResult } from '@boxxy/model';
1
+ import type { Concept, ConceptName, ConceptPin, LoadResult, PhaseKind } from '@boxxy/model';
2
2
  interface LoadRulesInput {
3
3
  readonly checkout: {
4
4
  readonly commit: string;
@@ -6,6 +6,7 @@ interface LoadRulesInput {
6
6
  };
7
7
  readonly concepts: readonly Concept[];
8
8
  readonly pins: ReadonlyMap<ConceptName, ConceptPin>;
9
+ readonly phases: readonly PhaseKind[];
9
10
  }
10
11
  export declare function loadRules(input: LoadRulesInput): LoadResult;
11
12
  export {};
@@ -0,0 +1,3 @@
1
+ import type { Filter } from '@boxxy/model';
2
+ import type { ParsedRulesFile } from '../parse/rules-file-schema.ts';
3
+ export declare function toFilter(filter: ParsedRulesFile['pools'][number]['takes']): Filter;
@@ -0,0 +1,84 @@
1
+ import { z } from 'zod';
2
+ declare const stepSchema: z.ZodObject<{
3
+ phase: z.core.$ZodBranded<z.ZodString, "PhaseName", "out">;
4
+ maxAttempts: z.ZodInt;
5
+ timeoutMinutes: z.ZodOptional<z.ZodInt>;
6
+ on: z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodLiteral<"next">, z.ZodLiteral<"again">, z.ZodObject<{
7
+ to: z.core.$ZodBranded<z.ZodString, "PhaseName", "out">;
8
+ max: z.ZodExactOptional<z.ZodInt>;
9
+ resume: z.ZodExactOptional<z.ZodBoolean>;
10
+ }, z.core.$strict>, z.ZodObject<{
11
+ end: z.ZodString;
12
+ }, z.core.$strict>, z.ZodObject<{
13
+ hold: z.core.$ZodBranded<z.ZodString, "RulesHoldReason", "out">;
14
+ }, z.core.$strict>]>>;
15
+ }, z.core.$strict>;
16
+ export declare const rulesFileSchema: z.ZodObject<{
17
+ version: z.ZodLiteral<1>;
18
+ concepts: z.ZodArray<z.ZodTemplateLiteral<`${string}@${number}`>>;
19
+ pipelines: z.ZodRecord<z.core.$ZodBranded<z.ZodString, "PipelineName", "out">, z.ZodArray<z.ZodObject<{
20
+ phase: z.core.$ZodBranded<z.ZodString, "PhaseName", "out">;
21
+ maxAttempts: z.ZodInt;
22
+ timeoutMinutes: z.ZodOptional<z.ZodInt>;
23
+ on: z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodLiteral<"next">, z.ZodLiteral<"again">, z.ZodObject<{
24
+ to: z.core.$ZodBranded<z.ZodString, "PhaseName", "out">;
25
+ max: z.ZodExactOptional<z.ZodInt>;
26
+ resume: z.ZodExactOptional<z.ZodBoolean>;
27
+ }, z.core.$strict>, z.ZodObject<{
28
+ end: z.ZodString;
29
+ }, z.core.$strict>, z.ZodObject<{
30
+ hold: z.core.$ZodBranded<z.ZodString, "RulesHoldReason", "out">;
31
+ }, z.core.$strict>]>>;
32
+ }, z.core.$strict>>>;
33
+ kinds: z.ZodRecord<z.core.$ZodBranded<z.ZodString, "ItemKindName", "out">, z.ZodObject<{
34
+ pipeline: z.core.$ZodBranded<z.ZodString, "PipelineName", "out">;
35
+ recipes: z.ZodRecord<z.ZodString, z.core.$ZodBranded<z.ZodString, "RecipeName", "out">>;
36
+ }, z.core.$strict>>;
37
+ skills: z.ZodOptional<z.ZodObject<{
38
+ source: z.core.$ZodBranded<z.ZodString, "RepoName", "out">;
39
+ ref: z.core.$ZodBranded<z.ZodString, "CommitSha", "out">;
40
+ }, z.core.$strict>>;
41
+ relationTypes: z.ZodArray<z.core.$ZodBranded<z.ZodString, "RelationTypeName", "out">>;
42
+ recipes: z.ZodRecord<z.core.$ZodBranded<z.ZodString, "RecipeName", "out">, z.ZodObject<{
43
+ agent: z.ZodString;
44
+ model: z.ZodString;
45
+ effort: z.ZodEnum<{
46
+ high: "high";
47
+ low: "low";
48
+ max: "max";
49
+ medium: "medium";
50
+ xhigh: "xhigh";
51
+ }>;
52
+ prompt: z.ZodString;
53
+ source: z.core.$ZodBranded<z.ZodString, "SourceName", "out">;
54
+ skills: z.ZodArray<z.ZodString>;
55
+ }, z.core.$strict>>;
56
+ limits: z.ZodArray<z.ZodObject<{
57
+ name: z.core.$ZodBranded<z.ZodString, "LimitName", "out">;
58
+ max: z.ZodInt;
59
+ counts: z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
60
+ }, z.core.$strict>>;
61
+ pools: z.ZodArray<z.ZodObject<{
62
+ name: z.core.$ZodBranded<z.ZodString, "PoolName", "out">;
63
+ slots: z.ZodOptional<z.ZodNever>;
64
+ takes: z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
65
+ order: z.ZodArray<z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodJSONSchema>]>>;
66
+ }, z.core.$strict>>;
67
+ lead: z.ZodDefault<z.ZodArray<z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodJSONSchema>]>>>;
68
+ propagation: z.ZodDefault<z.ZodArray<z.ZodObject<{
69
+ value: z.ZodString;
70
+ over: z.core.$ZodBranded<z.ZodString, "RelationTypeName", "out">;
71
+ toward: z.ZodEnum<{
72
+ source: "source";
73
+ target: "target";
74
+ }>;
75
+ }, z.core.$strict>>>;
76
+ sleep: z.ZodObject<{
77
+ afterIdleMinutes: z.ZodInt;
78
+ afterQuietMinutes: z.ZodInt;
79
+ afterUnattendedMinutes: z.ZodInt;
80
+ }, z.core.$strict>;
81
+ }, z.core.$strict>;
82
+ export type ParsedRulesFile = z.output<typeof rulesFileSchema>;
83
+ export type ParsedStep = z.output<typeof stepSchema>;
84
+ export {};
@@ -1,5 +1,5 @@
1
1
  import type { ConceptPin, LoadRejection, Registry } from '@boxxy/model';
2
- import type { PinnedConcept } from './types.ts';
2
+ import type { PinnedConcept } from '../types.ts';
3
3
  export declare function buildRegistry(pinned: readonly PinnedConcept[], pins: readonly ConceptPin[]): {
4
4
  readonly value: Registry;
5
5
  readonly rejections: readonly LoadRejection[];
@@ -1,4 +1,4 @@
1
1
  import type { JsonValue } from '@boxxy/content-hash';
2
2
  import type { ParamKindName, Registered, Resolved } from '@boxxy/model';
3
- import type { Checked } from './types.ts';
3
+ import type { Checked } from '../types.ts';
4
4
  export declare function resolvePlugin<K extends ParamKindName>(registered: Registered<K>, params: JsonValue, key: string, path: readonly PropertyKey[]): Checked<Resolved<K>>;
@@ -1,3 +1,3 @@
1
1
  import type { Registry, SignalRef } from '@boxxy/model';
2
- import type { Checked, ResolvedSignal } from './types.ts';
2
+ import type { Checked, ResolvedSignal } from '../types.ts';
3
3
  export declare function resolveSignalRef(ref: SignalRef, path: readonly PropertyKey[], registry: Registry): Checked<ResolvedSignal>;
@@ -0,0 +1,11 @@
1
+ import type { Concept, ConceptName, ConceptPin } from '@boxxy/model';
2
+ interface ExampleLoadInput {
3
+ readonly checkout: {
4
+ readonly commit: string;
5
+ readonly files: ReadonlyMap<string, Uint8Array>;
6
+ };
7
+ readonly concepts: readonly Concept[];
8
+ readonly pins: ReadonlyMap<ConceptName, ConceptPin>;
9
+ }
10
+ export declare function buildExampleLoadInput(): ExampleLoadInput;
11
+ export {};
@@ -0,0 +1,7 @@
1
+ import type { JsonValue, LoadedRules } from '@boxxy/model';
2
+ interface MockRulesInput {
3
+ readonly steps: readonly JsonValue[];
4
+ readonly kinds?: Readonly<Record<string, string>>;
5
+ }
6
+ export declare function loadMockRules(input: MockRulesInput): LoadedRules;
7
+ export {};
package/dist/types.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Concept, LoadRejection, Resolved, SignalKey } from '@boxxy/model';
1
+ import type { Concept, ItemId, ItemView, LoadRejection, Position, Resolved, RetryLever, Settled, SignalKey } from '@boxxy/model';
2
2
  export interface Checked<T> {
3
3
  readonly value: T | null;
4
4
  readonly rejections: readonly LoadRejection[];
@@ -11,3 +11,14 @@ export interface PinnedConcept {
11
11
  readonly concept: Concept;
12
12
  readonly entryIndex: number;
13
13
  }
14
+ export interface RulesDryRunLine {
15
+ readonly item: ItemId;
16
+ readonly now: Position;
17
+ readonly then: Position;
18
+ readonly moved: boolean;
19
+ }
20
+ export interface DryRunItem {
21
+ readonly item: ItemView;
22
+ readonly attempts: readonly Settled[];
23
+ readonly retries: readonly RetryLever[];
24
+ }
@@ -0,0 +1,4 @@
1
+ Build the item {{title}} to its design and acceptance list, and open a pull request.
2
+
3
+ Run the checks that the repo's own agent instructions name. Close out `built`, `blocked` with the
4
+ question the owner must answer, or `abandoned`.
@@ -0,0 +1,4 @@
1
+ Design the item {{title}} with the owner, who works in this session with you.
2
+
3
+ Ask the owner what you need to know. When the owner is done, close out `designed` with the design,
4
+ `blocked` with the open question, or `abandoned`.
@@ -0,0 +1,4 @@
1
+ Design the item {{title}} against its acceptance list.
2
+
3
+ Close out `designed` with the design, `blocked` with the question the owner must answer, or
4
+ `abandoned`.
@@ -0,0 +1,4 @@
1
+ Review the pull request of the item {{title}} against its acceptance list.
2
+
3
+ Run `noslop review --report json`. Close out `passed`, or `changes-requested` with each finding as
4
+ text.
@@ -0,0 +1,5 @@
1
+ Triage the item {{title}}.
2
+
3
+ Decide whether boxxy takes it on: close out `accepted`, `duplicate` with the other item, or
4
+ `declined` with the reason. An accepted item needs an acceptance list. Accept no criterion that only
5
+ a release or a live run can prove.
@@ -0,0 +1,110 @@
1
+ # The v1 rules file: four phases, two limits, five pools and the sleep times.
2
+ version: 1
3
+ concepts: [pool@2, defer@1, expedite@1] # pool@2 adds the filter field `designMode`
4
+
5
+ pipelines:
6
+ standard:
7
+ - phase: triage
8
+ maxAttempts: 2
9
+ timeoutMinutes: 20
10
+ on: # every outcome of the phase kind, or the load fails
11
+ accepted: next
12
+ duplicate: { hold: duplicate } # the owner confirms with `boxxy item close`,
13
+ declined: { hold: declined } # or sends the item on with `boxxy item retry`
14
+ - phase: design
15
+ maxAttempts: 2
16
+ timeoutMinutes: 90 # the clock of an agent-mode attempt; an attended one sleeps instead
17
+ on:
18
+ designed: next
19
+ blocked: { hold: blocked } # the agent's question; the owner answers with `boxxy item note`
20
+ abandoned: again # the same phase again, counted against maxAttempts
21
+ - phase: build
22
+ maxAttempts: 2
23
+ timeoutMinutes: 180
24
+ on:
25
+ built: next
26
+ blocked: { hold: blocked }
27
+ abandoned: again
28
+ - phase: review
29
+ maxAttempts: 2
30
+ timeoutMinutes: 45
31
+ on:
32
+ passed: { end: reviewed }
33
+ changes-requested: { to: build, max: 3, resume: true } # wakes the build's kept session
34
+
35
+ kinds:
36
+ core.idea:
37
+ pipeline: standard
38
+ recipes:
39
+ triage: triage-v1
40
+ design: design-v1
41
+ design/attended: design-attended-v1
42
+ build: build-v1
43
+ review: review-v1
44
+ core.issue:
45
+ pipeline: standard
46
+ recipes:
47
+ triage: triage-v1
48
+ design: design-v1
49
+ design/attended: design-attended-v1
50
+ build: build-v1
51
+ review: review-v1
52
+
53
+ skills: # one commit of the skills repo, for every recipe
54
+ source: example/skills
55
+ ref: 4f1c9e07a2b35d68c0e1f7a9b3d5c2e8f6a4b1d0
56
+
57
+ relationTypes: [blocks, duplicate-of, relates-to]
58
+
59
+ recipes: # the pin and each recipe's skills are part of the recipe hash
60
+ triage-v1:
61
+ agent: claude
62
+ model: claude-opus-5-5
63
+ effort: medium
64
+ prompt: prompts/triage-v1.md
65
+ source: subscription
66
+ skills: []
67
+ design-v1:
68
+ agent: claude
69
+ model: claude-opus-5-5
70
+ effort: medium
71
+ prompt: prompts/design-v1.md
72
+ source: subscription
73
+ skills: [bake-off]
74
+ design-attended-v1:
75
+ agent: claude
76
+ model: claude-opus-5-5
77
+ effort: medium
78
+ prompt: prompts/design-attended-v1.md
79
+ source: subscription
80
+ skills: [bake-off]
81
+ build-v1:
82
+ agent: claude
83
+ model: claude-opus-5-5
84
+ effort: medium
85
+ prompt: prompts/build-v1.md
86
+ source: subscription
87
+ skills: []
88
+ review-v1:
89
+ agent: claude
90
+ model: claude-opus-5-5
91
+ effort: medium
92
+ prompt: prompts/review-v1.md
93
+ source: subscription
94
+ skills: []
95
+
96
+ limits:
97
+ - { name: workers, max: 7, counts: {} }
98
+ - { name: owner-design, max: 1, counts: { phase: design, designMode: attended } }
99
+
100
+ pools: # a filter and a sort; a limit caps the attempts
101
+ - { name: triage, takes: { phase: triage }, order: [oldest] }
102
+ - { name: design-with-owner, takes: { phase: design, designMode: attended }, order: [oldest] }
103
+ - { name: design, takes: { phase: design, designMode: agent }, order: [oldest] }
104
+ - { name: review, takes: { phase: review }, order: [oldest] }
105
+ - { name: build, takes: { phase: build }, order: [oldest] }
106
+
107
+ sleep:
108
+ afterIdleMinutes: 30 # a session that waits for the owner this long sleeps
109
+ afterQuietMinutes: 60 # an attended session with no activity this long counts as waiting
110
+ afterUnattendedMinutes: 240 # an attended session the owner has not attached to this long sleeps
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boxxy/rules",
3
- "version": "0.1.0",
3
+ "version": "0.1.3",
4
4
  "description": "boxxy's rules loader: checks a rules checkout and builds the plug-in registry",
5
5
  "homepage": "https://github.com/zgeoff/boxxy/tree/main/modules/rules#readme",
6
6
  "bugs": "https://github.com/zgeoff/boxxy/issues",
@@ -13,6 +13,7 @@
13
13
  "files": [
14
14
  "src",
15
15
  "!src/**/*.test.ts",
16
+ "example",
16
17
  "dist"
17
18
  ],
18
19
  "type": "module",
@@ -20,7 +21,9 @@
20
21
  ".": {
21
22
  "types": "./dist/index.d.ts",
22
23
  "default": "./src/index.ts"
23
- }
24
+ },
25
+ "./example/*": "./example/*",
26
+ "./test-utils/*": "./src/test-utils/*.ts"
24
27
  },
25
28
  "publishConfig": {
26
29
  "access": "public"
@@ -30,8 +33,8 @@
30
33
  "typecheck": "tsrs --noEmit"
31
34
  },
32
35
  "dependencies": {
33
- "@boxxy/content-hash": "0.1.0",
34
- "@boxxy/model": "0.1.1",
36
+ "@boxxy/content-hash": "0.1.3",
37
+ "@boxxy/model": "0.1.3",
35
38
  "tiny-invariant": "1.3.3",
36
39
  "ts-pattern": "5.9.2",
37
40
  "yaml": "2.9.1",
@@ -0,0 +1,53 @@
1
+ import type { LoadedRules, Position } from '@boxxy/model';
2
+ import { findPosition } from '@boxxy/model';
3
+ import type { DryRunItem, RulesDryRunLine } from '../types.ts';
4
+
5
+ interface DryRunInput {
6
+ // the rules the server runs
7
+ readonly current: LoadedRules;
8
+
9
+ // a rules file that passed the loader
10
+ readonly proposed: LoadedRules;
11
+
12
+ // the open items only
13
+ readonly items: readonly DryRunItem[];
14
+ }
15
+
16
+ // Folds the position of each item twice over the same attempts and levers, once under each rules
17
+ // set. It writes nothing. A live attempt is not touched by a rules change, so the line of an item
18
+ // with a live attempt says where the item stands after that attempt settles as stored so far.
19
+ export function dryRunRules(input: DryRunInput): RulesDryRunLine[] {
20
+ return input.items.map((entry) => {
21
+ const now = foldPosition(input.current, entry);
22
+ const next = foldPosition(input.proposed, entry);
23
+
24
+ // oxlint-disable-next-line unicorn/no-thenable -- the wire name of the contract; never awaited
25
+ return { item: entry.item.id, now, then: next, moved: !isSamePosition(now, next) };
26
+ });
27
+ }
28
+
29
+ function foldPosition(rules: LoadedRules, entry: DryRunItem): Position {
30
+ return findPosition({
31
+ pipeline: rules.kinds.get(entry.item.kind)?.pipeline ?? null,
32
+ attempts: entry.attempts,
33
+ retries: entry.retries,
34
+ });
35
+ }
36
+
37
+ function isSamePosition(left: Position, right: Position): boolean {
38
+ if (left.kind === 'phase' && right.kind === 'phase') {
39
+ return left.phase === right.phase && left.round === right.round;
40
+ }
41
+
42
+ if (left.kind === 'end' && right.kind === 'end') {
43
+ return left.reason === right.reason;
44
+ }
45
+
46
+ return (
47
+ left.kind === 'hold' &&
48
+ right.kind === 'hold' &&
49
+ left.reason === right.reason &&
50
+ left.then === right.then &&
51
+ left.round === right.round
52
+ );
53
+ }
package/src/index.ts CHANGED
@@ -1 +1,4 @@
1
- export { loadRules } from './load-rules.ts';
1
+ export { rulesFileName } from './constants.ts';
2
+ export { loadRules } from './loading/load-rules.ts';
3
+ export { dryRunRules } from './dry-run/dry-run-rules.ts';
4
+ export type { DryRunItem, RulesDryRunLine } from './types.ts';
@@ -1,6 +1,6 @@
1
1
  import type { LoadRejection, LoadRejectionReason } from '@boxxy/model';
2
2
  import { formatKeyPath } from '@boxxy/model';
3
- import { rulesFileName } from './constants.ts';
3
+ import { rulesFileName } from '../constants.ts';
4
4
 
5
5
  export function buildRejection(
6
6
  path: readonly PropertyKey[],
@@ -0,0 +1,44 @@
1
+ import type { Filter, LoadRejection, Registry } from '@boxxy/model';
2
+ import type { ParsedRulesFile } from '../parse/rules-file-schema.ts';
3
+ import { buildRejection } from './build-rejection.ts';
4
+ import { toFilter } from './to-filter.ts';
5
+
6
+ export function collectFilterRejections(
7
+ file: ParsedRulesFile,
8
+ registry: Registry,
9
+ ): LoadRejection[] {
10
+ const fields = registry['filter-field'];
11
+ const limitCounts = file.limits.map((limit) => toFilter(limit.counts));
12
+
13
+ const unknownFields = [
14
+ ...file.limits.map((limit, index) => ({
15
+ filter: limit.counts,
16
+ path: ['limits', index, 'counts'],
17
+ })),
18
+ ...file.pools.map((pool, index) => ({ filter: pool.takes, path: ['pools', index, 'takes'] })),
19
+ ].flatMap((entry) =>
20
+ Object.keys(entry.filter)
21
+ .filter((field) => !fields.has(field))
22
+ .map((field) =>
23
+ buildRejection([...entry.path, field], { kind: 'unknown-filter-field', field }),
24
+ ),
25
+ );
26
+
27
+ const unbounded = file.pools.flatMap((pool, index) =>
28
+ limitCounts.some((counts) => isSureToCount(counts, toFilter(pool.takes)))
29
+ ? []
30
+ : [buildRejection(['pools', index], { kind: 'pool-without-limit', pool: pool.name })],
31
+ );
32
+
33
+ return [...unknownFields, ...unbounded];
34
+ }
35
+
36
+ // A limit counts every attempt of a pool only when each field it filters on is one the pool takes
37
+ // on too, with no value the limit leaves out.
38
+ function isSureToCount(counts: Filter, takes: Filter): boolean {
39
+ return Object.entries(counts).every(([field, counted]) => {
40
+ const taken = Object.hasOwn(takes, field) ? takes[field] : undefined;
41
+
42
+ return taken !== undefined && taken.every((value) => counted.includes(value));
43
+ });
44
+ }
@@ -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
+ }