@mcrescenzo/opencode-workflows 0.1.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 (71) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/CODE_OF_CONDUCT.md +134 -0
  3. package/CONTRIBUTING.md +95 -0
  4. package/LICENSE +21 -0
  5. package/README.md +746 -0
  6. package/SECURITY.md +38 -0
  7. package/commands/repo-bughunt.md +94 -0
  8. package/commands/repo-review.md +148 -0
  9. package/commands/workflow-live-gates-release-check.md +143 -0
  10. package/docs/workflow-plugin.md +400 -0
  11. package/opencode-workflows.js +5 -0
  12. package/package.json +86 -0
  13. package/skills/opencode-workflow-authoring/SKILL.md +180 -0
  14. package/skills/repo-review-command-protocol/SKILL.md +56 -0
  15. package/skills/workflow-model-tiering/SKILL.md +57 -0
  16. package/skills/workflow-plan-review/SKILL.md +91 -0
  17. package/workflow-kernel/approval-hashing.js +39 -0
  18. package/workflow-kernel/async-util.js +33 -0
  19. package/workflow-kernel/audited-shell-policy.js +200 -0
  20. package/workflow-kernel/authority-policy.js +670 -0
  21. package/workflow-kernel/budget-accounting.js +142 -0
  22. package/workflow-kernel/capability-adapter.js +753 -0
  23. package/workflow-kernel/child-agent-runner.js +1264 -0
  24. package/workflow-kernel/constants.js +117 -0
  25. package/workflow-kernel/diagnostics.js +152 -0
  26. package/workflow-kernel/drain-runtime.js +421 -0
  27. package/workflow-kernel/errors.js +181 -0
  28. package/workflow-kernel/event-journal.js +487 -0
  29. package/workflow-kernel/extension-registry.js +144 -0
  30. package/workflow-kernel/free-text-redactor.js +91 -0
  31. package/workflow-kernel/gate-shapes.js +82 -0
  32. package/workflow-kernel/git-util.js +45 -0
  33. package/workflow-kernel/index.js +72 -0
  34. package/workflow-kernel/integration-mode.js +155 -0
  35. package/workflow-kernel/lane-effort-policy.js +134 -0
  36. package/workflow-kernel/lifecycle-control.js +608 -0
  37. package/workflow-kernel/live-gate-probes.js +916 -0
  38. package/workflow-kernel/notification-toast-cards.js +393 -0
  39. package/workflow-kernel/notification-toast-policy.js +179 -0
  40. package/workflow-kernel/notification-toast-scope.js +100 -0
  41. package/workflow-kernel/notification-toast.js +287 -0
  42. package/workflow-kernel/path-policy.js +219 -0
  43. package/workflow-kernel/result-readback.js +106 -0
  44. package/workflow-kernel/role-template-loading.js +606 -0
  45. package/workflow-kernel/run-context.js +139 -0
  46. package/workflow-kernel/run-observability.js +43 -0
  47. package/workflow-kernel/run-store-fs.js +231 -0
  48. package/workflow-kernel/run-store-locks.js +180 -0
  49. package/workflow-kernel/run-store-projections.js +421 -0
  50. package/workflow-kernel/run-store-rehydrate.js +68 -0
  51. package/workflow-kernel/run-store-state.js +147 -0
  52. package/workflow-kernel/run-store-status-format.js +1154 -0
  53. package/workflow-kernel/run-store-status.js +107 -0
  54. package/workflow-kernel/sandbox-executor.js +1131 -0
  55. package/workflow-kernel/session-access.js +56 -0
  56. package/workflow-kernel/structured-output.js +143 -0
  57. package/workflow-kernel/test-fix-drain-adapter.js +119 -0
  58. package/workflow-kernel/text-json.js +136 -0
  59. package/workflow-kernel/workflow-plugin.js +3017 -0
  60. package/workflow-kernel/workflow-source.js +444 -0
  61. package/workflow-kernel/worktree-adapter.js +256 -0
  62. package/workflow-kernel/worktree-git.js +147 -0
  63. package/workflows/repo-bughunt.js +362 -0
  64. package/workflows/repo-cleanup.js +383 -0
  65. package/workflows/repo-complexity.js +404 -0
  66. package/workflows/repo-deps.js +457 -0
  67. package/workflows/repo-modernize.js +395 -0
  68. package/workflows/repo-perf.js +394 -0
  69. package/workflows/repo-review.js +831 -0
  70. package/workflows/repo-security-audit.js +466 -0
  71. package/workflows/repo-test-gaps.js +377 -0
