@try-works/dsh-recursive-mode 0.3.0 → 0.4.0

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 (120) hide show
  1. package/README.md +959 -0
  2. package/lib/client.js +9 -2
  3. package/lib/closeout-report.d.ts +113 -0
  4. package/lib/closeout-standards.d.ts +35 -0
  5. package/lib/closeout.d.ts +12 -0
  6. package/lib/commands.d.ts +1 -1
  7. package/lib/config.d.ts +202 -0
  8. package/lib/delegation.d.ts +123 -3
  9. package/lib/enforcement.d.ts +90 -1
  10. package/lib/errors.d.ts +168 -0
  11. package/lib/git-context.d.ts +17 -0
  12. package/lib/guard-log.d.ts +39 -0
  13. package/lib/handoff.d.ts +29 -0
  14. package/lib/hooks.d.ts +103 -0
  15. package/lib/identity.d.ts +61 -0
  16. package/lib/index.d.ts +33 -12
  17. package/lib/index.js +10017 -3969
  18. package/lib/job-log.d.ts +34 -0
  19. package/lib/jobs-runner.d.ts +105 -0
  20. package/lib/json-safe.d.ts +33 -0
  21. package/lib/lock.d.ts +42 -0
  22. package/lib/memory-feedback.d.ts +52 -0
  23. package/lib/memory-select.d.ts +78 -0
  24. package/lib/memory.d.ts +137 -0
  25. package/lib/model-inventory.d.ts +106 -0
  26. package/lib/phase-graph.d.ts +111 -0
  27. package/lib/phase-rules.d.ts +67 -8
  28. package/lib/plan-gate.d.ts +68 -0
  29. package/lib/policy-globs.d.ts +222 -0
  30. package/lib/policy-write.d.ts +42 -0
  31. package/lib/policy.d.ts +39 -0
  32. package/lib/recursive_ask.tool.d.ts +88 -0
  33. package/lib/recursive_closeout.tool.d.ts +1 -1
  34. package/lib/recursive_delegate.tool.d.ts +22 -0
  35. package/lib/recursive_preview.tool.d.ts +48 -0
  36. package/lib/recursive_review.tool.d.ts +28 -0
  37. package/lib/result-cap.d.ts +70 -0
  38. package/lib/review-round.d.ts +82 -0
  39. package/lib/review.d.ts +9 -0
  40. package/lib/role-route.d.ts +122 -0
  41. package/lib/router.d.ts +90 -5
  42. package/lib/runtime.d.ts +252 -12
  43. package/lib/settlement.d.ts +132 -0
  44. package/lib/skills-phase.d.ts +71 -0
  45. package/lib/skills.d.ts +70 -0
  46. package/lib/status.d.ts +53 -1
  47. package/lib/teams-loop.d.ts +91 -2
  48. package/lib/training.d.ts +211 -0
  49. package/lib/ts-lint.d.ts +15 -0
  50. package/lib/types.d.ts +48 -0
  51. package/lib/workflow-audit.d.ts +207 -0
  52. package/package.json +31 -31
  53. package/preset/recursive.patch.yml +312 -0
  54. package/scripts/e2e-run.mjs +51 -0
  55. package/scripts/link-dsh.mjs +233 -0
  56. package/scripts/live/fake-llm.mjs +150 -0
  57. package/scripts/live-session-plugin.mjs +179 -0
  58. package/scripts/live-session-stock.mjs +106 -0
  59. package/scripts/live-session.mjs +139 -0
  60. package/skills/recursive-mode/SKILL.md +66 -0
  61. package/src/client/derive.ts +18 -2
  62. package/src/closeout-report.ts +274 -0
  63. package/src/closeout-standards.ts +102 -0
  64. package/src/closeout.ts +39 -2
  65. package/src/commands.ts +116 -4
  66. package/src/config.ts +113 -0
  67. package/src/delegation.ts +336 -18
  68. package/src/enforcement.ts +262 -72
  69. package/src/errors.ts +197 -0
  70. package/src/git-context.ts +33 -2
  71. package/src/guard-log.ts +134 -0
  72. package/src/handoff.ts +62 -0
  73. package/src/hooks.ts +316 -0
  74. package/src/identity.ts +230 -0
  75. package/src/index.ts +394 -20
  76. package/src/job-log.ts +112 -0
  77. package/src/jobs-runner.ts +222 -0
  78. package/src/json-safe.ts +75 -0
  79. package/src/lock.ts +153 -16
  80. package/src/memory-feedback.ts +185 -0
  81. package/src/memory-select.ts +187 -0
  82. package/src/memory.ts +309 -0
  83. package/src/model-inventory.ts +196 -0
  84. package/src/phase-graph.ts +191 -0
  85. package/src/phase-rules.ts +236 -0
  86. package/src/plan-gate.ts +111 -0
  87. package/src/policy-globs.ts +636 -0
  88. package/src/policy-write.ts +210 -0
  89. package/src/policy.ts +70 -5
  90. package/src/recursive_ask.tool.ts +276 -0
  91. package/src/recursive_audit_team.tool.ts +7 -3
  92. package/src/recursive_closeout.tool.ts +36 -35
  93. package/src/recursive_delegate.tool.ts +194 -0
  94. package/src/recursive_init.tool.ts +4 -3
  95. package/src/recursive_lint.tool.ts +81 -6
  96. package/src/recursive_lock.tool.ts +21 -4
  97. package/src/recursive_phase.tool.ts +3 -2
  98. package/src/recursive_preview.tool.ts +142 -0
  99. package/src/recursive_review.tool.ts +190 -0
  100. package/src/recursive_scratch.tool.ts +5 -4
  101. package/src/recursive_status.tool.ts +3 -2
  102. package/src/recursive_worktree.tool.ts +6 -5
  103. package/src/result-cap.ts +130 -0
  104. package/src/review-round.ts +335 -0
  105. package/src/review.ts +17 -3
  106. package/src/role-route.ts +230 -0
  107. package/src/router.ts +128 -2
  108. package/src/runtime.ts +968 -39
  109. package/src/settlement.ts +355 -0
  110. package/src/skills-phase.ts +143 -0
  111. package/src/skills.ts +151 -0
  112. package/src/snapshot.ts +39 -8
  113. package/src/status.ts +209 -4
  114. package/src/teams-loop.ts +223 -9
  115. package/src/training.ts +565 -0
  116. package/src/ts-lint.ts +38 -4
  117. package/src/types.ts +51 -0
  118. package/src/workflow-audit.ts +288 -0
  119. package/scripts/install-preset.cmd +0 -7
  120. package/scripts/install-preset.js +0 -101
