@boxxy/closeout 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.
- package/README.md +14 -11
- package/dist/build-schema-parser.d.ts +9 -0
- package/dist/close-out-limits.d.ts +2 -0
- package/dist/index.d.ts +8 -3
- package/dist/measure-json-bytes.d.ts +2 -0
- package/dist/noslop/build-noslop-summary.d.ts +3 -0
- package/dist/noslop/noslop-report-schema.d.ts +9 -0
- package/dist/phase-kinds.d.ts +2 -0
- package/dist/phases/build.d.ts +1 -0
- package/dist/phases/design.d.ts +1 -0
- package/dist/phases/review.d.ts +1 -0
- package/dist/phases/triage.d.ts +1 -0
- package/dist/test-utils/build-mock-noslop-report.d.ts +2 -0
- package/dist/types.d.ts +7 -0
- package/dist/utils/build-criterion-texts.d.ts +2 -0
- package/dist/utils/find-latest-close-out.d.ts +3 -0
- package/dist/utils/find-latest-design.d.ts +2 -0
- package/dist/utils/find-pull-request-link.d.ts +2 -0
- package/package.json +21 -5
- package/src/build-schema-parser.ts +124 -0
- package/src/close-out-limits.ts +9 -0
- package/src/index.ts +8 -3
- package/src/measure-json-bytes.ts +8 -0
- package/src/noslop/build-noslop-summary.ts +25 -0
- package/src/noslop/noslop-report-schema.ts +13 -0
- package/src/phase-kinds.ts +8 -0
- package/src/phases/build.ts +130 -0
- package/src/phases/design.ts +42 -0
- package/src/phases/review.ts +50 -0
- package/src/phases/triage.ts +152 -0
- package/src/test-utils/build-mock-noslop-report.ts +30 -0
- package/src/types.ts +13 -0
- package/src/utils/build-criterion-texts.ts +6 -0
- package/src/utils/find-latest-close-out.ts +20 -0
- package/src/utils/find-latest-design.ts +9 -0
- package/src/utils/find-pull-request-link.ts +12 -0
- package/dist/build-close-out-parser.d.ts +0 -2
- package/src/build-close-out-parser.ts +0 -115
package/README.md
CHANGED
|
@@ -11,11 +11,11 @@
|
|
|
11
11
|
</p>
|
|
12
12
|
</div>
|
|
13
13
|
|
|
14
|
-
boxxy's
|
|
15
|
-
|
|
14
|
+
boxxy's phase kinds: triage, design, build and review. Each one gives the input of an attempt,
|
|
15
|
+
checks the close-out the agent sends at the end of it, and settles the attempt from that close-out.
|
|
16
16
|
|
|
17
|
-
The package does no I/O: the caller passes in the item, the attempt's evidence rows
|
|
18
|
-
|
|
17
|
+
The package does no I/O: the caller passes in the item, its attempts, the attempt's evidence rows
|
|
18
|
+
and what a close-out may name.
|
|
19
19
|
|
|
20
20
|
## Install
|
|
21
21
|
|
|
@@ -29,13 +29,16 @@ The package runs on [Bun](https://bun.sh) 1.3 or newer.
|
|
|
29
29
|
|
|
30
30
|
## Features
|
|
31
31
|
|
|
32
|
-
- `
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
- `
|
|
36
|
-
|
|
32
|
+
- `triage`, `design`, `build` and `review`, the four phase kinds, and `phaseKinds`, which lists them
|
|
33
|
+
for the rules loader. Each parser returns the stored close-out, or every problem with it in one
|
|
34
|
+
pass.
|
|
35
|
+
- `closeOutLimits`, the byte limits of a close-out, and `measureJsonBytes`, which measures a value
|
|
36
|
+
as stored.
|
|
37
|
+
- `dedupeOpenItems`, which matches each open item the agent reports to an open item with the same
|
|
38
|
+
title.
|
|
37
39
|
|
|
38
40
|
## Documentation
|
|
39
41
|
|
|
40
|
-
The [close-outs design](../../docs/design/boxxy/close-outs.md) says what
|
|
41
|
-
|
|
42
|
+
The [close-outs design](../../docs/design/boxxy/close-outs.md#the-v1-phase-kinds) says what each
|
|
43
|
+
phase kind checks and settles, and
|
|
44
|
+
[`02-types.ts`](../../docs/design/boxxy/sketches/v1/core/02-types.ts) gives the shapes.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { CloseOutContext, CloseOutParser, CloseOutProblem } from '@boxxy/model';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
interface SchemaParserInput<Raw, Body> {
|
|
4
|
+
readonly schema: z.ZodType<Raw>;
|
|
5
|
+
readonly toBody: (raw: Raw, frictionDropped: number) => Body;
|
|
6
|
+
readonly check: (body: Body, ctx: CloseOutContext) => readonly CloseOutProblem[];
|
|
7
|
+
}
|
|
8
|
+
export declare function buildSchemaParser<Raw, Body>(input: SchemaParserInput<Raw, Body>): CloseOutParser<Body>;
|
|
9
|
+
export {};
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export { closeOutLimits } from './close-out-limits.ts';
|
|
2
2
|
export { dedupeOpenItems } from './dedupe-open-items.ts';
|
|
3
|
-
export {
|
|
4
|
-
export {
|
|
3
|
+
export { measureJsonBytes } from './measure-json-bytes.ts';
|
|
4
|
+
export { phaseKinds } from './phase-kinds.ts';
|
|
5
|
+
export { build } from './phases/build.ts';
|
|
6
|
+
export { design } from './phases/design.ts';
|
|
7
|
+
export { review } from './phases/review.ts';
|
|
8
|
+
export { triage } from './phases/triage.ts';
|
|
9
|
+
export type { CloseOutLimits, NoslopReport } from './types.ts';
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export declare const noslopReportSchema: z.ZodObject<{
|
|
3
|
+
version: z.ZodNumber;
|
|
4
|
+
reviewID: z.ZodString;
|
|
5
|
+
head: z.core.$ZodBranded<z.ZodString, "CommitSha", "out">;
|
|
6
|
+
findings: z.ZodArray<z.ZodUnknown>;
|
|
7
|
+
failedCalls: z.ZodArray<z.ZodUnknown>;
|
|
8
|
+
fixChecks: z.ZodExactOptional<z.ZodArray<z.ZodUnknown>>;
|
|
9
|
+
}, z.core.$loose>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const build: import("@boxxy/model").PhaseKind<unknown, import("@boxxy/content-hash").JsonValue, readonly string[]>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const design: import("@boxxy/model").PhaseKind<unknown, import("@boxxy/content-hash").JsonValue, readonly string[]>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const review: import("@boxxy/model").PhaseKind<unknown, import("@boxxy/content-hash").JsonValue, readonly string[]>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const triage: import("@boxxy/model").PhaseKind<unknown, import("@boxxy/content-hash").JsonValue, readonly string[]>;
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { z } from 'zod';
|
|
2
|
+
import type { noslopReportSchema } from './noslop/noslop-report-schema.ts';
|
|
3
|
+
export interface CloseOutLimits {
|
|
4
|
+
readonly closeOutBytes: 120_000;
|
|
5
|
+
readonly noslopReportBytes: 32_768;
|
|
6
|
+
}
|
|
7
|
+
export type NoslopReport = z.output<typeof noslopReportSchema>;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@boxxy/closeout",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "boxxy's
|
|
3
|
+
"version": "0.1.4",
|
|
4
|
+
"description": "boxxy's phase kinds: the input, the close-out parser and the settle rule of triage, design, build and review",
|
|
5
5
|
"homepage": "https://github.com/zgeoff/boxxy/tree/main/modules/closeout#readme",
|
|
6
6
|
"bugs": "https://github.com/zgeoff/boxxy/issues",
|
|
7
7
|
"license": "MIT",
|
|
@@ -20,7 +20,8 @@
|
|
|
20
20
|
".": {
|
|
21
21
|
"types": "./dist/index.d.ts",
|
|
22
22
|
"default": "./src/index.ts"
|
|
23
|
-
}
|
|
23
|
+
},
|
|
24
|
+
"./test-utils/*": "./src/test-utils/*.ts"
|
|
24
25
|
},
|
|
25
26
|
"publishConfig": {
|
|
26
27
|
"access": "public"
|
|
@@ -30,8 +31,23 @@
|
|
|
30
31
|
"typecheck": "tsrs --noEmit"
|
|
31
32
|
},
|
|
32
33
|
"dependencies": {
|
|
33
|
-
"@boxxy/model": "0.1.
|
|
34
|
-
"tiny-invariant": "1.3.3"
|
|
34
|
+
"@boxxy/model": "0.1.4",
|
|
35
|
+
"tiny-invariant": "1.3.3",
|
|
36
|
+
"ts-pattern": "5.9.2",
|
|
37
|
+
"zod": "4.6.7"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@boxxy/planning": "0.1.4",
|
|
41
|
+
"@boxxy/rules": "0.1.4",
|
|
42
|
+
"@faker-js/faker": "10.6.0"
|
|
43
|
+
},
|
|
44
|
+
"peerDependencies": {
|
|
45
|
+
"@faker-js/faker": "10.6.0"
|
|
46
|
+
},
|
|
47
|
+
"peerDependenciesMeta": {
|
|
48
|
+
"@faker-js/faker": {
|
|
49
|
+
"optional": true
|
|
50
|
+
}
|
|
35
51
|
},
|
|
36
52
|
"engines": {
|
|
37
53
|
"bun": ">=1.3",
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { CloseOutContext, CloseOutParse, CloseOutParser, CloseOutProblem } from '@boxxy/model';
|
|
2
|
+
import { formatKeyPath, toJSONValue } from '@boxxy/model';
|
|
3
|
+
import invariant from 'tiny-invariant';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { closeOutLimits } from './close-out-limits.ts';
|
|
6
|
+
import { measureJsonBytes } from './measure-json-bytes.ts';
|
|
7
|
+
|
|
8
|
+
interface SchemaParserInput<Raw, Body> {
|
|
9
|
+
// the body as the agent sends it, with no `frictionDropped`: the parser counts that itself
|
|
10
|
+
readonly schema: z.ZodType<Raw>;
|
|
11
|
+
readonly toBody: (raw: Raw, frictionDropped: number) => Body;
|
|
12
|
+
|
|
13
|
+
// the rules of the phase that the schema cannot hold; each problem comes back
|
|
14
|
+
readonly check: (body: Body, ctx: CloseOutContext) => readonly CloseOutProblem[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// The one parse of every phase kind: drop each friction entry with no target, check the
|
|
18
|
+
// schema, build the stored body, check the phase's rules, then the stored size. A failed step
|
|
19
|
+
// reports every problem it found, and stops the steps after it.
|
|
20
|
+
export function buildSchemaParser<Raw, Body>(
|
|
21
|
+
input: SchemaParserInput<Raw, Body>,
|
|
22
|
+
): CloseOutParser<Body> {
|
|
23
|
+
return {
|
|
24
|
+
parse: (raw, ctx) => parseCloseOut(input, raw, ctx),
|
|
25
|
+
jsonSchema: toJSONValue(z.toJSONSchema(input.schema)),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function parseCloseOut<Raw, Body>(
|
|
30
|
+
input: SchemaParserInput<Raw, Body>,
|
|
31
|
+
raw: unknown,
|
|
32
|
+
ctx: CloseOutContext,
|
|
33
|
+
): CloseOutParse<Body> {
|
|
34
|
+
const split = splitFriction(raw);
|
|
35
|
+
const parsed = input.schema.safeParse(split.candidate);
|
|
36
|
+
|
|
37
|
+
if (!parsed.success) {
|
|
38
|
+
return {
|
|
39
|
+
kind: 'invalid',
|
|
40
|
+
problems: parsed.error.issues.map((issue) => buildSchemaProblem(issue, split.keptRawIndexes)),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const body = input.toBody(parsed.data, split.dropped);
|
|
45
|
+
const problems = input.check(body, ctx);
|
|
46
|
+
|
|
47
|
+
if (problems.length > 0) {
|
|
48
|
+
return { kind: 'invalid', problems };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const bytes = measureJsonBytes(toJSONValue(body));
|
|
52
|
+
|
|
53
|
+
return bytes > closeOutLimits.closeOutBytes
|
|
54
|
+
? {
|
|
55
|
+
kind: 'invalid',
|
|
56
|
+
problems: [{ kind: 'too-large', bytes, max: closeOutLimits.closeOutBytes }],
|
|
57
|
+
}
|
|
58
|
+
: { kind: 'valid', closeOut: body };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface FrictionSplit {
|
|
62
|
+
readonly candidate: unknown;
|
|
63
|
+
readonly keptRawIndexes: readonly number[];
|
|
64
|
+
readonly dropped: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// An agent's count of dropped entries is not trusted: the key is removed, and the parser counts.
|
|
68
|
+
function splitFriction(raw: unknown): FrictionSplit {
|
|
69
|
+
if (!isRecord(raw)) {
|
|
70
|
+
return { candidate: raw, keptRawIndexes: [], dropped: 0 };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const { frictionDropped: _, ...rest } = raw;
|
|
74
|
+
const entries: readonly unknown[] = Array.isArray(rest['friction']) ? rest['friction'] : [];
|
|
75
|
+
const keptRawIndexes = entries.flatMap((entry, index) => (isUntargeted(entry) ? [] : [index]));
|
|
76
|
+
|
|
77
|
+
const friction = Array.isArray(rest['friction'])
|
|
78
|
+
? keptRawIndexes.map((index) => entries[index])
|
|
79
|
+
: rest['friction'];
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
candidate: { ...rest, friction },
|
|
83
|
+
keptRawIndexes,
|
|
84
|
+
dropped: entries.length - keptRawIndexes.length,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
89
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function isUntargeted(entry: unknown): boolean {
|
|
93
|
+
return isRecord(entry) && (entry['target'] === undefined || entry['target'] === null);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
interface SchemaIssue {
|
|
97
|
+
readonly path: readonly PropertyKey[];
|
|
98
|
+
readonly message: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function buildSchemaProblem(
|
|
102
|
+
issue: SchemaIssue,
|
|
103
|
+
keptRawIndexes: readonly number[],
|
|
104
|
+
): CloseOutProblem {
|
|
105
|
+
return {
|
|
106
|
+
kind: 'schema',
|
|
107
|
+
path: formatKeyPath(toRawPath(issue.path, keptRawIndexes)),
|
|
108
|
+
message: issue.message,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function toRawPath(path: readonly PropertyKey[], keptRawIndexes: readonly number[]): PropertyKey[] {
|
|
113
|
+
const [field, index, ...rest] = path;
|
|
114
|
+
|
|
115
|
+
if (field !== 'friction' || typeof index !== 'number') {
|
|
116
|
+
return [...path];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const rawIndex = keptRawIndexes[index];
|
|
120
|
+
|
|
121
|
+
invariant(rawIndex !== undefined, 'a friction issue points at an entry the parser kept');
|
|
122
|
+
|
|
123
|
+
return [field, rawIndex, ...rest];
|
|
124
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { CloseOutLimits } from './types.ts';
|
|
2
|
+
|
|
3
|
+
// The byte limits of a close-out, measured on the JSON of the value as stored. A close-out of
|
|
4
|
+
// 120,000 bytes makes a `closed_out` fact under half of the 256 KiB a fact may take on the wire,
|
|
5
|
+
// so a layer that escapes the JSON once more still fits.
|
|
6
|
+
export const closeOutLimits: CloseOutLimits = {
|
|
7
|
+
closeOutBytes: 120_000,
|
|
8
|
+
noslopReportBytes: 32_768,
|
|
9
|
+
};
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export { closeOutLimits } from './close-out-limits.ts';
|
|
2
2
|
export { dedupeOpenItems } from './dedupe-open-items.ts';
|
|
3
|
-
export {
|
|
4
|
-
export {
|
|
3
|
+
export { measureJsonBytes } from './measure-json-bytes.ts';
|
|
4
|
+
export { phaseKinds } from './phase-kinds.ts';
|
|
5
|
+
export { build } from './phases/build.ts';
|
|
6
|
+
export { design } from './phases/design.ts';
|
|
7
|
+
export { review } from './phases/review.ts';
|
|
8
|
+
export { triage } from './phases/triage.ts';
|
|
9
|
+
export type { CloseOutLimits, NoslopReport } from './types.ts';
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { JsonValue } from '@boxxy/model';
|
|
2
|
+
|
|
3
|
+
const encoder = new TextEncoder();
|
|
4
|
+
|
|
5
|
+
// The UTF-8 bytes of the value's JSON, which is what a stored copy takes.
|
|
6
|
+
export function measureJsonBytes(value: JsonValue): number {
|
|
7
|
+
return encoder.encode(JSON.stringify(value)).length;
|
|
8
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { NoslopSummary } from '@boxxy/model';
|
|
2
|
+
import { toJSONValue } from '@boxxy/model';
|
|
3
|
+
import { closeOutLimits } from '../close-out-limits.ts';
|
|
4
|
+
import { measureJsonBytes } from '../measure-json-bytes.ts';
|
|
5
|
+
import type { NoslopReport } from '../types.ts';
|
|
6
|
+
|
|
7
|
+
// Keeps the counts of noslop's result, and the result whole only when it fits the limit: a cut
|
|
8
|
+
// part of the JSON would be no report at all.
|
|
9
|
+
export function buildNoslopSummary(report: NoslopReport): NoslopSummary {
|
|
10
|
+
const whole = toJSONValue(report);
|
|
11
|
+
const reportBytes = measureJsonBytes(whole);
|
|
12
|
+
|
|
13
|
+
return {
|
|
14
|
+
version: report.version,
|
|
15
|
+
reviewID: report.reviewID,
|
|
16
|
+
head: report.head,
|
|
17
|
+
counts: {
|
|
18
|
+
findings: report.findings.length,
|
|
19
|
+
failedCalls: report.failedCalls.length,
|
|
20
|
+
fixChecks: report.fixChecks?.length ?? 0,
|
|
21
|
+
},
|
|
22
|
+
reportBytes,
|
|
23
|
+
report: reportBytes <= closeOutLimits.noslopReportBytes ? whole : null,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { brandSchemas } from '@boxxy/model';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
// The fields of `noslop review --report json` that the summary reads. Every other field passes
|
|
5
|
+
// through unread, and a list's entries are not checked, so a newer noslop still parses.
|
|
6
|
+
export const noslopReportSchema = z.looseObject({
|
|
7
|
+
version: z.number().int().positive(),
|
|
8
|
+
reviewID: z.string().min(1),
|
|
9
|
+
head: brandSchemas.commitSha,
|
|
10
|
+
findings: z.array(z.unknown()),
|
|
11
|
+
failedCalls: z.array(z.unknown()),
|
|
12
|
+
fixChecks: z.array(z.unknown()).exactOptional(),
|
|
13
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { PhaseKind } from '@boxxy/model';
|
|
2
|
+
import { build } from './phases/build.ts';
|
|
3
|
+
import { design } from './phases/design.ts';
|
|
4
|
+
import { review } from './phases/review.ts';
|
|
5
|
+
import { triage } from './phases/triage.ts';
|
|
6
|
+
|
|
7
|
+
// The phase kinds that the server passes to the rules loader, in pipeline order
|
|
8
|
+
export const phaseKinds: readonly PhaseKind[] = [triage, design, build, review];
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
BuildCloseOut,
|
|
3
|
+
BuildInput,
|
|
4
|
+
CloseOutContext,
|
|
5
|
+
CloseOutProblem,
|
|
6
|
+
Criterion,
|
|
7
|
+
Effect,
|
|
8
|
+
ItemView,
|
|
9
|
+
PhaseInputContext,
|
|
10
|
+
} from '@boxxy/model';
|
|
11
|
+
import {
|
|
12
|
+
brandSchemas,
|
|
13
|
+
buildCloseOutSchema,
|
|
14
|
+
definePhase,
|
|
15
|
+
reviewCloseOutSchema,
|
|
16
|
+
toJSONValue,
|
|
17
|
+
} from '@boxxy/model';
|
|
18
|
+
import { match } from 'ts-pattern';
|
|
19
|
+
import type { z } from 'zod';
|
|
20
|
+
import { buildSchemaParser } from '../build-schema-parser.ts';
|
|
21
|
+
import { missingCriteria } from '../missing-criteria.ts';
|
|
22
|
+
import { buildCriterionTexts } from '../utils/build-criterion-texts.ts';
|
|
23
|
+
import { findLatestCloseOut } from '../utils/find-latest-close-out.ts';
|
|
24
|
+
import { findLatestDesign } from '../utils/find-latest-design.ts';
|
|
25
|
+
import { findPullRequestLink } from '../utils/find-pull-request-link.ts';
|
|
26
|
+
|
|
27
|
+
const buildBodySchema = buildCloseOutSchema.omit({ frictionDropped: true });
|
|
28
|
+
|
|
29
|
+
export const build = definePhase({
|
|
30
|
+
name: brandSchemas.phaseName.parse('build'),
|
|
31
|
+
version: 1,
|
|
32
|
+
worker: brandSchemas.workerKindName.parse('atc.session'),
|
|
33
|
+
outcomes: ['built', 'blocked', 'abandoned'],
|
|
34
|
+
needsAcceptance: true,
|
|
35
|
+
input: (ctx) => toJSONValue(buildBuildInput(ctx)),
|
|
36
|
+
closeOut: buildSchemaParser({
|
|
37
|
+
schema: buildBodySchema,
|
|
38
|
+
toBody: (raw: z.output<typeof buildBodySchema>, frictionDropped): BuildCloseOut => ({
|
|
39
|
+
...raw,
|
|
40
|
+
frictionDropped,
|
|
41
|
+
}),
|
|
42
|
+
check: collectBuildProblems,
|
|
43
|
+
}),
|
|
44
|
+
|
|
45
|
+
// A blocked or abandoned build did not reach its goal, so its attempt fails and keeps no
|
|
46
|
+
// session. The failure is the outcome name: the agent's reason is free text, and the engine
|
|
47
|
+
// reads some failure names, such as `no-capacity`. The reason stays in the close-out.
|
|
48
|
+
settle: (input) => ({
|
|
49
|
+
...match(input.closeOut.result)
|
|
50
|
+
.with({ kind: 'built' }, () => ({ state: 'succeeded' as const, failure: null }))
|
|
51
|
+
.with({ kind: 'blocked' }, { kind: 'abandoned' }, (ended) => ({
|
|
52
|
+
state: 'failed' as const,
|
|
53
|
+
failure: ended.kind,
|
|
54
|
+
}))
|
|
55
|
+
.exhaustive(),
|
|
56
|
+
outcome: input.closeOut.result.kind,
|
|
57
|
+
effects: collectBuildEffects(input.closeOut, input.item),
|
|
58
|
+
frictionDropped: input.closeOut.frictionDropped,
|
|
59
|
+
}),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// The findings come from the latest review, and only when it asked for changes: a build after
|
|
63
|
+
// a passed review, sent back by the owner, gets none.
|
|
64
|
+
function buildBuildInput(ctx: PhaseInputContext): BuildInput {
|
|
65
|
+
const review = findLatestCloseOut(ctx.history, 'review', reviewCloseOutSchema);
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
acceptance: buildCriterionTexts(ctx.item),
|
|
69
|
+
design: findLatestDesign(ctx.history),
|
|
70
|
+
findings: review?.verdict.kind === 'changes-requested' ? review.verdict.findings : [],
|
|
71
|
+
pullRequest: findPullRequestLink(ctx.pullRequests),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function collectBuildProblems(closeOut: BuildCloseOut, ctx: CloseOutContext): CloseOutProblem[] {
|
|
76
|
+
const acceptance = new Set(ctx.item.acceptance.map((criterion) => criterion.id));
|
|
77
|
+
const own = new Set(ctx.evidence.map((row) => row.factId));
|
|
78
|
+
|
|
79
|
+
const cited = closeOut.criteria;
|
|
80
|
+
|
|
81
|
+
const unknownCriteria = new Set(
|
|
82
|
+
cited.map((entry) => entry.criterion).filter((id) => !acceptance.has(id)),
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const foreignEvidence = new Set(
|
|
86
|
+
cited.map((entry) => entry.evidence).filter((id) => !own.has(id)),
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
const missing =
|
|
90
|
+
closeOut.result.kind === 'built' ? missingCriteria(ctx.item, ctx.evidence, cited) : [];
|
|
91
|
+
|
|
92
|
+
return [
|
|
93
|
+
...[...unknownCriteria].map((criterion) => ({ kind: 'unknown-criterion' as const, criterion })),
|
|
94
|
+
...[...foreignEvidence].map((evidence) => ({ kind: 'foreign-evidence' as const, evidence })),
|
|
95
|
+
...(missing.length > 0 ? [{ kind: 'built-without-evidence' as const, criteria: missing }] : []),
|
|
96
|
+
];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function collectBuildEffects(closeOut: BuildCloseOut, item: ItemView): Effect[] {
|
|
100
|
+
const acceptance = buildCitedAcceptance(item.acceptance, closeOut.criteria);
|
|
101
|
+
|
|
102
|
+
// a failed build changes no item: the next build of the item cites its evidence again
|
|
103
|
+
const isChanged =
|
|
104
|
+
closeOut.result.kind === 'built' &&
|
|
105
|
+
acceptance.some(
|
|
106
|
+
(criterion, index) => criterion.evidence.length !== item.acceptance[index]?.evidence.length,
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
return [
|
|
110
|
+
...closeOut.openItems.map((openItem) => ({ kind: 'file-item' as const, item: openItem })),
|
|
111
|
+
...(isChanged ? [{ kind: 'set-acceptance' as const, criteria: acceptance }] : []),
|
|
112
|
+
...closeOut.friction.map((friction) => ({ kind: 'record-proposal' as const, friction })),
|
|
113
|
+
];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Each criterion gains the evidence ids the close-out cites for it, once each.
|
|
117
|
+
function buildCitedAcceptance(
|
|
118
|
+
acceptance: readonly Criterion[],
|
|
119
|
+
cited: BuildCloseOut['criteria'],
|
|
120
|
+
): Criterion[] {
|
|
121
|
+
return acceptance.map((criterion) => ({
|
|
122
|
+
...criterion,
|
|
123
|
+
evidence: [
|
|
124
|
+
...new Set([
|
|
125
|
+
...criterion.evidence,
|
|
126
|
+
...cited.filter((entry) => entry.criterion === criterion.id).map((entry) => entry.evidence),
|
|
127
|
+
]),
|
|
128
|
+
],
|
|
129
|
+
}));
|
|
130
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { AcceptanceInput, DesignCloseOut } from '@boxxy/model';
|
|
2
|
+
import { brandSchemas, definePhase, designCloseOutSchema, toJSONValue } from '@boxxy/model';
|
|
3
|
+
import { match } from 'ts-pattern';
|
|
4
|
+
import type { z } from 'zod';
|
|
5
|
+
import { buildSchemaParser } from '../build-schema-parser.ts';
|
|
6
|
+
import { buildCriterionTexts } from '../utils/build-criterion-texts.ts';
|
|
7
|
+
|
|
8
|
+
const designBodySchema = designCloseOutSchema.omit({ frictionDropped: true });
|
|
9
|
+
|
|
10
|
+
export const design = definePhase({
|
|
11
|
+
name: brandSchemas.phaseName.parse('design'),
|
|
12
|
+
version: 1,
|
|
13
|
+
worker: brandSchemas.workerKindName.parse('atc.session'),
|
|
14
|
+
outcomes: ['designed', 'blocked', 'abandoned'],
|
|
15
|
+
needsAcceptance: true,
|
|
16
|
+
mode: (item) => (item.fields.designMode === 'attended' ? 'attended' : 'agent'),
|
|
17
|
+
input: (ctx) =>
|
|
18
|
+
toJSONValue({ acceptance: buildCriterionTexts(ctx.item) } satisfies AcceptanceInput),
|
|
19
|
+
closeOut: buildSchemaParser({
|
|
20
|
+
schema: designBodySchema,
|
|
21
|
+
toBody: (raw: z.output<typeof designBodySchema>, frictionDropped): DesignCloseOut => ({
|
|
22
|
+
...raw,
|
|
23
|
+
frictionDropped,
|
|
24
|
+
}),
|
|
25
|
+
check: () => [],
|
|
26
|
+
}),
|
|
27
|
+
|
|
28
|
+
// A blocked or abandoned design did not reach its goal, so its attempt fails. The failure is
|
|
29
|
+
// the outcome name, never the agent's free text; the reason stays in the close-out.
|
|
30
|
+
settle: (input) => ({
|
|
31
|
+
...match(input.closeOut.result)
|
|
32
|
+
.with({ kind: 'designed' }, () => ({ state: 'succeeded' as const, failure: null }))
|
|
33
|
+
.with({ kind: 'blocked' }, { kind: 'abandoned' }, (ended) => ({
|
|
34
|
+
state: 'failed' as const,
|
|
35
|
+
failure: ended.kind,
|
|
36
|
+
}))
|
|
37
|
+
.exhaustive(),
|
|
38
|
+
outcome: input.closeOut.result.kind,
|
|
39
|
+
effects: input.closeOut.friction.map((friction) => ({ kind: 'record-proposal', friction })),
|
|
40
|
+
frictionDropped: input.closeOut.frictionDropped,
|
|
41
|
+
}),
|
|
42
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { PhaseInputContext, ReviewCloseOut, ReviewInput } from '@boxxy/model';
|
|
2
|
+
import { brandSchemas, definePhase, reviewCloseOutSchema, toJSONValue } from '@boxxy/model';
|
|
3
|
+
import type { z } from 'zod';
|
|
4
|
+
import { buildSchemaParser } from '../build-schema-parser.ts';
|
|
5
|
+
import { buildNoslopSummary } from '../noslop/build-noslop-summary.ts';
|
|
6
|
+
import { noslopReportSchema } from '../noslop/noslop-report-schema.ts';
|
|
7
|
+
import { buildCriterionTexts } from '../utils/build-criterion-texts.ts';
|
|
8
|
+
import { findLatestDesign } from '../utils/find-latest-design.ts';
|
|
9
|
+
import { findPullRequestLink } from '../utils/find-pull-request-link.ts';
|
|
10
|
+
|
|
11
|
+
// The session sends noslop's whole result; the stored close-out holds its summary instead.
|
|
12
|
+
const reviewBodySchema = reviewCloseOutSchema
|
|
13
|
+
.omit({ frictionDropped: true })
|
|
14
|
+
.extend({ noslop: noslopReportSchema });
|
|
15
|
+
|
|
16
|
+
export const review = definePhase({
|
|
17
|
+
name: brandSchemas.phaseName.parse('review'),
|
|
18
|
+
version: 1,
|
|
19
|
+
worker: brandSchemas.workerKindName.parse('atc.session'),
|
|
20
|
+
outcomes: ['passed', 'changes-requested'],
|
|
21
|
+
needsAcceptance: true,
|
|
22
|
+
input: (ctx) => toJSONValue(buildReviewInput(ctx)),
|
|
23
|
+
closeOut: buildSchemaParser({
|
|
24
|
+
schema: reviewBodySchema,
|
|
25
|
+
toBody: (raw: z.output<typeof reviewBodySchema>, frictionDropped): ReviewCloseOut => ({
|
|
26
|
+
...raw,
|
|
27
|
+
noslop: buildNoslopSummary(raw.noslop),
|
|
28
|
+
frictionDropped,
|
|
29
|
+
}),
|
|
30
|
+
check: (closeOut) =>
|
|
31
|
+
closeOut.verdict.kind === 'changes-requested' && closeOut.verdict.findings.length === 0
|
|
32
|
+
? [{ kind: 'phase-rule', message: 'changes-requested needs at least one finding' }]
|
|
33
|
+
: [],
|
|
34
|
+
}),
|
|
35
|
+
settle: (input) => ({
|
|
36
|
+
state: 'succeeded',
|
|
37
|
+
failure: null,
|
|
38
|
+
outcome: input.closeOut.verdict.kind,
|
|
39
|
+
effects: input.closeOut.friction.map((friction) => ({ kind: 'record-proposal', friction })),
|
|
40
|
+
frictionDropped: input.closeOut.frictionDropped,
|
|
41
|
+
}),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
function buildReviewInput(ctx: PhaseInputContext): ReviewInput {
|
|
45
|
+
return {
|
|
46
|
+
acceptance: buildCriterionTexts(ctx.item),
|
|
47
|
+
design: findLatestDesign(ctx.history),
|
|
48
|
+
pullRequest: findPullRequestLink(ctx.pullRequests),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AcceptanceInput,
|
|
3
|
+
CloseOutContext,
|
|
4
|
+
CloseOutProblem,
|
|
5
|
+
Criterion,
|
|
6
|
+
Effect,
|
|
7
|
+
TriageCloseOut,
|
|
8
|
+
} from '@boxxy/model';
|
|
9
|
+
import { brandSchemas, definePhase, toJSONValue, triageCloseOutSchema } from '@boxxy/model';
|
|
10
|
+
import { match } from 'ts-pattern';
|
|
11
|
+
import type { z } from 'zod';
|
|
12
|
+
import { buildSchemaParser } from '../build-schema-parser.ts';
|
|
13
|
+
import { buildCriterionTexts } from '../utils/build-criterion-texts.ts';
|
|
14
|
+
|
|
15
|
+
const triageBodySchema = triageCloseOutSchema.omit({ frictionDropped: true });
|
|
16
|
+
|
|
17
|
+
export const triage = definePhase({
|
|
18
|
+
name: brandSchemas.phaseName.parse('triage'),
|
|
19
|
+
version: 1,
|
|
20
|
+
worker: brandSchemas.workerKindName.parse('atc.session'),
|
|
21
|
+
outcomes: ['accepted', 'duplicate', 'declined'],
|
|
22
|
+
needsAcceptance: false,
|
|
23
|
+
input: (ctx) =>
|
|
24
|
+
toJSONValue({ acceptance: buildCriterionTexts(ctx.item) } satisfies AcceptanceInput),
|
|
25
|
+
closeOut: buildSchemaParser({
|
|
26
|
+
schema: triageBodySchema,
|
|
27
|
+
toBody: (raw: z.output<typeof triageBodySchema>, frictionDropped): TriageCloseOut => ({
|
|
28
|
+
...raw,
|
|
29
|
+
frictionDropped,
|
|
30
|
+
}),
|
|
31
|
+
check: collectTriageProblems,
|
|
32
|
+
}),
|
|
33
|
+
settle: (input) => ({
|
|
34
|
+
state: 'succeeded',
|
|
35
|
+
failure: null,
|
|
36
|
+
outcome: input.closeOut.result.kind,
|
|
37
|
+
effects: collectTriageEffects(input.closeOut),
|
|
38
|
+
frictionDropped: input.closeOut.frictionDropped,
|
|
39
|
+
}),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
function collectTriageProblems(closeOut: TriageCloseOut, ctx: CloseOutContext): CloseOutProblem[] {
|
|
43
|
+
const acceptance = closeOut.set?.acceptance ?? ctx.item.acceptance;
|
|
44
|
+
const kind = closeOut.set?.kind;
|
|
45
|
+
const duplicateOf = closeOut.result.kind === 'duplicate' ? [closeOut.result.of] : [];
|
|
46
|
+
|
|
47
|
+
const named = [...duplicateOf, ...closeOut.relations.map((relation) => relation.target)].filter(
|
|
48
|
+
(item) => item !== ctx.item.id,
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
return [
|
|
52
|
+
...(closeOut.result.kind === 'accepted' && acceptance.length === 0
|
|
53
|
+
? [{ kind: 'no-acceptance' as const }]
|
|
54
|
+
: []),
|
|
55
|
+
...(kind !== undefined && !ctx.world.kinds.has(kind)
|
|
56
|
+
? [{ kind: 'unknown-kind' as const, itemKind: kind }]
|
|
57
|
+
: []),
|
|
58
|
+
...[...new Set(named)]
|
|
59
|
+
.filter((item) => !ctx.world.items.has(item))
|
|
60
|
+
.map((item) => ({ kind: 'unknown-item' as const, item })),
|
|
61
|
+
...duplicateOf
|
|
62
|
+
.filter((item) => item === ctx.item.id)
|
|
63
|
+
.map(() => ({
|
|
64
|
+
kind: 'phase-rule' as const,
|
|
65
|
+
message: 'an item is not a duplicate of itself',
|
|
66
|
+
})),
|
|
67
|
+
...closeOut.relations.flatMap((relation) => collectRelationProblems(relation, ctx)),
|
|
68
|
+
...collectRepeatedRelations(closeOut, duplicateOf),
|
|
69
|
+
];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function collectRelationProblems(
|
|
73
|
+
relation: TriageCloseOut['relations'][number],
|
|
74
|
+
ctx: CloseOutContext,
|
|
75
|
+
): CloseOutProblem[] {
|
|
76
|
+
const refused = (reason: 'unknown-type' | 'self'): CloseOutProblem => ({
|
|
77
|
+
kind: 'refused-relation',
|
|
78
|
+
type: relation.type,
|
|
79
|
+
target: relation.target,
|
|
80
|
+
reason,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
return [
|
|
84
|
+
...(ctx.world.relationTypes.has(relation.type) ? [] : [refused('unknown-type')]),
|
|
85
|
+
...(relation.target === ctx.item.id ? [refused('self')] : []),
|
|
86
|
+
];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Each relation is one row of the store, so a close-out names each pair of type and target once.
|
|
90
|
+
// A duplicate's `of` names the `duplicate-of` relation already.
|
|
91
|
+
function collectRepeatedRelations(
|
|
92
|
+
closeOut: TriageCloseOut,
|
|
93
|
+
duplicateOf: readonly TriageCloseOut['relations'][number]['target'][],
|
|
94
|
+
): CloseOutProblem[] {
|
|
95
|
+
const named = [
|
|
96
|
+
...duplicateOf.map((target) => ({ type: 'duplicate-of', target })),
|
|
97
|
+
...closeOut.relations,
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
return named.flatMap((relation, index) =>
|
|
101
|
+
named
|
|
102
|
+
.slice(0, index)
|
|
103
|
+
.some((earlier) => earlier.type === relation.type && earlier.target === relation.target)
|
|
104
|
+
? [
|
|
105
|
+
{
|
|
106
|
+
kind: 'phase-rule' as const,
|
|
107
|
+
message: `the close-out names ${relation.type} ${relation.target} more than once`,
|
|
108
|
+
},
|
|
109
|
+
]
|
|
110
|
+
: [],
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// A duplicate closes with `of`, which adds the `duplicate-of` relation in the same change.
|
|
115
|
+
function collectTriageEffects(closeOut: TriageCloseOut): Effect[] {
|
|
116
|
+
const set = closeOut.set ?? {};
|
|
117
|
+
|
|
118
|
+
return [
|
|
119
|
+
...(set.kind === undefined ? [] : [{ kind: 'set-kind' as const, itemKind: set.kind }]),
|
|
120
|
+
...(set.designMode === undefined
|
|
121
|
+
? []
|
|
122
|
+
: [{ kind: 'set-field' as const, field: 'designMode' as const, value: set.designMode }]),
|
|
123
|
+
...(set.acceptance === undefined
|
|
124
|
+
? []
|
|
125
|
+
: [{ kind: 'set-acceptance' as const, criteria: buildCriteria(set.acceptance) }]),
|
|
126
|
+
...closeOut.relations.map((relation) => ({ kind: 'add-relation' as const, relation })),
|
|
127
|
+
...buildCloseEffects(closeOut.result),
|
|
128
|
+
...closeOut.friction.map((friction) => ({ kind: 'record-proposal' as const, friction })),
|
|
129
|
+
];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// The list replaces the item's list whole, so its criteria are numbered from `a1`.
|
|
133
|
+
function buildCriteria(acceptance: readonly { readonly text: string }[]): Criterion[] {
|
|
134
|
+
return acceptance.map((criterion, index) => ({
|
|
135
|
+
id: brandSchemas.criterionId.parse(`a${index + 1}`),
|
|
136
|
+
text: criterion.text,
|
|
137
|
+
evidence: [],
|
|
138
|
+
}));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function buildCloseEffects(result: TriageCloseOut['result']): Effect[] {
|
|
142
|
+
return match(result)
|
|
143
|
+
.returnType<Effect[]>()
|
|
144
|
+
.with({ kind: 'accepted' }, () => [])
|
|
145
|
+
.with({ kind: 'duplicate' }, (duplicate) => [
|
|
146
|
+
{ kind: 'close-item', reason: 'duplicate', of: duplicate.of },
|
|
147
|
+
])
|
|
148
|
+
.with({ kind: 'declined' }, (declined) => [
|
|
149
|
+
{ kind: 'close-item', reason: declined.reason, of: null },
|
|
150
|
+
])
|
|
151
|
+
.exhaustive();
|
|
152
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { brandSchemas } from '@boxxy/model';
|
|
2
|
+
import { faker } from '@faker-js/faker';
|
|
3
|
+
import type { NoslopReport } from '../types.ts';
|
|
4
|
+
|
|
5
|
+
// A review result as noslop's schema version 7 prints it, with no findings.
|
|
6
|
+
export function buildMockNoslopReport(overrides: Partial<NoslopReport> = {}): NoslopReport {
|
|
7
|
+
return {
|
|
8
|
+
version: 7,
|
|
9
|
+
reviewID: faker.string.uuid(),
|
|
10
|
+
repo: `${faker.word.noun()}/${faker.word.noun()}`,
|
|
11
|
+
pullRequest: null,
|
|
12
|
+
base: faker.git.commitSha(),
|
|
13
|
+
head: brandSchemas.commitSha.parse(faker.git.commitSha()),
|
|
14
|
+
title: faker.lorem.sentence(),
|
|
15
|
+
depth: 'standard',
|
|
16
|
+
special: null,
|
|
17
|
+
riskClasses: [],
|
|
18
|
+
lenses: [],
|
|
19
|
+
calls: [],
|
|
20
|
+
findings: [],
|
|
21
|
+
dropped: [],
|
|
22
|
+
failedCalls: [],
|
|
23
|
+
notReviewed: [],
|
|
24
|
+
notReviewedByLens: [],
|
|
25
|
+
configHash: faker.string.hexadecimal({ length: 64, casing: 'lower', prefix: '' }),
|
|
26
|
+
startedAt: '2026-11-02T10:00:00.000Z',
|
|
27
|
+
endedAt: '2026-11-02T10:04:00.000Z',
|
|
28
|
+
...overrides,
|
|
29
|
+
};
|
|
30
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { z } from 'zod';
|
|
2
|
+
import type { noslopReportSchema } from './noslop/noslop-report-schema.ts';
|
|
3
|
+
|
|
4
|
+
export interface CloseOutLimits {
|
|
5
|
+
// a close-out as stored, in any phase
|
|
6
|
+
readonly closeOutBytes: 120_000;
|
|
7
|
+
|
|
8
|
+
// the most of noslop's result that a review close-out keeps whole
|
|
9
|
+
readonly noslopReportBytes: 32_768;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// What `noslop review --report json` printed, as the review session sends it
|
|
13
|
+
export type NoslopReport = z.output<typeof noslopReportSchema>;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { CriterionText, ItemView } from '@boxxy/model';
|
|
2
|
+
|
|
3
|
+
// The item's acceptance list as a phase input holds it: the evidence ids stay out.
|
|
4
|
+
export function buildCriterionTexts(item: ItemView): CriterionText[] {
|
|
5
|
+
return item.acceptance.map((criterion) => ({ id: criterion.id, text: criterion.text }));
|
|
6
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { AttemptRow } from '@boxxy/model';
|
|
2
|
+
import type { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
// The close-out of the item's latest succeeded attempt of a phase, read through the schema of
|
|
5
|
+
// its body, so a stored body of another shape reads as none.
|
|
6
|
+
export function findLatestCloseOut<Body>(
|
|
7
|
+
history: readonly AttemptRow[],
|
|
8
|
+
phase: string,
|
|
9
|
+
schema: z.ZodType<Body>,
|
|
10
|
+
): Body | null {
|
|
11
|
+
const attempt = history.findLast((row) => row.phase === phase && row.state === 'succeeded');
|
|
12
|
+
|
|
13
|
+
if (attempt === undefined) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const parsed = schema.safeParse(attempt.closeOut);
|
|
18
|
+
|
|
19
|
+
return parsed.success ? parsed.data : null;
|
|
20
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { AttemptRow, DesignRef } from '@boxxy/model';
|
|
2
|
+
import { designCloseOutSchema } from '@boxxy/model';
|
|
3
|
+
import { findLatestCloseOut } from './find-latest-close-out.ts';
|
|
4
|
+
|
|
5
|
+
export function findLatestDesign(history: readonly AttemptRow[]): DesignRef | null {
|
|
6
|
+
const closeOut = findLatestCloseOut(history, 'design', designCloseOutSchema);
|
|
7
|
+
|
|
8
|
+
return closeOut?.result.kind === 'designed' ? closeOut.result.design : null;
|
|
9
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { PullRequestLink, PullRequestRow } from '@boxxy/model';
|
|
2
|
+
|
|
3
|
+
// The item's latest pull request, which a build of a later round pushes to and the review reads.
|
|
4
|
+
export function findPullRequestLink(
|
|
5
|
+
pullRequests: readonly PullRequestRow[],
|
|
6
|
+
): PullRequestLink | null {
|
|
7
|
+
const latest = pullRequests.at(-1);
|
|
8
|
+
|
|
9
|
+
return latest === undefined
|
|
10
|
+
? null
|
|
11
|
+
: { repo: latest.repo, number: latest.number, url: latest.url };
|
|
12
|
+
}
|
|
@@ -1,115 +0,0 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
BuildCloseOut,
|
|
3
|
-
CloseOutContext,
|
|
4
|
-
CloseOutParse,
|
|
5
|
-
CloseOutParser,
|
|
6
|
-
CloseOutProblem,
|
|
7
|
-
} from '@boxxy/model';
|
|
8
|
-
import { buildCloseOutSchema, formatKeyPath } from '@boxxy/model';
|
|
9
|
-
import invariant from 'tiny-invariant';
|
|
10
|
-
import { missingCriteria } from './missing-criteria.ts';
|
|
11
|
-
|
|
12
|
-
export const buildCloseOutParser: CloseOutParser<BuildCloseOut> = { parse: parseBuildCloseOut };
|
|
13
|
-
|
|
14
|
-
function parseBuildCloseOut(raw: unknown, ctx: CloseOutContext): CloseOutParse<BuildCloseOut> {
|
|
15
|
-
const split = splitFriction(raw);
|
|
16
|
-
const parsed = buildCloseOutSchema.safeParse(split.candidate);
|
|
17
|
-
|
|
18
|
-
if (!parsed.success) {
|
|
19
|
-
return {
|
|
20
|
-
kind: 'invalid',
|
|
21
|
-
problems: parsed.error.issues.map((issue) => buildSchemaProblem(issue, split.keptRawIndexes)),
|
|
22
|
-
};
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
const problems = collectProblems(parsed.data, ctx);
|
|
26
|
-
|
|
27
|
-
return problems.length === 0
|
|
28
|
-
? { kind: 'valid', closeOut: parsed.data }
|
|
29
|
-
: { kind: 'invalid', problems };
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
interface FrictionSplit {
|
|
33
|
-
readonly candidate: unknown;
|
|
34
|
-
readonly keptRawIndexes: readonly number[];
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function splitFriction(raw: unknown): FrictionSplit {
|
|
38
|
-
if (!isRecord(raw)) {
|
|
39
|
-
return { candidate: raw, keptRawIndexes: [] };
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const entries: readonly unknown[] = Array.isArray(raw['friction']) ? raw['friction'] : [];
|
|
43
|
-
const keptRawIndexes = entries.flatMap((entry, index) => (isUntargeted(entry) ? [] : [index]));
|
|
44
|
-
|
|
45
|
-
const friction = Array.isArray(raw['friction'])
|
|
46
|
-
? keptRawIndexes.map((index) => entries[index])
|
|
47
|
-
: raw['friction'];
|
|
48
|
-
|
|
49
|
-
return {
|
|
50
|
-
candidate: { ...raw, friction, frictionDropped: entries.length - keptRawIndexes.length },
|
|
51
|
-
keptRawIndexes,
|
|
52
|
-
};
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
56
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function isUntargeted(entry: unknown): boolean {
|
|
60
|
-
return isRecord(entry) && (entry['target'] === undefined || entry['target'] === null);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
interface SchemaIssue {
|
|
64
|
-
readonly path: readonly PropertyKey[];
|
|
65
|
-
readonly message: string;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function buildSchemaProblem(
|
|
69
|
-
issue: SchemaIssue,
|
|
70
|
-
keptRawIndexes: readonly number[],
|
|
71
|
-
): CloseOutProblem {
|
|
72
|
-
return {
|
|
73
|
-
kind: 'schema',
|
|
74
|
-
path: formatKeyPath(toRawPath(issue.path, keptRawIndexes)),
|
|
75
|
-
message: issue.message,
|
|
76
|
-
};
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function toRawPath(path: readonly PropertyKey[], keptRawIndexes: readonly number[]): PropertyKey[] {
|
|
80
|
-
const [field, index, ...rest] = path;
|
|
81
|
-
|
|
82
|
-
if (field !== 'friction' || typeof index !== 'number') {
|
|
83
|
-
return [...path];
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
const rawIndex = keptRawIndexes[index];
|
|
87
|
-
|
|
88
|
-
invariant(rawIndex !== undefined, 'a friction issue points at an entry the parser kept');
|
|
89
|
-
|
|
90
|
-
return [field, rawIndex, ...rest];
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
function collectProblems(closeOut: BuildCloseOut, ctx: CloseOutContext): CloseOutProblem[] {
|
|
94
|
-
const acceptance = new Set(ctx.item.acceptance.map((criterion) => criterion.id));
|
|
95
|
-
const own = new Set(ctx.evidence.map((row) => row.factId));
|
|
96
|
-
|
|
97
|
-
const cited = closeOut.criteria;
|
|
98
|
-
|
|
99
|
-
const unknownCriteria = new Set(
|
|
100
|
-
cited.map((entry) => entry.criterion).filter((id) => !acceptance.has(id)),
|
|
101
|
-
);
|
|
102
|
-
|
|
103
|
-
const foreignEvidence = new Set(
|
|
104
|
-
cited.map((entry) => entry.evidence).filter((id) => !own.has(id)),
|
|
105
|
-
);
|
|
106
|
-
|
|
107
|
-
const missing =
|
|
108
|
-
closeOut.result.kind === 'built' ? missingCriteria(ctx.item, ctx.evidence, cited) : [];
|
|
109
|
-
|
|
110
|
-
return [
|
|
111
|
-
...[...unknownCriteria].map((criterion) => ({ kind: 'unknown-criterion' as const, criterion })),
|
|
112
|
-
...[...foreignEvidence].map((evidence) => ({ kind: 'foreign-evidence' as const, evidence })),
|
|
113
|
-
...(missing.length > 0 ? [{ kind: 'built-without-evidence' as const, criteria: missing }] : []),
|
|
114
|
-
];
|
|
115
|
-
}
|