@@ -0,0 +1,377 @@
1
+ // LEAF workflow — repo-test-gaps engine (OpenCode port).
2
+ //
3
+ // Contract source of truth: docs/repo-review-leaf-contract.md. Canonical
4
+ // exemplar: workflows/repo-bughunt.js + tests/repo-bughunt.test.mjs.
5
+ // Port lineage (domain logic): internal Claude workflow source (adapted
6
+ // to OpenCode: QuickJS guest, no fs, tier-based models, pure-JS synthesis).
7
+ //
8
+ // This engine is a LEAF: it NEVER calls workflow() (the /repo-review meta
9
+ // invokes THIS via workflow(); nesting is one level only). It has NO
10
+ // Bash/fs/git and CANNOT import any module — the shared contract pieces
11
+ // (envelope, emptyCounts, RECON_SCHEMA, formatRecon, fingerprintOf,
12
+ // fitWithinBudget) are duplicated verbatim per docs/repo-review-leaf-contract.md
13
+ // § "How later leaves conform".
14
+ //
15
+ // Coverage-aware domain: this is a read-only-review leaf. It does NOT run any
16
+ // coverage tool or test suite (no shell), so it defaults shellCoverage="none"
17
+ // and emits a coverageLimitations string explaining coverage was NOT measured.
18
+ // It never claims line/branch coverage metrics it did not observe.
19
+ export const meta = {
20
+ name: "repo-test-gaps",
21
+ description: "Map undertested code across a repo (uncovered public surfaces, untested error paths, missing edge cases, branches exercised without assertions, weak critical paths, untested integration seams) and propose concrete tests. Report-only: returns ranked structured findings; the workflow writes no files.",
22
+ profile: "read-only-review",
23
+ maxAgents: 4096,
24
+ concurrency: 16,
25
+ phases: ["recon", "find", "verify", "synthesize"],
26
+ category: "repo-review-leaf",
27
+ notes: "Read-only report-only test-gap leaf. Does not run coverage or tests; findings are static proposals. Finder lanes use fast tier, high-risk verification uses deep tier.",
28
+ examples: [
29
+ { label: "normal test-gap scan", args: { depth: "normal", paths: ["src", "tests"] } },
30
+ { label: "critical-path gaps", args: { depth: "quick", categories: ["weak-critical-path", "untested-error-path"], paths: ["src"] } },
31
+ ],
32
+ argsSchema: {
33
+ type: ["object", "string", "null"],
34
+ properties: {
35
+ paths: { type: "array", items: { type: "string" } },
36
+ exclude: { type: "array", items: { type: "string" } },
37
+ depth: { type: "string", enum: ["quick", "normal", "thorough"] },
38
+ categories: { type: "array", items: { type: "string", enum: ["uncovered-public", "untested-error-path", "missing-edge-case", "branch-no-assertion", "weak-critical-path", "untested-seam"] } },
39
+ maxReturnFindings: { type: "integer", minimum: 1 },
40
+ recon: { type: ["object", "string"] },
41
+ },
42
+ },
43
+ };
44
+
45
+ // ---- suite identity ----
46
+ const DOMAIN = "test-gaps";
47
+ const SCHEMA_VERSION = 1;
48
+
49
+ // args may arrive as an object (workflow_run args) or, defensively, a JSON string.
50
+ let RT = args;
51
+ if (typeof RT === "string") { try { RT = RT.trim() ? JSON.parse(RT) : {}; } catch (error) { throw new Error(`Invalid repo-test-gaps runtime args JSON: ${error.message}`); } }
52
+ if (!RT || typeof RT !== "object" || Array.isArray(RT)) RT = {};
53
+
54
+ const paths = Array.isArray(RT.paths) && RT.paths.length ? RT.paths : ["."];
55
+ const exclude = Array.isArray(RT.exclude) ? RT.exclude : ["node_modules", "dist", "build", ".git", "vendor", "target", "*.min.*", "*.map"];
56
+ const depth = ["quick", "normal", "thorough"].includes(RT.depth) ? RT.depth : "thorough";
57
+
58
+ // Model selection is intent-based: lanes declare a tier; the kernel resolves tier -> concrete model from
59
+ // run.modelTiers (set by the planning agent), falling back to the session-inherited default.
60
+ // recon + finders = bulk work (fast); skeptics = subtle correctness (deep); synth = pure JS (no model).
61
+ const TIER_RECON = "fast";
62
+ const TIER_FINDER = "fast";
63
+ const TIER_VERIFY = "deep";
64
+
65
+ const MAX_RETURN_FINDINGS = Number.isInteger(RT.maxReturnFindings) && RT.maxReturnFindings > 0 ? RT.maxReturnFindings : 1000000;
66
+
67
+ const ALL_CATEGORIES = ["uncovered-public", "untested-error-path", "missing-edge-case", "branch-no-assertion", "weak-critical-path", "untested-seam"];
68
+ const requestedCategories = Array.isArray(RT.categories) ? RT.categories.filter((c) => typeof c === "string" && c.trim()) : [];
69
+ const categories = requestedCategories.length > 0 ? requestedCategories.filter((c) => ALL_CATEGORIES.includes(c)) : ALL_CATEGORIES;
70
+ if (requestedCategories.length > 0 && categories.length === 0) {
71
+ throw new Error(`No valid repo-test-gaps categories supplied. Valid categories: ${ALL_CATEGORIES.join(", ")}`);
72
+ }
73
+ // High-risk gaps are the ones a "normal" depth run subjects to adversarial verification.
74
+ const HIGH_RISK = ["uncovered-public", "untested-error-path", "weak-critical-path"];
75
+
76
+ const scope = `Scope: paths = ${JSON.stringify(paths)}. Exclude (do not scan/report): ${JSON.stringify(exclude)}.`;
77
+
78
+ // ---- coverage-aware extension fields (docs/repo-review-leaf-contract.md §2) ----
79
+ // read-only-review gives the engine NO shell, so no coverage tool or test suite
80
+ // is executed. We therefore default shellCoverage="none" and state why coverage
81
+ // metrics were NOT observed. We never claim coverage percentages we did not run.
82
+ const SHELL_COVERAGE = "none";
83
+ const COVERAGE_LIMITATIONS = "No coverage tool or test suite was executed by this read-only engine (no shell); line/branch coverage was not measured. Gaps reflect static and agent review of code paths against nearby tests, not observed coverage metrics. Do not read these findings as coverage percentages.";
84
+
85
+ // ---- lane coverage telemetry (iui1.2) ----
86
+ // Tracks expected/completed/dropped lane counts per phase so a dropped finder/verifier lane
87
+ // (onFailure returnNull + filter(Boolean)) surfaces as degraded coverage instead of a silent
88
+ // null. The meta reads laneCoverage.dropped to block materialization on partial coverage.
89
+ const laneCoverage = { expected: 0, completed: 0, dropped: 0, byPhase: {}, droppedLabels: [] };
90
+ function tallyPhase(name, results, labelOf) {
91
+ const expected = results.length;
92
+ let completed = 0;
93
+ for (let i = 0; i < results.length; i++) {
94
+ if (results[i] === null || results[i] === undefined) laneCoverage.droppedLabels.push(labelOf ? labelOf(i) : `${name}:${i + 1}`);
95
+ else completed++;
96
+ }
97
+ const dropped = expected - completed;
98
+ laneCoverage.expected += expected;
99
+ laneCoverage.completed += completed;
100
+ laneCoverage.dropped += dropped;
101
+ const prev = laneCoverage.byPhase[name] || { expected: 0, completed: 0, dropped: 0 };
102
+ laneCoverage.byPhase[name] = { expected: prev.expected + expected, completed: prev.completed + completed, dropped: prev.dropped + dropped };
103
+ return results;
104
+ }
105
+
106
+ // ---- standardized return envelope ----
107
+ function envelope(status, extra) {
108
+ return { domain: DOMAIN, schemaVersion: SCHEMA_VERSION, status, abortReason: null, reportPath: null, laneCoverage, ...extra };
109
+ }
110
+ const emptyCounts = { total: 0, critical: 0, high: 0, medium: 0, low: 0 };
111
+
112
+ // ---- shared recon schema (tolerates a prose string via formatRecon) ----
113
+ const RECON_SCHEMA = {
114
+ type: "object", additionalProperties: false,
115
+ properties: {
116
+ languages: { type: "array", items: { type: "string" } },
117
+ frameworks: { type: "array", items: { type: "string" } },
118
+ packageManagers: { type: "array", items: { type: "string" } },
119
+ entryPoints: { type: "array", items: { type: "string" } },
120
+ testLayout: { type: "string" },
121
+ buildTooling: { type: "string" },
122
+ concurrencyModel: { type: "string" },
123
+ errorHandling: { type: "string" },
124
+ externalResources: { type: "array", items: { type: "string" } },
125
+ notes: { type: "string" },
126
+ },
127
+ required: ["languages", "notes"],
128
+ };
129
+ function formatRecon(r) {
130
+ if (typeof r === "string") return r;
131
+ if (!r || typeof r !== "object") return "No recon available.";
132
+ const L = (k, v) => (v && (Array.isArray(v) ? v.length : true) ? `${k}: ${Array.isArray(v) ? v.join(", ") : v}` : null);
133
+ return [
134
+ L("Languages", r.languages), L("Frameworks", r.frameworks), L("Package managers", r.packageManagers),
135
+ L("Entry points", r.entryPoints), L("Test layout", r.testLayout), L("Build tooling", r.buildTooling),
136
+ L("Concurrency model", r.concurrencyModel), L("Error handling", r.errorHandling),
137
+ L("External resources", r.externalResources), L("Notes", r.notes),
138
+ ].filter(Boolean).join("\n");
139
+ }
140
+
141
+ // ---- stable content fingerprint (djb2; deterministic, no crypto) ----
142
+ // <suite:fingerprintOf>
143
+ function fingerprintOf(f) {
144
+ const norm = (s) => (s || "").toString().toLowerCase().replace(/\s+/g, " ").trim();
145
+ const basis = `${DOMAIN}|${norm(f.file)}|${norm(f.category)}|${norm(f.description).slice(0, 160)}`;
146
+ let h = 5381;
147
+ for (let i = 0; i < basis.length; i++) h = ((h * 33) ^ basis.charCodeAt(i)) >>> 0;
148
+ return `${DOMAIN}-${h.toString(16)}`;
149
+ }
150
+ // </suite:fingerprintOf>
151
+
152
+ // ---- schemas ----
153
+ const FINDINGS_SCHEMA = {
154
+ type: "object", additionalProperties: false,
155
+ properties: {
156
+ findings: {
157
+ type: "array",
158
+ items: {
159
+ type: "object", additionalProperties: false,
160
+ properties: {
161
+ category: { type: "string" },
162
+ file: { type: "string" },
163
+ line: { type: "integer" },
164
+ severity: { type: "string", enum: ["high", "medium", "low"] },
165
+ description: { type: "string" },
166
+ targetUnderTest: { type: "string" },
167
+ suggestedTest: { type: "string" },
168
+ proposedChange: { type: "string" },
169
+ confidence: { type: "integer" },
170
+ effort: { type: "string", enum: ["small", "medium", "large"] },
171
+ docImpact: { type: "string" },
172
+ },
173
+ required: ["category", "file", "line", "severity", "description", "targetUnderTest", "suggestedTest", "proposedChange", "confidence", "effort", "docImpact"],
174
+ },
175
+ },
176
+ },
177
+ required: ["findings"],
178
+ };
179
+ const VERDICT_SCHEMA = {
180
+ type: "object", additionalProperties: false,
181
+ properties: {
182
+ refuted: { type: "boolean" },
183
+ reasoning: { type: "string" },
184
+ adjustedConfidence: { type: "integer" },
185
+ },
186
+ required: ["refuted", "reasoning", "adjustedConfidence"],
187
+ };
188
+
189
+ // ---- lenses ----
190
+ const LENS = {
191
+ "uncovered-public": "Public functions/methods/exports with NO test exercising them at all.",
192
+ "untested-error-path": "Error/exception/failure paths never asserted: thrown errors, rejected promises, non-happy returns, retries, rollbacks.",
193
+ "missing-edge-case": "Edge cases not covered: boundaries (0, 1, max), empty/null inputs, large inputs, unicode, concurrency, time/timezone.",
194
+ "branch-no-assertion": "Conditional branches that execute in tests but whose outcomes are never asserted (coverage without verification).",
195
+ "weak-critical-path": "Business-critical paths (auth, payments, data writes, security) with weak or shallow assertions.",
196
+ "untested-seam": "Integration seams (DB, network, file, queue boundaries) with no test, or only fully-mocked tests that assert nothing real.",
197
+ };
198
+
199
+ function finderPrompt(cat, recon, roundNote) {
200
+ return [
201
+ `You are the "${cat}" test-gap finder. REPORT-ONLY — do NOT write tests yet.`,
202
+ scope,
203
+ `Repo profile (recon):\n${formatRecon(recon)}`,
204
+ `Your lens: ${LENS[cat]}`,
205
+ roundNote || "",
206
+ `For EVERY finding set category to exactly "${cat}". Fill targetUnderTest (the function/branch/path that needs a test) and a concrete suggestedTest (a skeleton matching the repo's test framework/style). Only flag genuine gaps — verify the code isn't already tested elsewhere before flagging.`,
207
+ `Return findings via the structured output.`,
208
+ ].filter(Boolean).join("\n\n");
209
+ }
210
+
211
+ function skepticPrompt(f) {
212
+ return [
213
+ "You are a skeptic. Try to REFUTE the claimed test gap below — prove it IS already tested.",
214
+ scope,
215
+ `Claimed gap (${f.category}) at ${f.file}:${f.line}:`,
216
+ `Description: ${f.description}`,
217
+ `Target under test: ${f.targetUnderTest}`,
218
+ "Search the WHOLE test suite for DIRECT OR INDIRECT coverage. A gap is refuted if any test — even one written for a different purpose — exercises and asserts this target, OR if the target is unreachable/does not exist.",
219
+ "Set refuted=true if it is already covered (directly or indirectly) or not a real gap. Default to refuted=true when genuinely uncertain.",
220
+ ].join("\n\n");
221
+ }
222
+
223
+ // ---- 1. Recon (use injected recon if present, else profile once) ----
224
+ await phase("recon");
225
+ const recon = RT.recon
226
+ ? RT.recon
227
+ : await agent(
228
+ ["Profile this repository for test-gap analysis.", scope,
229
+ "Report: languages/frameworks; test framework(s) & runner; how tests are organized/named; whether a coverage tool is configured (note its name but do NOT run or install one); the public API surface and the most critical paths (auth, money, data writes).",
230
+ "Explore with your tools."].join("\n\n"),
231
+ { label: "recon", schema: RECON_SCHEMA, tier: TIER_RECON, onFailure: "returnNull" },
232
+ );
233
+
234
+ // ---- 2. Find + dedup ----
235
+ function dedup(findings) {
236
+ const seen = new Set(); const out = [];
237
+ for (const f of findings) {
238
+ if (!f) continue;
239
+ const key = `${f.category}::${(f.file || "").trim()}::${f.line || 0}`;
240
+ if (seen.has(key)) continue;
241
+ seen.add(key); out.push(f);
242
+ }
243
+ return out;
244
+ }
245
+
246
+ async function findRound(roundNote) {
247
+ await phase("find");
248
+ // arity-1 thunks (api) => ... so parallel() runs them CONCURRENTLY (zero-arg thunks fail unless { sequential: true } is explicit).
249
+ const results = await parallel(categories.map((cat) => (api) =>
250
+ api.agent(finderPrompt(cat, recon, roundNote), { label: `find:${cat}`, schema: FINDINGS_SCHEMA, tier: TIER_FINDER, onFailure: "returnNull" })));
251
+ tallyPhase("find", results, (i) => `find:${categories[i]}`);
252
+ return results.filter(Boolean).flatMap((r) => (r.findings || []).map((f) => ({ ...f, category: f.category || "unknown" })));
253
+ }
254
+
255
+ let findings = dedup(await findRound(null));
256
+ await log(`Round 1: ${findings.length} candidate gaps across ${categories.length} lenses`);
257
+
258
+ if (depth === "thorough") {
259
+ const known = findings.map((f) => `- ${f.category} ${f.file}:${f.line} — ${f.description}`).join("\n");
260
+ const round2 = await findRound(`SECOND pass. Already found below — find only NEW gaps, do not repeat:\n${known}`);
261
+ findings = dedup(findings.concat(round2));
262
+ await log(`After round 2: ${findings.length} candidates`);
263
+ }
264
+
265
+ // positional id (in-run reference) + stable content fingerprint (cross-run dedupe key)
266
+ findings = findings.map((f, i) => ({ ...f, id: `${f.category}-${i + 1}`, fingerprint: fingerprintOf(f) }));
267
+
268
+ if (findings.length === 0) {
269
+ return envelope("empty", { summary: "No test gaps found.", counts: emptyCounts, findings: [], truncatedFindings: false, reportMarkdown: null, shellCoverage: SHELL_COVERAGE, coverageLimitations: COVERAGE_LIMITATIONS });
270
+ }
271
+
272
+ // ---- 3. Verify (indirect-coverage refutation profile) ----
273
+ // quick: no verification. normal: verify HIGH_RISK categories only. thorough: verify ALL. Single-vote skeptic.
274
+ let toVerify;
275
+ if (depth === "quick") toVerify = [];
276
+ else if (depth === "thorough") toVerify = findings;
277
+ else toVerify = findings.filter((f) => HIGH_RISK.includes(f.category)); // normal
278
+ const verifyIds = new Set(toVerify.map((f) => f.id));
279
+ const passThrough = findings.filter((f) => !verifyIds.has(f.id));
280
+
281
+ let verified = passThrough;
282
+ if (toVerify.length > 0) {
283
+ await phase("verify");
284
+ const checked = await parallel(toVerify.map((f) => (api) =>
285
+ api.agent(skepticPrompt(f), { label: `verify:${f.id}`, schema: VERDICT_SCHEMA, tier: TIER_VERIFY, onFailure: "returnNull" })
286
+ .then((v) => ({ f, keep: !!(v && !v.refuted), conf: v ? v.adjustedConfidence : undefined }))));
287
+ tallyPhase("verify", checked, (i) => `verify:${toVerify[i].id}`);
288
+ const survivors = checked.filter(Boolean).filter((c) => c.keep)
289
+ .map((c) => ({ ...c.f, confidence: c.conf != null ? c.conf : c.f.confidence }));
290
+ verified = passThrough.concat(survivors);
291
+ await log(`Verified: ${survivors.length}/${toVerify.length} gaps survived; ${verified.length} total`);
292
+ }
293
+
294
+ if (verified.length === 0) {
295
+ return envelope("empty", { summary: "No test gaps survived verification.", counts: emptyCounts, findings: [], truncatedFindings: false, reportMarkdown: null, shellCoverage: SHELL_COVERAGE, coverageLimitations: COVERAGE_LIMITATIONS });
296
+ }
297
+
298
+ // ---- 4. Synthesize (PURE JS — dedup, rank, render; the host persists the returned object) ----
299
+ await phase("synthesize");
300
+ const SEVW = { high: 3, medium: 2, low: 1 };
301
+ const EFFD = { small: 1, medium: 0.8, large: 0.6 };
302
+ function score(f) { return (SEVW[f.severity] || 1) * ((f.confidence || 0) / 100) * (EFFD[f.effort] || 0.8); }
303
+
304
+ const ranked = verified.map((f) => ({ ...f })).sort((a, b) => score(b) - score(a));
305
+ ranked.forEach((f, i) => { f.rank = i + 1; });
306
+
307
+ const counts = {
308
+ total: ranked.length,
309
+ critical: 0, // non-security domain: critical tier never populated
310
+ high: ranked.filter((f) => f.severity === "high").length,
311
+ medium: ranked.filter((f) => f.severity === "medium").length,
312
+ low: ranked.filter((f) => f.severity === "low").length,
313
+ };
314
+
315
+ function mdCell(s) { return String(s == null ? "" : s).replace(/\|/g, "\\|").replace(/\n+/g, " "); }
316
+ function renderMarkdown(rows, c) {
317
+ const lines = [];
318
+ lines.push(`# Test Gaps Report (${DOMAIN})`, "");
319
+ lines.push("> Report-only. No files were modified, no tests were written, and no coverage tool was run.", "");
320
+ lines.push("## Summary", `- Total: ${c.total} (high: ${c.high}, medium: ${c.medium}, low: ${c.low})`, "");
321
+ lines.push(`## Coverage`, `- shellCoverage: ${SHELL_COVERAGE}`, `- coverageLimitations: ${COVERAGE_LIMITATIONS}`, "");
322
+ lines.push("## Ranked findings", "");
323
+ lines.push("| Rank | Category | Severity | Confidence | Location | Target | Description |");
324
+ lines.push("| ---- | -------- | -------- | ---------- | -------- | ------ | ----------- |");
325
+ for (const f of rows) {
326
+ lines.push(`| ${f.rank} | ${mdCell(f.category)} | ${mdCell(f.severity)} | ${f.confidence} | ${mdCell(f.file)}:${f.line || 0} | ${mdCell(f.targetUnderTest).slice(0, 80)} | ${mdCell(f.description).slice(0, 120)} |`);
327
+ }
328
+ lines.push("", "## Detail");
329
+ for (const f of rows) {
330
+ lines.push("", `### ${f.rank}. ${mdCell(f.category)} — ${mdCell(f.file)}:${f.line || 0} (${f.severity}, conf ${f.confidence})`);
331
+ lines.push(`- **What's untested:** ${f.description}`);
332
+ lines.push(`- **Target under test:** ${f.targetUnderTest}`);
333
+ lines.push(`- **Suggested test:** ${f.suggestedTest}`);
334
+ lines.push(`- **Proposed change:** ${f.proposedChange}`);
335
+ if (f.docImpact) lines.push(`- **Doc impact:** ${f.docImpact}`);
336
+ lines.push(`- **Fingerprint:** \`${f.fingerprint}\``);
337
+ }
338
+ return lines.join("\n");
339
+ }
340
+
341
+ // Size-fit to the 256 KB host cap: drop reportMarkdown first, then halve findings, until it fits.
342
+ function utf8ByteLength(value) {
343
+ const s = String(value ?? "");
344
+ let bytes = 0;
345
+ for (let i = 0; i < s.length; i += 1) {
346
+ const code = s.charCodeAt(i);
347
+ if (code <= 0x7f) bytes += 1;
348
+ else if (code <= 0x7ff) bytes += 2;
349
+ else if (code >= 0xd800 && code <= 0xdbff && i + 1 < s.length) {
350
+ const next = s.charCodeAt(i + 1);
351
+ if (next >= 0xdc00 && next <= 0xdfff) {
352
+ bytes += 4;
353
+ i += 1;
354
+ } else bytes += 3;
355
+ } else bytes += 3;
356
+ }
357
+ return bytes;
358
+ }
359
+ function jsonUtf8ByteLength(value) {
360
+ return utf8ByteLength(JSON.stringify(value));
361
+ }
362
+ function fitWithinBudget(status, summary) {
363
+ const LIMIT = 230000; // headroom under MAX_RESULT_BYTES (262144) for the {output:...} wrapper + envelope fields
364
+ let returned = ranked.slice(0, MAX_RETURN_FINDINGS);
365
+ let truncated = ranked.length > returned.length;
366
+ let reportMarkdown = renderMarkdown(ranked, counts);
367
+ const sizeOf = () => jsonUtf8ByteLength(envelope(status, { summary, counts, findings: returned, truncatedFindings: truncated, reportMarkdown, shellCoverage: SHELL_COVERAGE, coverageLimitations: COVERAGE_LIMITATIONS }));
368
+ if (sizeOf() > LIMIT) reportMarkdown = null;
369
+ while (sizeOf() > LIMIT && returned.length > 10) {
370
+ returned = returned.slice(0, Math.ceil(returned.length / 2));
371
+ truncated = true;
372
+ }
373
+ return envelope(status, { summary, counts, findings: returned, truncatedFindings: truncated, reportMarkdown, shellCoverage: SHELL_COVERAGE, coverageLimitations: COVERAGE_LIMITATIONS });
374
+ }
375
+
376
+ const summary = `Found ${counts.total} test gap(s): ${counts.high} high, ${counts.medium} medium, ${counts.low} low. Report-only — nothing applied.`;
377
+ return fitWithinBudget("ok", summary);