@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.
- package/CHANGELOG.md +14 -0
- package/CODE_OF_CONDUCT.md +134 -0
- package/CONTRIBUTING.md +95 -0
- package/LICENSE +21 -0
- package/README.md +746 -0
- package/SECURITY.md +38 -0
- package/commands/repo-bughunt.md +94 -0
- package/commands/repo-review.md +148 -0
- package/commands/workflow-live-gates-release-check.md +143 -0
- package/docs/workflow-plugin.md +400 -0
- package/opencode-workflows.js +5 -0
- package/package.json +86 -0
- package/skills/opencode-workflow-authoring/SKILL.md +180 -0
- package/skills/repo-review-command-protocol/SKILL.md +56 -0
- package/skills/workflow-model-tiering/SKILL.md +57 -0
- package/skills/workflow-plan-review/SKILL.md +91 -0
- package/workflow-kernel/approval-hashing.js +39 -0
- package/workflow-kernel/async-util.js +33 -0
- package/workflow-kernel/audited-shell-policy.js +200 -0
- package/workflow-kernel/authority-policy.js +670 -0
- package/workflow-kernel/budget-accounting.js +142 -0
- package/workflow-kernel/capability-adapter.js +753 -0
- package/workflow-kernel/child-agent-runner.js +1264 -0
- package/workflow-kernel/constants.js +117 -0
- package/workflow-kernel/diagnostics.js +152 -0
- package/workflow-kernel/drain-runtime.js +421 -0
- package/workflow-kernel/errors.js +181 -0
- package/workflow-kernel/event-journal.js +487 -0
- package/workflow-kernel/extension-registry.js +144 -0
- package/workflow-kernel/free-text-redactor.js +91 -0
- package/workflow-kernel/gate-shapes.js +82 -0
- package/workflow-kernel/git-util.js +45 -0
- package/workflow-kernel/index.js +72 -0
- package/workflow-kernel/integration-mode.js +155 -0
- package/workflow-kernel/lane-effort-policy.js +134 -0
- package/workflow-kernel/lifecycle-control.js +608 -0
- package/workflow-kernel/live-gate-probes.js +916 -0
- package/workflow-kernel/notification-toast-cards.js +393 -0
- package/workflow-kernel/notification-toast-policy.js +179 -0
- package/workflow-kernel/notification-toast-scope.js +100 -0
- package/workflow-kernel/notification-toast.js +287 -0
- package/workflow-kernel/path-policy.js +219 -0
- package/workflow-kernel/result-readback.js +106 -0
- package/workflow-kernel/role-template-loading.js +606 -0
- package/workflow-kernel/run-context.js +139 -0
- package/workflow-kernel/run-observability.js +43 -0
- package/workflow-kernel/run-store-fs.js +231 -0
- package/workflow-kernel/run-store-locks.js +180 -0
- package/workflow-kernel/run-store-projections.js +421 -0
- package/workflow-kernel/run-store-rehydrate.js +68 -0
- package/workflow-kernel/run-store-state.js +147 -0
- package/workflow-kernel/run-store-status-format.js +1154 -0
- package/workflow-kernel/run-store-status.js +107 -0
- package/workflow-kernel/sandbox-executor.js +1131 -0
- package/workflow-kernel/session-access.js +56 -0
- package/workflow-kernel/structured-output.js +143 -0
- package/workflow-kernel/test-fix-drain-adapter.js +119 -0
- package/workflow-kernel/text-json.js +136 -0
- package/workflow-kernel/workflow-plugin.js +3017 -0
- package/workflow-kernel/workflow-source.js +444 -0
- package/workflow-kernel/worktree-adapter.js +256 -0
- package/workflow-kernel/worktree-git.js +147 -0
- package/workflows/repo-bughunt.js +362 -0
- package/workflows/repo-cleanup.js +383 -0
- package/workflows/repo-complexity.js +404 -0
- package/workflows/repo-deps.js +457 -0
- package/workflows/repo-modernize.js +395 -0
- package/workflows/repo-perf.js +394 -0
- package/workflows/repo-review.js +831 -0
- package/workflows/repo-security-audit.js +466 -0
- package/workflows/repo-test-gaps.js +377 -0
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
// LEAF workflow — repo-modernize 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
|
+
// Report-only domain: this is a read-only-review leaf. It proposes
|
|
16
|
+
// modernizations and sequences a migrationPlan but it NEVER applies them — no
|
|
17
|
+
// installs, codemods, or package-manager mutations (the profile denies edit/
|
|
18
|
+
// bash/network at the OpenCode permission layer). The engine writes no files;
|
|
19
|
+
// reportPath is always null and the command wrapper persists the report.
|
|
20
|
+
export const meta = {
|
|
21
|
+
name: "repo-modernize",
|
|
22
|
+
description: "Find modernization opportunities across a repo (deprecated APIs, outdated idioms, legacy patterns, unneeded polyfills, config-format upgrades), adversarially verify HIGH_RISK items, and sequence a migration plan. Report-only: returns ranked structured findings + migrationPlan; the workflow writes no files and runs no installs/codemods/package-manager mutations.",
|
|
23
|
+
profile: "read-only-review",
|
|
24
|
+
maxAgents: 4096,
|
|
25
|
+
concurrency: 16,
|
|
26
|
+
phases: ["recon", "find", "verify", "synthesize"],
|
|
27
|
+
category: "repo-review-leaf",
|
|
28
|
+
notes: "Read-only report-only modernization leaf. No installs, codemods, or package-manager mutation. Finder lanes use fast tier, risky migration verification uses deep tier.",
|
|
29
|
+
examples: [
|
|
30
|
+
{ label: "normal modernization scan", args: { depth: "normal", paths: ["src"] } },
|
|
31
|
+
{ label: "deprecated APIs", args: { depth: "quick", categories: ["deprecated-api"], paths: ["src"] } },
|
|
32
|
+
],
|
|
33
|
+
argsSchema: {
|
|
34
|
+
type: ["object", "string", "null"],
|
|
35
|
+
properties: {
|
|
36
|
+
paths: { type: "array", items: { type: "string" } },
|
|
37
|
+
exclude: { type: "array", items: { type: "string" } },
|
|
38
|
+
depth: { type: "string", enum: ["quick", "normal", "thorough"] },
|
|
39
|
+
categories: { type: "array", items: { type: "string", enum: ["deprecated-api", "outdated-idiom", "legacy-pattern", "unneeded-polyfill", "config-upgrade"] } },
|
|
40
|
+
maxReturnFindings: { type: "integer", minimum: 1 },
|
|
41
|
+
recon: { type: ["object", "string"] },
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// ---- suite identity ----
|
|
47
|
+
const DOMAIN = "modernize";
|
|
48
|
+
const SCHEMA_VERSION = 1;
|
|
49
|
+
|
|
50
|
+
// args may arrive as an object (workflow_run args) or, defensively, a JSON string.
|
|
51
|
+
let RT = args;
|
|
52
|
+
if (typeof RT === "string") { try { RT = RT.trim() ? JSON.parse(RT) : {}; } catch (error) { throw new Error(`Invalid repo-modernize runtime args JSON: ${error.message}`); } }
|
|
53
|
+
if (!RT || typeof RT !== "object" || Array.isArray(RT)) RT = {};
|
|
54
|
+
|
|
55
|
+
const paths = Array.isArray(RT.paths) && RT.paths.length ? RT.paths : ["."];
|
|
56
|
+
const exclude = Array.isArray(RT.exclude) ? RT.exclude : ["node_modules", "dist", "build", ".git", "vendor", "target", "*.min.*", "*.map"];
|
|
57
|
+
const depth = ["quick", "normal", "thorough"].includes(RT.depth) ? RT.depth : "thorough";
|
|
58
|
+
|
|
59
|
+
// Model selection is intent-based: lanes declare a tier; the kernel resolves tier -> concrete model from
|
|
60
|
+
// run.modelTiers (set by the planning agent), falling back to the session-inherited default.
|
|
61
|
+
// recon + finders = bulk work (fast); skeptics = subtle correctness (deep); synth = pure JS (no model).
|
|
62
|
+
const TIER_RECON = "fast";
|
|
63
|
+
const TIER_FINDER = "fast";
|
|
64
|
+
const TIER_VERIFY = "deep";
|
|
65
|
+
|
|
66
|
+
const MAX_RETURN_FINDINGS = Number.isInteger(RT.maxReturnFindings) && RT.maxReturnFindings > 0 ? RT.maxReturnFindings : 1000000;
|
|
67
|
+
|
|
68
|
+
const ALL_CATEGORIES = ["deprecated-api", "outdated-idiom", "legacy-pattern", "unneeded-polyfill", "config-upgrade"];
|
|
69
|
+
const requestedCategories = Array.isArray(RT.categories) ? RT.categories.filter((c) => typeof c === "string" && c.trim()) : [];
|
|
70
|
+
const categories = requestedCategories.length > 0 ? requestedCategories.filter((c) => ALL_CATEGORIES.includes(c)) : ALL_CATEGORIES;
|
|
71
|
+
if (requestedCategories.length > 0 && categories.length === 0) {
|
|
72
|
+
throw new Error(`No valid repo-modernize categories supplied. Valid categories: ${ALL_CATEGORIES.join(", ")}`);
|
|
73
|
+
}
|
|
74
|
+
// HIGH_RISK categories are the ones a "normal" depth run subjects to adversarial verification: deprecated
|
|
75
|
+
// APIs and legacy patterns can actually break a build/runtime, so a skeptic must confirm the replacement
|
|
76
|
+
// is equivalent and available before reporting.
|
|
77
|
+
const HIGH_RISK = ["deprecated-api", "legacy-pattern"];
|
|
78
|
+
// Defects (breakage risk) vs optional modernizations — the migrationPlan separates and sequences them.
|
|
79
|
+
const DEFECT_CATEGORIES = HIGH_RISK;
|
|
80
|
+
|
|
81
|
+
const scope = `Scope: paths = ${JSON.stringify(paths)}. Exclude (do not scan/report): ${JSON.stringify(exclude)}.`;
|
|
82
|
+
|
|
83
|
+
// ---- lane coverage telemetry (iui1.2) ----
|
|
84
|
+
// Tracks expected/completed/dropped lane counts per phase so a dropped finder/verifier lane
|
|
85
|
+
// (onFailure returnNull + filter(Boolean)) surfaces as degraded coverage instead of a silent
|
|
86
|
+
// null. The meta reads laneCoverage.dropped to block materialization on partial coverage.
|
|
87
|
+
const laneCoverage = { expected: 0, completed: 0, dropped: 0, byPhase: {}, droppedLabels: [] };
|
|
88
|
+
function tallyPhase(name, results, labelOf) {
|
|
89
|
+
const expected = results.length;
|
|
90
|
+
let completed = 0;
|
|
91
|
+
for (let i = 0; i < results.length; i++) {
|
|
92
|
+
if (results[i] === null || results[i] === undefined) laneCoverage.droppedLabels.push(labelOf ? labelOf(i) : `${name}:${i + 1}`);
|
|
93
|
+
else completed++;
|
|
94
|
+
}
|
|
95
|
+
const dropped = expected - completed;
|
|
96
|
+
laneCoverage.expected += expected;
|
|
97
|
+
laneCoverage.completed += completed;
|
|
98
|
+
laneCoverage.dropped += dropped;
|
|
99
|
+
const prev = laneCoverage.byPhase[name] || { expected: 0, completed: 0, dropped: 0 };
|
|
100
|
+
laneCoverage.byPhase[name] = { expected: prev.expected + expected, completed: prev.completed + completed, dropped: prev.dropped + dropped };
|
|
101
|
+
return results;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ---- standardized return envelope ----
|
|
105
|
+
function envelope(status, extra) {
|
|
106
|
+
return { domain: DOMAIN, schemaVersion: SCHEMA_VERSION, status, abortReason: null, reportPath: null, laneCoverage, ...extra };
|
|
107
|
+
}
|
|
108
|
+
const emptyCounts = { total: 0, critical: 0, high: 0, medium: 0, low: 0 };
|
|
109
|
+
|
|
110
|
+
// ---- shared recon schema (tolerates a prose string via formatRecon) ----
|
|
111
|
+
const RECON_SCHEMA = {
|
|
112
|
+
type: "object", additionalProperties: false,
|
|
113
|
+
properties: {
|
|
114
|
+
languages: { type: "array", items: { type: "string" } },
|
|
115
|
+
frameworks: { type: "array", items: { type: "string" } },
|
|
116
|
+
packageManagers: { type: "array", items: { type: "string" } },
|
|
117
|
+
entryPoints: { type: "array", items: { type: "string" } },
|
|
118
|
+
testLayout: { type: "string" },
|
|
119
|
+
buildTooling: { type: "string" },
|
|
120
|
+
concurrencyModel: { type: "string" },
|
|
121
|
+
errorHandling: { type: "string" },
|
|
122
|
+
externalResources: { type: "array", items: { type: "string" } },
|
|
123
|
+
notes: { type: "string" },
|
|
124
|
+
},
|
|
125
|
+
required: ["languages", "notes"],
|
|
126
|
+
};
|
|
127
|
+
function formatRecon(r) {
|
|
128
|
+
if (typeof r === "string") return r;
|
|
129
|
+
if (!r || typeof r !== "object") return "No recon available.";
|
|
130
|
+
const L = (k, v) => (v && (Array.isArray(v) ? v.length : true) ? `${k}: ${Array.isArray(v) ? v.join(", ") : v}` : null);
|
|
131
|
+
return [
|
|
132
|
+
L("Languages", r.languages), L("Frameworks", r.frameworks), L("Package managers", r.packageManagers),
|
|
133
|
+
L("Entry points", r.entryPoints), L("Test layout", r.testLayout), L("Build tooling", r.buildTooling),
|
|
134
|
+
L("Concurrency model", r.concurrencyModel), L("Error handling", r.errorHandling),
|
|
135
|
+
L("External resources", r.externalResources), L("Notes", r.notes),
|
|
136
|
+
].filter(Boolean).join("\n");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ---- stable content fingerprint (djb2; deterministic, no crypto) ----
|
|
140
|
+
// <suite:fingerprintOf>
|
|
141
|
+
function fingerprintOf(f) {
|
|
142
|
+
const norm = (s) => (s || "").toString().toLowerCase().replace(/\s+/g, " ").trim();
|
|
143
|
+
const basis = `${DOMAIN}|${norm(f.file)}|${norm(f.category)}|${norm(f.description).slice(0, 160)}`;
|
|
144
|
+
let h = 5381;
|
|
145
|
+
for (let i = 0; i < basis.length; i++) h = ((h * 33) ^ basis.charCodeAt(i)) >>> 0;
|
|
146
|
+
return `${DOMAIN}-${h.toString(16)}`;
|
|
147
|
+
}
|
|
148
|
+
// </suite:fingerprintOf>
|
|
149
|
+
|
|
150
|
+
// ---- schemas ----
|
|
151
|
+
const FINDINGS_SCHEMA = {
|
|
152
|
+
type: "object", additionalProperties: false,
|
|
153
|
+
properties: {
|
|
154
|
+
findings: {
|
|
155
|
+
type: "array",
|
|
156
|
+
items: {
|
|
157
|
+
type: "object", additionalProperties: false,
|
|
158
|
+
properties: {
|
|
159
|
+
category: { type: "string" },
|
|
160
|
+
file: { type: "string" },
|
|
161
|
+
line: { type: "integer" },
|
|
162
|
+
severity: { type: "string", enum: ["high", "medium", "low"] },
|
|
163
|
+
description: { type: "string" },
|
|
164
|
+
deprecatedSince: { type: "string", description: "version/date it was deprecated, or empty" },
|
|
165
|
+
replacement: { type: "string", description: "the modern equivalent" },
|
|
166
|
+
targetVersion: { type: "string", description: "min version where the replacement is available, or empty" },
|
|
167
|
+
proposedChange: { type: "string", description: "the rewrite (report-only: describe, do NOT apply)" },
|
|
168
|
+
confidence: { type: "integer" },
|
|
169
|
+
effort: { type: "string", enum: ["small", "medium", "large"] },
|
|
170
|
+
docImpact: { type: "string" },
|
|
171
|
+
},
|
|
172
|
+
required: ["category", "file", "line", "severity", "description", "deprecatedSince", "replacement", "targetVersion", "proposedChange", "confidence", "effort", "docImpact"],
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
required: ["findings"],
|
|
177
|
+
};
|
|
178
|
+
const VERDICT_SCHEMA = {
|
|
179
|
+
type: "object", additionalProperties: false,
|
|
180
|
+
properties: {
|
|
181
|
+
refuted: { type: "boolean", description: "true if the replacement is not equivalent/available or the change is unsafe" },
|
|
182
|
+
reasoning: { type: "string" },
|
|
183
|
+
adjustedConfidence: { type: "integer" },
|
|
184
|
+
},
|
|
185
|
+
required: ["refuted", "reasoning", "adjustedConfidence"],
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
// ---- lenses ----
|
|
189
|
+
const LENS = {
|
|
190
|
+
"deprecated-api": "Deprecated API usage: standard-library or framework calls marked deprecated, scheduled for removal, or already removed in the target version. Note deprecatedSince and the replacement.",
|
|
191
|
+
"outdated-idiom": "Outdated language idioms where a clearer modern construct exists (e.g. var->const/let, callbacks->async/await, manual loops->iterators/comprehensions, string concat->templates).",
|
|
192
|
+
"legacy-pattern": "Legacy framework/ecosystem patterns superseded by modern ones (e.g. class components->hooks, older config/DI styles, old module systems). Ecosystem-specific.",
|
|
193
|
+
"unneeded-polyfill": "Polyfills, shims, or compatibility code no longer needed given the project's minimum supported runtime/target.",
|
|
194
|
+
"config-upgrade": "Config/build format upgrades: outdated config schemas, old build-tool configs, deprecated compiler/runtime options.",
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
function finderPrompt(cat, recon, roundNote) {
|
|
198
|
+
return [
|
|
199
|
+
`You are the "${cat}" modernization finder. REPORT-ONLY — do NOT modify files, run installs, codemods, or package-manager mutations.`,
|
|
200
|
+
scope,
|
|
201
|
+
`Repo profile (recon):\n${formatRecon(recon)}`,
|
|
202
|
+
`Your lens: ${LENS[cat]}`,
|
|
203
|
+
roundNote || "",
|
|
204
|
+
`For EVERY finding set category to exactly "${cat}". Fill deprecatedSince (version/date or empty), replacement (the modern equivalent), targetVersion (min version where the replacement is available, or empty), and proposedChange (describe the rewrite — do NOT apply it). Only flag changes that are equivalent and compatible with the project's stated minimum runtime/framework version.`,
|
|
205
|
+
`Return findings via the structured output.`,
|
|
206
|
+
].filter(Boolean).join("\n\n");
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function skepticPrompt(f) {
|
|
210
|
+
return [
|
|
211
|
+
"You are a skeptic. REFUTE the modernization below — prove the replacement is NOT equivalent, NOT available in the project's minimum supported version, or unsafe to apply.",
|
|
212
|
+
scope,
|
|
213
|
+
`Item (${f.category}) at ${f.file}:${f.line}:`,
|
|
214
|
+
`Description: ${f.description}`,
|
|
215
|
+
`Replacement: ${f.replacement} (target ${f.targetVersion || "n/a"})`,
|
|
216
|
+
"Investigate with your tools. Confirm the replacement is behavior-equivalent AND available in the project's minimum supported version. Set refuted=true otherwise. Default to refuted=true when genuinely uncertain.",
|
|
217
|
+
].join("\n\n");
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ---- 1. Recon (use injected recon if present, else profile once) ----
|
|
221
|
+
await phase("recon");
|
|
222
|
+
const recon = RT.recon
|
|
223
|
+
? RT.recon
|
|
224
|
+
: await agent(
|
|
225
|
+
["Profile this repository for a modernization pass.", scope,
|
|
226
|
+
"Report: language(s) + their version (from manifests/configs); framework(s) + versions; the project's minimum supported runtime/target; build tooling; anything pinned for compatibility. This determines which modernizations are safe.",
|
|
227
|
+
"Explore with your tools."].join("\n\n"),
|
|
228
|
+
{ label: "recon", schema: RECON_SCHEMA, tier: TIER_RECON, onFailure: "returnNull" },
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
// ---- 2. Find + dedup ----
|
|
232
|
+
function dedup(findings) {
|
|
233
|
+
const seen = new Set(); const out = [];
|
|
234
|
+
for (const f of findings) {
|
|
235
|
+
if (!f) continue;
|
|
236
|
+
const key = `${f.category}::${(f.file || "").trim()}::${f.line || 0}`;
|
|
237
|
+
if (seen.has(key)) continue;
|
|
238
|
+
seen.add(key); out.push(f);
|
|
239
|
+
}
|
|
240
|
+
return out;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function findRound(roundNote) {
|
|
244
|
+
await phase("find");
|
|
245
|
+
// arity-1 thunks (api) => ... so parallel() runs them CONCURRENTLY (zero-arg thunks fail unless { sequential: true } is explicit).
|
|
246
|
+
const results = await parallel(categories.map((cat) => (api) =>
|
|
247
|
+
api.agent(finderPrompt(cat, recon, roundNote), { label: `find:${cat}`, schema: FINDINGS_SCHEMA, tier: TIER_FINDER, onFailure: "returnNull" })));
|
|
248
|
+
tallyPhase("find", results, (i) => `find:${categories[i]}`);
|
|
249
|
+
return results.filter(Boolean).flatMap((r) => (r.findings || []).map((f) => ({ ...f, category: f.category || "unknown" })));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
let findings = dedup(await findRound(null));
|
|
253
|
+
await log(`Round 1: ${findings.length} modernization opportunities across ${categories.length} lenses`);
|
|
254
|
+
|
|
255
|
+
if (depth === "thorough") {
|
|
256
|
+
const known = findings.map((f) => `- ${f.category} ${f.file}:${f.line} — ${f.description}`).join("\n");
|
|
257
|
+
const round2 = await findRound(`SECOND pass. Already found below — find only NEW items, do not repeat:\n${known}`);
|
|
258
|
+
findings = dedup(findings.concat(round2));
|
|
259
|
+
await log(`After round 2: ${findings.length} items`);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// positional id (in-run reference) + stable content fingerprint (cross-run dedupe key)
|
|
263
|
+
findings = findings.map((f, i) => ({ ...f, id: `${f.category}-${i + 1}`, fingerprint: fingerprintOf(f) }));
|
|
264
|
+
|
|
265
|
+
if (findings.length === 0) {
|
|
266
|
+
return envelope("empty", { summary: "No modernization opportunities found.", counts: emptyCounts, findings: [], truncatedFindings: false, reportMarkdown: null, migrationPlan: [] });
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ---- 3. Verify (replacement-equivalence refutation profile) ----
|
|
270
|
+
// quick: no verification. normal: verify HIGH_RISK categories only. thorough: verify ALL. Single-vote skeptic.
|
|
271
|
+
let toVerify;
|
|
272
|
+
if (depth === "quick") toVerify = [];
|
|
273
|
+
else if (depth === "thorough") toVerify = findings;
|
|
274
|
+
else toVerify = findings.filter((f) => HIGH_RISK.includes(f.category)); // normal
|
|
275
|
+
const verifyIds = new Set(toVerify.map((f) => f.id));
|
|
276
|
+
const passThrough = findings.filter((f) => !verifyIds.has(f.id));
|
|
277
|
+
|
|
278
|
+
let verified = passThrough;
|
|
279
|
+
if (toVerify.length > 0) {
|
|
280
|
+
await phase("verify");
|
|
281
|
+
const checked = await parallel(toVerify.map((f) => (api) =>
|
|
282
|
+
api.agent(skepticPrompt(f), { label: `verify:${f.id}`, schema: VERDICT_SCHEMA, tier: TIER_VERIFY, onFailure: "returnNull" })
|
|
283
|
+
.then((v) => ({ f, keep: !!(v && !v.refuted), conf: v ? v.adjustedConfidence : undefined }))));
|
|
284
|
+
tallyPhase("verify", checked, (i) => `verify:${toVerify[i].id}`);
|
|
285
|
+
const survivors = checked.filter(Boolean).filter((c) => c.keep)
|
|
286
|
+
.map((c) => ({ ...c.f, confidence: c.conf != null ? c.conf : c.f.confidence }));
|
|
287
|
+
verified = passThrough.concat(survivors);
|
|
288
|
+
await log(`Verified: ${survivors.length}/${toVerify.length} items survived; ${verified.length} total`);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (verified.length === 0) {
|
|
292
|
+
return envelope("empty", { summary: "No modernization items survived verification.", counts: emptyCounts, findings: [], truncatedFindings: false, reportMarkdown: null, migrationPlan: [] });
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ---- 4. Synthesize (PURE JS — dedup, rank, separate defects/optional, sequence migrationPlan, render) ----
|
|
296
|
+
await phase("synthesize");
|
|
297
|
+
const SEVW = { high: 3, medium: 2, low: 1 };
|
|
298
|
+
const EFFD = { small: 1, medium: 0.8, large: 0.6 };
|
|
299
|
+
const EFFRANK = { small: 0, medium: 1, large: 2 };
|
|
300
|
+
function score(f) { return (SEVW[f.severity] || 1) * ((f.confidence || 0) / 100) * (EFFD[f.effort] || 0.8); }
|
|
301
|
+
|
|
302
|
+
const ranked = verified.map((f) => ({ ...f })).sort((a, b) => score(b) - score(a));
|
|
303
|
+
ranked.forEach((f, i) => { f.rank = i + 1; });
|
|
304
|
+
|
|
305
|
+
const counts = {
|
|
306
|
+
total: ranked.length,
|
|
307
|
+
critical: 0, // non-security domain: critical tier never populated
|
|
308
|
+
high: ranked.filter((f) => f.severity === "high").length,
|
|
309
|
+
medium: ranked.filter((f) => f.severity === "medium").length,
|
|
310
|
+
low: ranked.filter((f) => f.severity === "low").length,
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
// Separate DEFECTS (HIGH_RISK — breakage risk) from OPTIONAL modernizations, then sequence the
|
|
314
|
+
// migration plan: mechanical/low-effort first within each group, defects before optional. This is the
|
|
315
|
+
// top-level domain-extra carve-out (docs/repo-review-leaf-contract.md §2); it is NEVER empty here
|
|
316
|
+
// because verified.length > 0 (the empty-status branch above returns migrationPlan: []).
|
|
317
|
+
const defects = ranked.filter((f) => DEFECT_CATEGORIES.includes(f.category));
|
|
318
|
+
const optional = ranked.filter((f) => !DEFECT_CATEGORIES.includes(f.category));
|
|
319
|
+
const byEffort = (a, b) => (EFFRANK[a.effort] != null ? EFFRANK[a.effort] : 1) - (EFFRANK[b.effort] != null ? EFFRANK[b.effort] : 1);
|
|
320
|
+
function stepFor(f, kind) {
|
|
321
|
+
const tv = f.targetVersion ? ` (target ${f.targetVersion})` : "";
|
|
322
|
+
return `${kind} — ${f.category} ${f.file}:${f.line}: ${f.description} → replace with ${f.replacement || "the modern equivalent"}${tv}`;
|
|
323
|
+
}
|
|
324
|
+
const migrationPlan = []
|
|
325
|
+
.concat(defects.slice().sort(byEffort).map((f) => stepFor(f, "Defect")))
|
|
326
|
+
.concat(optional.slice().sort(byEffort).map((f) => stepFor(f, "Optional modernization")));
|
|
327
|
+
|
|
328
|
+
function mdCell(s) { return String(s == null ? "" : s).replace(/\|/g, "\\|").replace(/\n+/g, " "); }
|
|
329
|
+
function renderMarkdown(rows, c, plan) {
|
|
330
|
+
const lines = [];
|
|
331
|
+
lines.push(`# Modernization Report (${DOMAIN})`, "");
|
|
332
|
+
lines.push("> Report-only. No files were modified; no installs, codemods, or package-manager mutations were run.", "");
|
|
333
|
+
lines.push("## Summary", `- Total: ${c.total} (high: ${c.high}, medium: ${c.medium}, low: ${c.low})`, "");
|
|
334
|
+
if (plan.length) {
|
|
335
|
+
lines.push("## Migration plan", "Ordered: defects (breakage risk) first, mechanical/low-effort before larger; then optional modernizations.", "");
|
|
336
|
+
for (const step of plan) lines.push(`- ${mdCell(step)}`);
|
|
337
|
+
lines.push("");
|
|
338
|
+
}
|
|
339
|
+
lines.push("## Ranked findings", "");
|
|
340
|
+
lines.push("| Rank | Category | Severity | Confidence | Location | Replacement | Description |");
|
|
341
|
+
lines.push("| ---- | -------- | -------- | ---------- | -------- | ----------- | ----------- |");
|
|
342
|
+
for (const f of rows) {
|
|
343
|
+
lines.push(`| ${f.rank} | ${mdCell(f.category)} | ${mdCell(f.severity)} | ${f.confidence} | ${mdCell(f.file)}:${f.line || 0} | ${mdCell(f.replacement).slice(0, 80)} | ${mdCell(f.description).slice(0, 140)} |`);
|
|
344
|
+
}
|
|
345
|
+
lines.push("", "## Detail");
|
|
346
|
+
for (const f of rows) {
|
|
347
|
+
lines.push("", `### ${f.rank}. ${mdCell(f.category)} — ${mdCell(f.file)}:${f.line || 0} (${f.severity}, conf ${f.confidence})`);
|
|
348
|
+
lines.push(`- **What:** ${f.description}`);
|
|
349
|
+
if (f.deprecatedSince) lines.push(`- **Deprecated since:** ${f.deprecatedSince}`);
|
|
350
|
+
lines.push(`- **Replacement:** ${f.replacement}`);
|
|
351
|
+
if (f.targetVersion) lines.push(`- **Target version:** ${f.targetVersion}`);
|
|
352
|
+
lines.push(`- **Proposed change:** ${f.proposedChange}`);
|
|
353
|
+
if (f.docImpact) lines.push(`- **Doc impact:** ${f.docImpact}`);
|
|
354
|
+
lines.push(`- **Fingerprint:** \`${f.fingerprint}\``);
|
|
355
|
+
}
|
|
356
|
+
return lines.join("\n");
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Size-fit to the 256 KB host cap: drop reportMarkdown first, then halve findings, until it fits.
|
|
360
|
+
function utf8ByteLength(value) {
|
|
361
|
+
const s = String(value ?? "");
|
|
362
|
+
let bytes = 0;
|
|
363
|
+
for (let i = 0; i < s.length; i += 1) {
|
|
364
|
+
const code = s.charCodeAt(i);
|
|
365
|
+
if (code <= 0x7f) bytes += 1;
|
|
366
|
+
else if (code <= 0x7ff) bytes += 2;
|
|
367
|
+
else if (code >= 0xd800 && code <= 0xdbff && i + 1 < s.length) {
|
|
368
|
+
const next = s.charCodeAt(i + 1);
|
|
369
|
+
if (next >= 0xdc00 && next <= 0xdfff) {
|
|
370
|
+
bytes += 4;
|
|
371
|
+
i += 1;
|
|
372
|
+
} else bytes += 3;
|
|
373
|
+
} else bytes += 3;
|
|
374
|
+
}
|
|
375
|
+
return bytes;
|
|
376
|
+
}
|
|
377
|
+
function jsonUtf8ByteLength(value) {
|
|
378
|
+
return utf8ByteLength(JSON.stringify(value));
|
|
379
|
+
}
|
|
380
|
+
function fitWithinBudget(status, summary) {
|
|
381
|
+
const LIMIT = 230000; // headroom under MAX_RESULT_BYTES (262144) for the {output:...} wrapper + envelope fields
|
|
382
|
+
let returned = ranked.slice(0, MAX_RETURN_FINDINGS);
|
|
383
|
+
let truncated = ranked.length > returned.length;
|
|
384
|
+
let reportMarkdown = renderMarkdown(ranked, counts, migrationPlan);
|
|
385
|
+
const sizeOf = () => jsonUtf8ByteLength(envelope(status, { summary, counts, findings: returned, truncatedFindings: truncated, reportMarkdown, migrationPlan }));
|
|
386
|
+
if (sizeOf() > LIMIT) reportMarkdown = null;
|
|
387
|
+
while (sizeOf() > LIMIT && returned.length > 10) {
|
|
388
|
+
returned = returned.slice(0, Math.ceil(returned.length / 2));
|
|
389
|
+
truncated = true;
|
|
390
|
+
}
|
|
391
|
+
return envelope(status, { summary, counts, findings: returned, truncatedFindings: truncated, reportMarkdown, migrationPlan });
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const summary = `Found ${counts.total} modernization item(s): ${counts.high} high, ${counts.medium} medium, ${counts.low} low. Report-only — nothing applied.`;
|
|
395
|
+
return fitWithinBudget("ok", summary);
|