@boxxy/closeout 0.2.1 → 0.2.2

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 CHANGED
@@ -29,11 +29,13 @@ The package runs on [Bun](https://bun.sh) 1.3 or newer.
29
29
 
30
30
  ## Features
31
31
 
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.
32
+ - `triage`, `design` and `build`, and `phaseKinds`, which lists them for the rules loader. Each
33
+ parser returns the stored close-out, or every problem with it in one pass.
34
+ - `createReviewPhase`, which builds the review phase kind on the review gate's `buildCommand`. The
35
+ server adds it to `phaseKinds`.
36
+ - `countUnrelatedLines`, the changed lines of a later review in files that had no open finding.
37
+ - `closeOutLimits`, the byte limit of a close-out, and `measureJsonBytes`, which measures a value as
38
+ stored.
37
39
  - `dedupeOpenItems`, which matches each open item the agent reports to an open item with the same
38
40
  title.
39
41
 
package/dist/index.d.ts CHANGED
@@ -4,6 +4,7 @@ export { measureJsonBytes } from './measure-json-bytes.ts';
4
4
  export { phaseKinds } from './phase-kinds.ts';
5
5
  export { build } from './phases/build.ts';
6
6
  export { design } from './phases/design.ts';
7
- export { review } from './phases/review.ts';
7
+ export { createReviewPhase } from './phases/create-review-phase.ts';
8
8
  export { triage } from './phases/triage.ts';
9
- export type { CloseOutLimits, NoslopReport } from './types.ts';
9
+ export { countUnrelatedLines } from './review/count-unrelated-lines.ts';
10
+ export type { CloseOutLimits } from './types.ts';
@@ -0,0 +1,2 @@
1
+ import type { ReviewGate } from '@boxxy/model';
2
+ export declare function createReviewPhase(deps: Pick<ReviewGate, 'buildCommand'>): import("@boxxy/model").PhaseKind<unknown, import("@boxxy/content-hash").JsonValue, readonly string[]>;
@@ -0,0 +1,2 @@
1
+ import type { RoundCause, Settled } from '@boxxy/model';
2
+ export declare function buildRoundCause(history: readonly Settled[]): RoundCause;
@@ -0,0 +1,2 @@
1
+ import type { ChangedFile } from '@boxxy/model';
2
+ export declare function countUnrelatedLines(changed: readonly ChangedFile[], findingPaths: readonly string[]): number;
package/dist/types.d.ts CHANGED
@@ -1,7 +1,3 @@
1
- import type { z } from 'zod';
2
- import type { noslopReportSchema } from './noslop/noslop-report-schema.ts';
3
1
  export interface CloseOutLimits {
4
2
  readonly closeOutBytes: 120_000;
5
- readonly noslopReportBytes: 32_768;
6
3
  }
7
- export type NoslopReport = z.output<typeof noslopReportSchema>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boxxy/closeout",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
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",
@@ -20,8 +20,7 @@
20
20
  ".": {
21
21
  "types": "./dist/index.d.ts",
22
22
  "default": "./src/index.ts"
23
- },
24
- "./test-utils/*": "./src/test-utils/*.ts"
23
+ }
25
24
  },
26
25
  "publishConfig": {
27
26
  "access": "public"
@@ -31,14 +30,14 @@
31
30
  "typecheck": "tsrs --noEmit"
32
31
  },
33
32
  "dependencies": {
34
- "@boxxy/model": "0.2.1",
33
+ "@boxxy/model": "0.2.2",
35
34
  "tiny-invariant": "1.3.3",
36
35
  "ts-pattern": "5.9.2",
37
36
  "zod": "4.6.7"
38
37
  },
39
38
  "devDependencies": {
40
- "@boxxy/planning": "0.2.1",
41
- "@boxxy/rules": "0.2.1",
39
+ "@boxxy/planning": "0.2.2",
40
+ "@boxxy/rules": "0.2.2",
42
41
  "@faker-js/faker": "10.6.0"
43
42
  },
