@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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 the boxxy authors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,42 @@
1
- # Temporary Holding Version
1
+ <div align="center">
2
+ <h1>@boxxy/rules</h1>
2
3
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
4
+ <p>
5
+ <a href="https://www.npmjs.com/package/@boxxy/rules"><img src="https://img.shields.io/npm/v/@boxxy/rules" alt="npm version"></a>
6
+ <a href="./LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT license"></a>
7
+ </p>
8
+
9
+ <p>
10
+ <a href="../../docs/design/boxxy/README.md">Documentation</a>
11
+ </p>
12
+ </div>
13
+
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.
17
+
18
+ The package does no I/O: the caller reads the checkout and passes in its commit hash and its files'
19
+ bytes.
20
+
21
+ ## Install
22
+
23
+ Install the package from [npm](https://www.npmjs.com/package/@boxxy/rules) with Bun:
24
+
25
+ ```bash
26
+ bun add @boxxy/rules
27
+ ```
28
+
29
+ The package runs on [Bun](https://bun.sh) 1.3 or newer.
30
+
31
+ ## Features
32
+
33
+ - `loadRules`, which returns the loaded rules, or every rejection with its file, its path in the
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)).
37
+
38
+ ## Documentation
39
+
40
+ The [rules design](../../docs/design/boxxy/rules.md) says why rules live in git, and
41
+ [`02-types.ts`](../../docs/design/boxxy/sketches/v1/core/02-types.ts) gives the shape of the loaded
42
+ rules.
@@ -0,0 +1 @@
1
+ export declare const rulesFileName = "rules.yaml";
@@ -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 {};
@@ -0,0 +1,4 @@
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,2 @@
1
+ import type { LoadRejection, LoadRejectionReason } from '@boxxy/model';
2
+ export declare function buildRejection(path: readonly PropertyKey[], reason: LoadRejectionReason): LoadRejection;
@@ -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[];
@@ -0,0 +1,12 @@
1
+ import type { Concept, ConceptName, ConceptPin, LoadResult, PhaseKind } from '@boxxy/model';
2
+ interface LoadRulesInput {
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
+ readonly phases: readonly PhaseKind[];
10
+ }
11
+ export declare function loadRules(input: LoadRulesInput): LoadResult;
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,5 @@
1
+ import type { ConceptEntry, ConceptName } from '@boxxy/model';
2
+ export declare function parseConceptEntry(entry: ConceptEntry): {
3
+ readonly name: ConceptName;
4
+ readonly version: number;
5
+ };
@@ -0,0 +1,11 @@
1
+ import type { LoadRejection } from '@boxxy/model';
2
+ import type { ParsedRulesFile } from './rules-file-schema.ts';
3
+ type RulesFileParse = {
4
+ readonly kind: 'parsed';
5
+ readonly file: ParsedRulesFile;
6
+ } | {
7
+ readonly kind: 'rejected';
8
+ readonly rejections: readonly LoadRejection[];
9
+ };
10
+ export declare function parseRulesFile(fileName: string, bytes: Uint8Array): RulesFileParse;
11
+ export {};
@@ -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 {};
@@ -0,0 +1,6 @@
1
+ import type { ConceptPin, LoadRejection, Registry } from '@boxxy/model';
2
+ import type { PinnedConcept } from '../types.ts';
3
+ export declare function buildRegistry(pinned: readonly PinnedConcept[], pins: readonly ConceptPin[]): {
4
+ readonly value: Registry;
5
+ readonly rejections: readonly LoadRejection[];
6
+ };
@@ -0,0 +1,4 @@
1
+ import type { JsonValue } from '@boxxy/content-hash';
2
+ import type { ParamKindName, Registered, Resolved } from '@boxxy/model';
3
+ import type { Checked } from '../types.ts';
4
+ export declare function resolvePlugin<K extends ParamKindName>(registered: Registered<K>, params: JsonValue, key: string, path: readonly PropertyKey[]): Checked<Resolved<K>>;
@@ -0,0 +1,3 @@
1
+ import type { Registry, SignalRef } from '@boxxy/model';
2
+ import type { Checked, ResolvedSignal } from '../types.ts';
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 {};
@@ -0,0 +1,24 @@
1
+ import type { Concept, ItemId, ItemView, LoadRejection, Position, Resolved, RetryLever, Settled, SignalKey } from '@boxxy/model';
2
+ export interface Checked<T> {
3
+ readonly value: T | null;
4
+ readonly rejections: readonly LoadRejection[];
5
+ }
6
+ export interface ResolvedSignal {
7
+ readonly key: SignalKey;
8
+ readonly resolved: Resolved<'signal'>;
9
+ }
10
+ export interface PinnedConcept {
11
+ readonly concept: Concept;
12
+ readonly entryIndex: number;
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,47 @@
1
1
  {
2
2
  "name": "@boxxy/rules",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.3",
4
+ "description": "boxxy's rules loader: checks a rules checkout and builds the plug-in registry",
5
+ "homepage": "https://github.com/zgeoff/boxxy/tree/main/modules/rules#readme",
6
+ "bugs": "https://github.com/zgeoff/boxxy/issues",
7
+ "license": "MIT",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/zgeoff/boxxy.git",
11
+ "directory": "modules/rules"
12
+ },
13
+ "files": [
14
+ "src",
15
+ "!src/**/*.test.ts",
16
+ "example",
17
+ "dist"
18
+ ],
19
+ "type": "module",
20
+ "exports": {
21
+ ".": {
22
+ "types": "./dist/index.d.ts",
23
+ "default": "./src/index.ts"
24
+ },
25
+ "./example/*": "./example/*",
26
+ "./test-utils/*": "./src/test-utils/*.ts"
27
+ },
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
31
+ "scripts": {
32
+ "build": "rm -rf dist && tsrs -p tsconfig.build.json",
33
+ "typecheck": "tsrs --noEmit"
34
+ },
35
+ "dependencies": {
36
+ "@boxxy/content-hash": "0.1.3",
37
+ "@boxxy/model": "0.1.3",
38
+ "tiny-invariant": "1.3.3",
39
+ "ts-pattern": "5.9.2",
40
+ "yaml": "2.9.1",
41
+ "zod": "4.6.7"
42
+ },
43
+ "engines": {
44
+ "bun": ">=1.3",
45
+ "node": ">=24"
46
+ }
47
+ }
@@ -0,0 +1 @@
1
+ export const rulesFileName = 'rules.yaml';
@@ -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 ADDED
@@ -0,0 +1,4 @@
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,10 @@
1
+ import type { LoadRejection, LoadRejectionReason } from '@boxxy/model';
2
+ import { formatKeyPath } from '@boxxy/model';
3
+ import { rulesFileName } from '../constants.ts';
4
+
5
+ export function buildRejection(
6
+ path: readonly PropertyKey[],
7
+ reason: LoadRejectionReason,
8
+ ): LoadRejection {
9
+ return { file: rulesFileName, path: formatKeyPath(path), reason };
10
+ }
@@ -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
+ }