@@ -0,0 +1,190 @@
1
+ /**
2
+ * `recursive_review` — T36: the reachable caller that makes a failed review become a
3
+ * repair.
4
+ *
5
+ * WHY A TOOL, AND WHY IT IS TURN-SHAPED. A tool call is the agent's only interface,
6
+ * so the operator's requirement — "the main agent can message a child whose work
7
+ * failed review and tell it to re-do or repair" — needs a tool or it is not real.
8
+ * It is deliberately ONE TRANSITION PER CALL, like `recursive_audit_team`: the
9
+ * harness has no parent-side await-settlement promise, so a review cannot be one
10
+ * call that waits for a verdict that arrives on a later turn. Call it, get either an
11
+ * outcome or "still running", and call it again after the child settles — the
12
+ * durable review state carries the child id between those calls.
13
+ *
14
+ * DELEGATION IS CONTINUABLE BY DEFAULT (T35), which is what makes the repair
15
+ * possible at all: a one-shot child is NOT resumable, so a one-shot review could
16
+ * only ever be a complaint. When the continuable seam is unavailable the outcome says
17
+ * so explicitly (`unavailable`) rather than reporting a success.
18
+ */
19
+ import { randomUUID } from 'node:crypto'
20
+ import { readFileSync } from 'node:fs'
21
+ import { join } from 'node:path'
22
+ import { defineTool } from '@deepseek-ai/dsh-tools'
23
+ import type { JsonValue } from '@deepseek-ai/dsh-util-values'
24
+ import { toLosslessJson } from './json-safe.ts'
25
+ import type { RecursiveRuntime } from './runtime.ts'
26
+ import { codeRuntimeRefusal, toolError } from './errors.ts'
27
+ import { replyPath } from './handoff.ts'
28
+ import { advanceReview } from './review-round.ts'
29
+ import { settlementRoundObserver } from './settlement.ts'
30
+ import type { ContinuableDelegationLike, SubagentParentHandle, SubagentsRuntimeLike } from './delegation.ts'
31
+
32
+ /**
33
+ * The reply contract, stated for the child AND relied on by the fail-closed reader:
34
+ * `verdict` decides the round, and `findings[].title` is the ONLY text that can
35
+ * become a repair instruction, so a child cannot inject instructions.
36
+ */
37
+ const REPLY_CONTRACT =
38
+ 'Write reply.md with a single JSON object: {"verdict":"APPROVE"|"REVISE"|"REJECT",'
39
+ + '"findings":[{"severity":"HIGH"|"MEDIUM"|"LOW","title":"...","detail":"..."}]}. '
40
+ + 'State the verdict explicitly: a reply that does not state one is read as REVISE, never as approval.'
41
+
42
+ export function createRecursiveReviewTool(recursive: RecursiveRuntime, subagents?: SubagentsRuntimeLike) {
43
+ return defineTool({
44
+ name: 'recursive_review',
45
+ description:
46
+ 'Run or resume an INDEPENDENT review of the current phase artifact with a durable continuable subagent. '
47
+ + 'Call it once per turn: it either reports an outcome (approved | rejected | unavailable) or that the '
48
+ + 'review is still running. When a round comes back REVISE the SAME child is sent the repair instruction, '
49
+ + 'so call this again after the re-submission settles. Never locks anything itself.',
50
+ parameters: {
51
+ runId: { type: 'string', description: 'Optional run id (defaults to the latest run by mtime).' },
52
+ phase: { type: 'string', description: 'Optional phase key to review (e.g. 03). Defaults to the run\'s current phase.' },
53
+ role: { type: 'string', description: 'Reviewer role to route (default: code-reviewer). Must be enabled in the router policy.' },
54
+ },
55
+ output: {
56
+ schema: { type: 'json' },
57
+ render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
58
+ },
59
+ async execute(args: { runId?: string; phase?: string; role?: string }, exec) {
60
+ try {
61
+ const root = await recursive.resolveWorkspaceRoot(exec.agent)
62
+ if (!root) return { error: toolError('NO_WORKSPACE') } as const
63
+
64
+ const status = await recursive.status(args.runId, exec.agent)
65
+ if (!status) return { error: toolError('NO_RUN') } as const
66
+ const runId = status.runId
67
+
68
+ // The phase defaults to the run's current phase: the one a review is actually
69
+ // about, because that is the artifact whose lock the review gates.
70
+ const phase = args.phase?.trim() || status.currentPhase?.key || ''
71
+ if (phase === '') return { error: toolError('NO_PHASE') } as const
72
+ const row = status.phases.find((p) => p.key === phase)
73
+ if (row === undefined || !row.exists) {
74
+ return { error: toolError('NO_PHASE', 'phase ' + phase + ' has no artifact to review yet') } as const
75
+ }
76
+
77
+ const role = args.role?.trim() || 'code-reviewer'
78
+ const delegationId = phase + '-review'
79
+ const runDir = join(root, '.recursive', 'run', runId)
80
+ const artifactPath = join(runDir, row.file)
81
+ const parent = exec.agent as unknown as SubagentParentHandle
82
+
83
+ const outcome = await advanceReview({
84
+ runDir,
85
+ delegationId,
86
+ phase,
87
+ role,
88
+ delegate: async ({ resumeChild }) => {
89
+ // A resumed round keeps the child it was given; a fresh round reserves an
90
+ // id up front so the brief/prompt paths and the session agree from the
91
+ // first call (`ctx.agents.create` accepts a reserved id).
92
+ const childId = resumeChild ?? randomUUID()
93
+ const review = await recursive.delegateReview({
94
+ root,
95
+ runId,
96
+ phase,
97
+ role,
98
+ delegationId,
99
+ childId,
100
+ artifactPath,
101
+ upstreamArtifacts: [],
102
+ auditQuestions: [
103
+ 'Does the artifact satisfy every required section for phase ' + phase + '?',
104
+ 'Are the gate fields (Coverage/Approval/Audit/TDD Compliance) truthful and supported by evidence?',
105
+ 'Do the artifact and the upstream artifacts it cites agree with each other?',
106
+ ],
107
+ requiredOutput: REPLY_CONTRACT,
108
+ subagents,
109
+ parent,
110
+ mode: 'continuable',
111
+ awaitRoundResult: settlementRoundObserver(runDir),
112
+ })
113
+ return toContinuable(review)
114
+ },
115
+ readReply: (childId) => readReplyText(root, runId, delegationId, childId),
116
+ // The loop can stop believing a prose reply was approved; the driver
117
+ // disagrees, so it must be able to deliver the repair itself.
118
+ sendRepair: async (childId, instruction) => {
119
+ const followup = subagents?.followup
120
+ if (followup === undefined) throw new Error('no continuable followup seam')
121
+ await followup(parent, childId, [{ type: 'text', text: instruction }], {
122
+ source: { kind: 'coordinator', form: 'relay', senderSessionId: parent?.id ?? '' },
123
+ })
124
+ },
125
+ })
126
+ // ⚠ FU-9 — A CAST IS NOT A CONTRACT, and this line proved it live. It used to be
127
+ // `return outcome as unknown as JsonValue`, and a real session answered with
128
+ // Error: tool "recursive_review" returned invalid output: value is not lossless JSON
129
+ // ToolOutputError, code INVALID_TOOL_OUTPUT
130
+ // because the outcome carries per-round `result` objects from the host — live Agents and seams, none of
131
+ // which survive JSON.stringify. The projection keeps every datum a caller reads (status, rounds, the
132
+ // round text, the message) and turns what has no JSON form into something that does, so the host can
133
+ // always read the result rather than refusing it and reporting nothing about the work.
134
+ const projected = toLosslessJson(outcome)
135
+ return (projected ?? { error: codeRuntimeRefusal('the review produced no JSON-representable result') }) as JsonValue
136
+ } catch (err) {
137
+ return { error: codeRuntimeRefusal(err instanceof Error ? err.message : String(err)) } as const
138
+ }
139
+ },
140
+ })
141
+ }
142
+
143
+ /** The child's reply file, or '' when it has not written one (an empty reply is not an approval). */
144
+ export function readReplyText(root: string, runId: string, delegationId: string, childId: string): string {
145
+ try {
146
+ return readFileSync(replyPath({ root, runId, delegationId, childId }), 'utf8')
147
+ } catch {
148
+ return ''
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Project `delegateReview`'s result onto the loop shape the driver reads.
154
+ *
155
+ * The mapping is where the honest failure modes live: no continuable result at all
156
+ * (self-audit, or the delegation never ran) becomes `fellBackToOneShot`, which the
157
+ * driver reports as `unavailable` — the review happened without the repair path, and
158
+ * saying so beats reporting a success that cannot be acted on.
159
+ */
160
+ export function toContinuable(review: {
161
+ continuable: { rounds: unknown[]; childId?: string; fellBackToOneShot?: boolean; parked?: boolean; ok?: boolean; reason?: string } | null
162
+ evaluation?: { accepted?: boolean }
163
+ error?: string | null
164
+ parked?: boolean
165
+ }): ContinuableDelegationLike {
166
+ if (review.continuable === null) {
167
+ return {
168
+ ok: false,
169
+ reason: review.error ?? 'no continuable delegation ran',
170
+ rounds: [],
171
+ accepted: false,
172
+ fellBackToOneShot: true,
173
+ }
174
+ }
175
+ return {
176
+ // ⚠ FU-9 — THE DELEGATION'S OWN `ok` AND `reason`, NOT A HARDCODED `true`. This adapter forwarded `rounds`,
177
+ // `childId`, `accepted` and `parked` while dropping `ok` and `reason`, so a delegation that returned
178
+ // `{ ok: false, reason: '...' }` arrived at the review driver as `{ ok: true, rounds: [] }` — and the driver
179
+ // then printed "the round ended without an approval", naming none of the three branches that can produce
180
+ // it. Two rounds of live runs went into recovering a sentence this object already carried.
181
+ ok: review.continuable.ok ?? true,
182
+ ...(review.continuable.reason === undefined ? {} : { reason: review.continuable.reason }),
183
+ rounds: review.continuable.rounds as ContinuableDelegationLike['rounds'],
184
+ ...(review.continuable.childId === undefined ? {} : { childId: review.continuable.childId }),
185
+ accepted: review.evaluation?.accepted === true,
186
+ ...(review.continuable.parked === true ? { parked: true } : {}),
187
+ // ⚠ AND THE FALLBACK FLAG, which decides whether the driver reports "no continuable repair path" at all.
188
+ ...(review.continuable.fellBackToOneShot === true ? { fellBackToOneShot: true } : {}),
189
+ }
190
+ }
@@ -1,5 +1,6 @@
1
1
  import { defineTool } from '@deepseek-ai/dsh-tools'
2
- import type { JsonValue } from '@deepseek-ai/dsh-tools'
2
+ import { toolError } from './errors.ts'
3
+ import type { JsonValue } from '@deepseek-ai/dsh-util-values'
3
4
  import type { RecursiveRuntime } from './runtime.ts'
4
5
 
5
6
  /**
@@ -26,14 +27,14 @@ export function createRecursiveScratchTool(recursive: RecursiveRuntime) {
26
27
  const runId = args.runId?.trim() ?? ''
27
28
  const target = args.target ?? ''
28
29
  if (!action || !runId || !target) {
29
- return { error: 'action, runId, and target are required' } as const
30
+ return { error: toolError('MISSING_SCRATCH_ARGS') } as const
30
31
  }
31
32
  if (target !== 'md' && target !== 'ts') {
32
- return { error: 'target must be md or ts' } as const
33
+ return { error: toolError('BAD_TARGET') } as const
33
34
  }
34
35
  const root = await recursive.resolveWorkspaceRoot(exec.agent)
35
36
  if (!root) {
36
- return { error: 'session is not attached to a registered workspace (cannot resolve control-plane root)' } as const
37
+ return { error: toolError('NO_WORKSPACE') } as const
37
38
  }
38
39
  const result = recursive.scratchRun(root, runId, action, target as 'md' | 'ts', args.content)
39
40
  return result as unknown as JsonValue
@@ -1,5 +1,6 @@
1
1
  import { defineTool } from '@deepseek-ai/dsh-tools'
2
- import type { JsonValue } from '@deepseek-ai/dsh-tools'
2
+ import { toolError } from './errors.ts'
3
+ import type { JsonValue } from '@deepseek-ai/dsh-util-values'
3
4
  import type { RecursiveRuntime } from './runtime.ts'
4
5
 
5
6
  export function createRecursiveStatusTool(recursive: RecursiveRuntime) {
@@ -17,7 +18,7 @@ export function createRecursiveStatusTool(recursive: RecursiveRuntime) {
17
18
  // B3: per-call workspace root via exec.agent.session.header.cwd (registry
18
19
  // first, cwd fallback) — never the host checkout process.cwd().
19
20
  const result = await recursive.status(args.runId, exec.agent as { session?: { header?: { cwd?: string } } } | null)
20
- if (!result) return { error: 'no recursive run found' } as const
21
+ if (!result) return { error: toolError('NO_RUN') } as const
21
22
  return result as unknown as JsonValue
22
23
  },
23
24
  })
@@ -1,5 +1,6 @@
1
1
  import { defineTool } from '@deepseek-ai/dsh-tools'
2
- import type { JsonValue } from '@deepseek-ai/dsh-tools'
2
+ import { toolError } from './errors.ts'
3
+ import type { JsonValue } from '@deepseek-ai/dsh-util-values'
3
4
  import type { RecursiveRuntime } from './runtime.ts'
4
5
 
5
6
  /**
@@ -28,18 +29,18 @@ export function createRecursiveWorktreeTool(recursive: RecursiveRuntime) {
28
29
  // so tests and headless callers work without a live session header.
29
30
  const root = await recursive.resolveRootFor(exec.agent as { session?: { header?: { cwd?: string } } } | null)
30
31
  if (!root) {
31
- return { error: 'session is not attached to a registered workspace (cannot resolve control-plane root)' } as const
32
+ return { error: toolError('NO_WORKSPACE') } as const
32
33
  }
33
34
  if (action === 'create') {
34
35
  if (!args.runId || args.runId.trim() === '') {
35
- return { error: 'runId is required for create' } as const
36
+ return { error: toolError('MISSING_CREATE_RUN_ID') } as const
36
37
  }
37
38
  const result = recursive.createRunWorktree(root, args.runId.trim(), args.baseBranch?.trim() || undefined)
38
39
  return result as unknown as JsonValue
39
40
  }
40
41
  if (action === 'promote') {
41
42
  if (!args.fromBranch || !args.toBranch) {
42
- return { error: 'fromBranch and toBranch are required for promote' } as const
43
+ return { error: toolError('MISSING_PROMOTE_BRANCHES') } as const
43
44
  }
44
45
  const result = recursive.promoteRunBranch(root, args.fromBranch.trim(), args.toBranch.trim())
45
46
  return result as unknown as JsonValue
@@ -48,7 +49,7 @@ export function createRecursiveWorktreeTool(recursive: RecursiveRuntime) {
48
49
  const result = recursive.worktreeStatus(root)
49
50
  return result as unknown as JsonValue
50
51
  }
51
- return { error: 'action must be create | promote | status' } as const
52
+ return { error: toolError('BAD_ACTION') } as const
52
53
  },
53
54
  })
54
55
  }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * T24 — bounded tool results.
3
+ *
4
+ * A linter over a 126 KB port can emit hundreds of findings, and `recursive_lint`
5
+ * returned `errors[]`/`warnings[]` unbounded, so a single tool result could grow
6
+ * without limit. Every result this plugin returns must be BOUNDED, and when a
7
+ * bound bites it must say so in a way the reader can act on:
8
+ *
9
+ * - WHAT was removed (which list, how many),
10
+ * - what is SHOWN, and
11
+ * - HOW to see the rest.
12
+ *
13
+ * A silent truncation is worse than no cap at all: the reader cannot tell a
14
+ * complete result from a clipped one. Hence {@link ElisionMeta} accompanies every
15
+ * truncation, and the caller surfaces it next to the findings.
16
+ *
17
+ * The "how to see the rest" route is deliberately ITERATIVE rather than a
18
+ * bypass argument: the cap is a hard bound, so telling the caller to pass
19
+ * `mode: 'full'` when `mode: 'full'` is already the capped mode would be a lie.
20
+ * Fixing the shown findings and re-running genuinely reveals the next batch,
21
+ * which is also the order the workflow wants them fixed in.
22
+ */
23
+
24
+ /** Findings kept by `mode: 'full'`. */
25
+ export const MAX_FINDINGS_FULL = 200
26
+
27
+ /** Findings kept by `mode: 'summary'` — enough to orient, not enough to drown a turn. */
28
+ export const MAX_FINDINGS_SUMMARY = 10
29
+
30
+ export interface ElisionMeta {
31
+ /** Which list was clipped. */
32
+ kind: 'errors' | 'warnings'
33
+ /** Findings the source produced. */
34
+ total: number
35
+ /** Findings actually returned. */
36
+ shown: number
37
+ /** `total - shown`; stated so a consumer need not compute it. */
38
+ omitted: number
39
+ /** One self-sufficient sentence: what was removed and how to see it. */
40
+ hint: string
41
+ }
42
+
43
+ export interface ElideResult {
44
+ kept: string[]
45
+ /** Present only when the list was clipped. */
46
+ meta: ElisionMeta | null
47
+ }
48
+
49
+ /** The payload shape the byte budget knows how to trim. */
50
+ export interface CappedPayload {
51
+ errors: string[]
52
+ warnings: string[]
53
+ elided: ElisionMeta[]
54
+ }
55
+
56
+ /** Serialized size of a payload, measured the way the byte budget is enforced. */
57
+ export function payloadBytes(value: unknown): number {
58
+ try {
59
+ return Buffer.byteLength(JSON.stringify(value) ?? '', 'utf8')
60
+ } catch {
61
+ return 0
62
+ }
63
+ }
64
+
65
+ /**
66
+ * T28 — cap a payload by BYTES, which T24's count cap cannot do.
67
+ *
68
+ * `MAX_FINDINGS_FULL` bounds HOW MANY findings come back; it cannot see SIZE, so a
69
+ * handful of enormous findings still passes. This trims the longer list repeatedly
70
+ * until the payload fits, and reports every trim through the same `ElisionMeta` T24
71
+ * established — a silent truncation is worse than no cap, because the reader cannot
72
+ * tell a complete result from a clipped one.
73
+ *
74
+ * Terminates: each pass at least halves the longer list, and an empty pair of lists
75
+ * ends the loop. A payload that still exceeds the budget once both lists are empty is
76
+ * returned as-is — the alternative would be deleting fields the caller needs.
77
+ */
78
+ export function capPayloadBytes<T extends CappedPayload>(payload: T, maxBytes: number): T {
79
+ let current = payload
80
+ let bytes = payloadBytes(current)
81
+ if (bytes <= maxBytes) return current
82
+
83
+ for (let attempt = 0; attempt < 16 && bytes > maxBytes; attempt += 1) {
84
+ const { errors, warnings } = current
85
+ if (errors.length === 0 && warnings.length === 0) break
86
+ const trimErrors = errors.length >= warnings.length
87
+ const from = trimErrors ? errors : warnings
88
+ const keep = Math.floor(from.length / 2)
89
+ current = {
90
+ ...current,
91
+ errors: trimErrors ? from.slice(0, keep) : errors,
92
+ warnings: trimErrors ? warnings : from.slice(0, keep),
93
+ elided: [...current.elided, {
94
+ kind: trimErrors ? 'errors' : 'warnings',
95
+ total: from.length,
96
+ shown: keep,
97
+ omitted: from.length - keep,
98
+ hint: 'trimmed to fit the configured result byte budget (maxResultBytes); '
99
+ + 'fix the findings shown and re-run to see the next batch',
100
+ }],
101
+ }
102
+ bytes = payloadBytes(current)
103
+ }
104
+ return current
105
+ }
106
+
107
+ /**
108
+ * Keep at most `max` findings. Clipping is reported, never silent.
109
+ * A `max` of 0 or less is treated as "keep nothing" but still reports honestly.
110
+ */
111
+ export function elideFindings(kind: 'errors' | 'warnings', findings: readonly string[], max: number): ElideResult {
112
+ const list = Array.isArray(findings) ? findings : []
113
+ const bound = Number.isFinite(max) ? Math.max(0, Math.floor(max)) : list.length
114
+ if (list.length <= bound) return { kept: [...list], meta: null }
115
+ const kept = list.slice(0, bound)
116
+ const omitted = list.length - kept.length
117
+ return {
118
+ kept,
119
+ meta: {
120
+ kind,
121
+ total: list.length,
122
+ shown: kept.length,
123
+ omitted,
124
+ hint:
125
+ 'showing ' + kept.length + ' of ' + list.length + ' ' + kind + '; ' + omitted +
126
+ ' omitted by the result cap - fix the shown findings and re-run to reveal the rest' +
127
+ ', or narrow the result with the artifact argument',
128
+ },
129
+ }
130
+ }