44
43
  "peerDependencies": {
@@ -71,6 +71,12 @@ function splitFriction(raw: unknown): FrictionSplit {
71
71
  }
72
72
 
73
73
  const { frictionDropped: _, ...rest } = raw;
74
+
75
+ // a body with no friction list, such as a review's, keeps no `friction` key for a strict schema
76
+ if (!('friction' in rest)) {
77
+ return { candidate: rest, keptRawIndexes: [], dropped: 0 };
78
+ }
79
+
74
80
  const entries: readonly unknown[] = Array.isArray(rest['friction']) ? rest['friction'] : [];
75
81
  const keptRawIndexes = entries.flatMap((entry, index) => (isUntargeted(entry) ? [] : [index]));
76
82
 
@@ -5,5 +5,4 @@ import type { CloseOutLimits } from './types.ts';
5
5
  // so a layer that escapes the JSON once more still fits.
6
6
  export const closeOutLimits: CloseOutLimits = {
7
7
  closeOutBytes: 120_000,
8
- noslopReportBytes: 32_768,
9
8
  };
package/src/index.ts CHANGED
@@ -4,6 +4,7 @@ export { measureJsonBytes } from './measure-json-bytes.ts';
4
4
  export { phaseKinds } from './phase-kinds.ts';
5
5
  export { build } from './phases/build.ts';
6
6
  export { design } from './phases/design.ts';
7
- export { review } from './phases/review.ts';
7
+ export { createReviewPhase } from './phases/create-review-phase.ts';
8
8
  export { triage } from './phases/triage.ts';
9
- export type { CloseOutLimits, NoslopReport } from './types.ts';
9
+ export { countUnrelatedLines } from './review/count-unrelated-lines.ts';
10
+ export type { CloseOutLimits } from './types.ts';
@@ -1,8 +1,8 @@
1
1
  import type { PhaseKind } from '@boxxy/model';
2
2
  import { build } from './phases/build.ts';
3
3
  import { design } from './phases/design.ts';
4
- import { review } from './phases/review.ts';
5
4
  import { triage } from './phases/triage.ts';
6
5
 
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];
6
+ // The phase kinds that need no port, in pipeline order. The server adds the review phase kind,
7
+ // which it builds on its review gate.
8
+ export const phaseKinds: readonly PhaseKind[] = [triage, design, build];
@@ -7,12 +7,13 @@ import type {
7
7
  Effect,
8
8
  ItemView,
9
9
  PhaseInputContext,
10
+ SentBack,
10
11
  } from '@boxxy/model';
11
12
  import {
12
13
  brandSchemas,
13
14
  buildCloseOutSchema,
14
15
  definePhase,
15
- reviewCloseOutSchema,
16
+ storedReviewCloseOutSchema,
16
17
  toJSONValue,
17
18
  } from '@boxxy/model';
18
19
  import { match } from 'ts-pattern';
@@ -59,19 +60,25 @@ export const build = definePhase({
59
60
  }),
60
61
  });
61
62
 
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
63
  function buildBuildInput(ctx: PhaseInputContext): BuildInput {
65
- const review = findLatestCloseOut(ctx.history, 'review', reviewCloseOutSchema);
66
-
67
64
  return {
68
65
  acceptance: buildCriterionTexts(ctx.item),
69
66
  design: findLatestDesign(ctx.history),
70
- findings: review?.verdict.kind === 'changes-requested' ? review.verdict.findings : [],
67
+ sentBack: findReviewSentBack(ctx),
71
68
  pullRequest: findPullRequestLink(ctx.pullRequests),
72
69
  };
73
70
  }
74
71
 
72
+ // Only a latest review that asked for changes sends the build its findings: a build after a
73
+ // passed review, sent back by the owner, gets none.
74
+ function findReviewSentBack(ctx: PhaseInputContext): SentBack | null {
75
+ const reading = findLatestCloseOut(ctx.history, 'review', storedReviewCloseOutSchema)?.reading;
76
+
77
+ return reading?.status === 'blocked' && reading.why === 'open-findings'
78
+ ? { from: 'review', head: reading.head, open: reading.open, answerHint: reading.answerHint }
79
+ : null;
80
+ }
81
+
75
82
  function collectBuildProblems(closeOut: BuildCloseOut, ctx: CloseOutContext): CloseOutProblem[] {
76
83
  const acceptance = new Set(ctx.item.acceptance.map((criterion) => criterion.id));
77
84
  const own = new Set(ctx.evidence.map((row) => row.factId));
@@ -0,0 +1,89 @@
1
+ import type {
2
+ GateReading,
3
+ PhaseInputContext,
4
+ ReviewCloseOut,
5
+ ReviewGate,
6
+ ReviewInput,
7
+ } from '@boxxy/model';
8
+ import {
9
+ brandSchemas,
10
+ collectSettled,
11
+ definePhase,
12
+ reviewCloseOutSchema,
13
+ toJSONValue,
14
+ } from '@boxxy/model';
15
+ import { match } from 'ts-pattern';
16
+ import { buildSchemaParser } from '../build-schema-parser.ts';
17
+ import { buildRoundCause } from '../review/build-round-cause.ts';
18
+ import { findPullRequestLink } from '../utils/find-pull-request-link.ts';
19
+
20
+ // The server passes the gate's pure `buildCommand`, so the module never imports an adapter. The
21
+ // session runs the command and sends the tool's raw result; the server builds the stored
22
+ // close-out from it, and this parser reads that.
23
+ export function createReviewPhase(deps: Pick<ReviewGate, 'buildCommand'>) {
24
+ return definePhase({
25
+ name: brandSchemas.phaseName.parse('review'),
26
+ version: 2,
27
+ worker: brandSchemas.workerKindName.parse('atc.session'),
28
+ outcomes: ['passed', 'changes-requested', 'failed'],
29
+ needsAcceptance: true,
30
+ input: (ctx) => toJSONValue(buildReviewInput(ctx, deps)),
31
+ closeOut: buildSchemaParser({
32
+ schema: reviewCloseOutSchema,
33
+ toBody: (raw): ReviewCloseOut => raw,
34
+ check: () => [],
35
+ }),
36
+ settle: (input) => {
37
+ const outcome = pickReviewOutcome(input.closeOut.reading);
38
+
39
+ return {
40
+ state: outcome === 'failed' ? 'failed' : 'succeeded',
41
+ failure: outcome === 'failed' ? 'review-failed' : null,
42
+ outcome,
43
+ effects: [],
44
+ frictionDropped: 0,
45
+ };
46
+ },
47
+ });
48
+ }
49
+
50
+ // The round counts the reviews since the item's latest retry that asked for changes.
51
+ function buildReviewInput(
52
+ ctx: PhaseInputContext,
53
+ deps: Pick<ReviewGate, 'buildCommand'>,
54
+ ): ReviewInput {
55
+ const since = ctx.retries.at(-1)?.at ?? Number.NEGATIVE_INFINITY;
56
+ const settled = collectSettled(ctx.history).filter((attempt) => attempt.endedAt > since);
57
+
58
+ const round =
59
+ 1 +
60
+ settled.filter(
61
+ (attempt) => attempt.phase === 'review' && attempt.outcome === 'changes-requested',
62
+ ).length;
63
+
64
+ const cause = buildRoundCause(settled);
65
+ const link = findPullRequestLink(ctx.pullRequests);
66
+
67
+ if (link === null) {
68
+ return { pullRequest: null, round, cause, command: null };
69
+ }
70
+
71
+ const pullRequest = { repo: link.repo, number: link.number };
72
+
73
+ return {
74
+ pullRequest,
75
+ round,
76
+ cause,
77
+ command: deps.buildCommand({ attempt: ctx.attempt, pullRequest, round, cause }),
78
+ };
79
+ }
80
+
81
+ // A failed call, an unreadable result or a reading that covers no head is `failed`: boxxy cannot
82
+ // tell whether the head is clear.
83
+ function pickReviewOutcome(reading: GateReading): 'passed' | 'changes-requested' | 'failed' {
84
+ return match(reading)
85
+ .with({ status: 'passed' }, () => 'passed' as const)
86
+ .with({ status: 'blocked', why: 'open-findings' }, () => 'changes-requested' as const)
87
+ .with({ status: 'blocked' }, { status: 'not-covered' }, () => 'failed' as const)
88
+ .exhaustive();
89
+ }
@@ -0,0 +1,20 @@
1
+ import type { RoundCause, Settled } from '@boxxy/model';
2
+
3
+ // Why a review round runs, from the item's settled attempts since its latest retry, oldest
4
+ // first. `rework`: the latest settled attempt that is not a build is a review that asked for
5
+ // changes. `recheck`: the item came back from another phase, or the last review failed.
6
+ export function buildRoundCause(history: readonly Settled[]): RoundCause {
7
+ const reviews = history.filter(
8
+ (settled) => settled.phase === 'review' && settled.outcome !== 'lost',
9
+ );
10
+
11
+ if (reviews.length === 0) {
12
+ return 'first';
13
+ }
14
+
15
+ const latestOther = history.findLast((settled) => settled.phase !== 'build');
16
+
17
+ return latestOther?.phase === 'review' && latestOther.outcome === 'changes-requested'
18
+ ? 'rework'
19
+ : 'recheck';
20
+ }
@@ -0,0 +1,13 @@
1
+ import type { ChangedFile } from '@boxxy/model';
2
+
3
+ // The changed lines in the files that had no open finding at the earlier review.
4
+ export function countUnrelatedLines(
5
+ changed: readonly ChangedFile[],
6
+ findingPaths: readonly string[],
7
+ ): number {
8
+ const withFindings = new Set(findingPaths);
9
+
10
+ return changed
11
+ .filter((file) => !withFindings.has(file.path))
12
+ .reduce((sum, file) => sum + file.changedLines, 0);
13
+ }
package/src/types.ts CHANGED
@@ -1,13 +1,4 @@
1
- import type { z } from 'zod';
2
- import type { noslopReportSchema } from './noslop/noslop-report-schema.ts';
3
-
4
1
  export interface CloseOutLimits {
5
2
  // a close-out as stored, in any phase
6
3
  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
4
  }
11
-
12
- // What `noslop review --json --report json` printed, as the review session sends it
13
- export type NoslopReport = z.output<typeof noslopReportSchema>;
@@ -1,3 +0,0 @@
1
- import type { NoslopSummary } from '@boxxy/model';
2
- import type { NoslopReport } from '../types.ts';
3
- export declare function buildNoslopSummary(report: NoslopReport): NoslopSummary;
@@ -1,9 +0,0 @@
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>;
@@ -1 +0,0 @@
1
- export declare const review: import("@boxxy/model").PhaseKind<unknown, import("@boxxy/content-hash").JsonValue, readonly string[]>;
@@ -1,2 +0,0 @@
1
- import type { NoslopReport } from '../types.ts';
2
- export declare function buildMockNoslopReport(overrides?: Partial<NoslopReport>): NoslopReport;
@@ -1,25 +0,0 @@
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
- }
@@ -1,13 +0,0 @@
1
- import { brandSchemas } from '@boxxy/model';
2
- import { z } from 'zod';
3
-
4
- // The fields of `noslop review --json --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
- });
@@ -1,50 +0,0 @@
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
- }
@@ -1,30 +0,0 @@
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
- }