@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,457 @@
|
|
|
1
|
+
// LEAF workflow — repo-deps engine (OpenCode port).
|
|
2
|
+
//
|
|
3
|
+
// Conforms to docs/repo-review-leaf-contract.md (the shared leaf contract).
|
|
4
|
+
// Canonical exemplar: workflows/repo-bughunt.js + tests/repo-bughunt.test.mjs.
|
|
5
|
+
// Sibling analog: workflows/repo-cleanup.js (same shape, top-level carve-out).
|
|
6
|
+
// Port lineage (DOMAIN LOGIC only): internal Claude workflow source, adapted to
|
|
7
|
+
// the OpenCode QuickJS guest: no fs/Bash/git, tier-based models (never hard-coded
|
|
8
|
+
// provider models), PURE-JS synthesis (no model), and reportMarkdown returned in
|
|
9
|
+
// the envelope (the guest cannot write files, so reportPath is always null — the
|
|
10
|
+
// command wrapper persists the report). The Claude source delegated synthesis and
|
|
11
|
+
// report-writing to a model lane and instructed lanes to run package-manager
|
|
12
|
+
// audit/outdated tools; both are reversed here: synthesis is pure JS and the
|
|
13
|
+
// DEPENDENCY INSPECTION POLICY is read-only lockfile/manifest inspection with NO
|
|
14
|
+
// network, installs, or package-manager mutation.
|
|
15
|
+
//
|
|
16
|
+
// DOMAIN: deps. This is a NON-SECURITY domain, so counts.critical is ALWAYS 0 and
|
|
17
|
+
// findings never carry severity "critical" (contract §6). CVE findings are still
|
|
18
|
+
// reported (category "cve") at high/medium/low severity.
|
|
19
|
+
//
|
|
20
|
+
// POLICY (the defining constraint of this leaf): NO network, NO installs, NO
|
|
21
|
+
// package-manager mutation. Lanes inspect lockfiles and manifests READ-ONLY and
|
|
22
|
+
// derive versions/findings from local files. CVE/outdated claims that cannot be
|
|
23
|
+
// proven from local files alone are reported at REDUCED confidence with the gap
|
|
24
|
+
// noted. See DEPS_POLICY below.
|
|
25
|
+
//
|
|
26
|
+
// SHELL-INSPECTION DEFERRAL: the contract (§2/§12) and authority-policy.js reserve
|
|
27
|
+
// the "inspect-with-shell" profile for the documented deps/complexity parity gaps,
|
|
28
|
+
// where a lane could run a read-only command allowlist (e.g. `npm ls`, `cargo tree`)
|
|
29
|
+
// to ground version trees and populate shellCoverage/coverageLimitations. That
|
|
30
|
+
// profile requires verified `permissionEnforcement` + `commandScopedBash` live gates
|
|
31
|
+
// (authority-policy.js:21-24), which are currently UNVERIFIED in this runtime
|
|
32
|
+
// (Stage-0 gate rrev.1: FALLBACK-ACCEPTED, inspect-with-shell NOT verified). This
|
|
33
|
+
// leaf therefore ships the DEFAULT "read-only-review" profile (requiredGates: []) and
|
|
34
|
+
// the shell lens is DEFERRED until those gates are verified. When shell inspection is
|
|
35
|
+
// later enabled, define an explicit read-only command allowlist (deny install/audit
|
|
36
|
+
// network commands) and populate shellCoverage/coverageLimitations; until then, keep
|
|
37
|
+
// the reduced-confidence policy below.
|
|
38
|
+
export const meta = {
|
|
39
|
+
name: "repo-deps",
|
|
40
|
+
description: "Report-only dependency health audit: outdated packages, known CVEs (from local-file analysis), unused/undeclared deps, license risk, version conflicts, and deprecated packages. Read-only lockfile/manifest inspection — no network, no installs, no package-manager mutation. Adversarially verifies the high-risk 'unused/undeclared' findings, ranks them, and returns a structured envelope plus a sequenced upgradePlan. Report-only: returns ranked structured findings; the workflow writes no files.",
|
|
41
|
+
profile: "read-only-review",
|
|
42
|
+
maxAgents: 4096,
|
|
43
|
+
concurrency: 16,
|
|
44
|
+
phases: ["recon", "find", "verify", "synthesize"],
|
|
45
|
+
category: "repo-review-leaf",
|
|
46
|
+
notes: "Read-only report-only dependency leaf. Inspects local manifests/lockfiles only: no network, installs, audit, or package-manager mutation. Finder lanes use fast tier, high-risk verification uses deep tier.",
|
|
47
|
+
examples: [
|
|
48
|
+
{ label: "normal dependency scan", args: { depth: "normal", paths: ["."] } },
|
|
49
|
+
{ label: "unused and undeclared deps", args: { depth: "quick", categories: ["unused", "undeclared"], paths: ["."] } },
|
|
50
|
+
],
|
|
51
|
+
argsSchema: {
|
|
52
|
+
type: ["object", "string", "null"],
|
|
53
|
+
properties: {
|
|
54
|
+
paths: { type: "array", items: { type: "string" } },
|
|
55
|
+
exclude: { type: "array", items: { type: "string" } },
|
|
56
|
+
depth: { type: "string", enum: ["quick", "normal", "thorough"] },
|
|
57
|
+
categories: { type: "array", items: { type: "string", enum: ["outdated", "cve", "unused", "undeclared", "license", "version-conflict", "deprecated"] } },
|
|
58
|
+
maxReturnFindings: { type: "integer", minimum: 1 },
|
|
59
|
+
recon: { type: ["object", "string"] },
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// ---- suite identity ----
|
|
65
|
+
const DOMAIN = "deps";
|
|
66
|
+
const SCHEMA_VERSION = 1;
|
|
67
|
+
|
|
68
|
+
// args may arrive as an object (workflow_run args) or, defensively, a JSON string.
|
|
69
|
+
let RT = args;
|
|
70
|
+
if (typeof RT === "string") { try { RT = RT.trim() ? JSON.parse(RT) : {}; } catch (error) { throw new Error(`Invalid repo-deps runtime args JSON: ${error.message}`); } }
|
|
71
|
+
if (!RT || typeof RT !== "object" || Array.isArray(RT)) RT = {};
|
|
72
|
+
|
|
73
|
+
const paths = Array.isArray(RT.paths) && RT.paths.length ? RT.paths : ["."];
|
|
74
|
+
const exclude = Array.isArray(RT.exclude) ? RT.exclude : ["node_modules", "dist", "build", ".git", "vendor", "target", "*.min.*", "*.map"];
|
|
75
|
+
const depth = ["quick", "normal", "thorough"].includes(RT.depth) ? RT.depth : "thorough";
|
|
76
|
+
|
|
77
|
+
// Model selection is intent-based: lanes declare a tier; the kernel resolves tier -> concrete model from
|
|
78
|
+
// run.modelTiers (set by the planning agent), falling back to the session-inherited default.
|
|
79
|
+
// recon + finders = bulk work (fast); skeptics = subtle correctness (deep); synth = pure JS (no model).
|
|
80
|
+
const TIER_RECON = "fast";
|
|
81
|
+
const TIER_FINDER = "fast";
|
|
82
|
+
const TIER_VERIFY = "deep";
|
|
83
|
+
|
|
84
|
+
const MAX_RETURN_FINDINGS = Number.isInteger(RT.maxReturnFindings) && RT.maxReturnFindings > 0 ? RT.maxReturnFindings : 1000000;
|
|
85
|
+
|
|
86
|
+
const ALL_CATEGORIES = ["outdated", "cve", "unused", "undeclared", "license", "version-conflict", "deprecated"];
|
|
87
|
+
const requestedCategories = Array.isArray(RT.categories) ? RT.categories.filter((c) => typeof c === "string" && c.trim()) : [];
|
|
88
|
+
const categories = requestedCategories.length > 0 ? requestedCategories.filter((c) => ALL_CATEGORIES.includes(c)) : ALL_CATEGORIES;
|
|
89
|
+
if (requestedCategories.length > 0 && categories.length === 0) {
|
|
90
|
+
throw new Error(`No valid repo-deps categories supplied. Valid categories: ${ALL_CATEGORIES.join(", ")}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Categories where a wrong "remove/declare this" is most damaging — always verified
|
|
94
|
+
// in 'normal', and (with everything else) in 'thorough'. quick verifies nothing.
|
|
95
|
+
const HIGH_RISK = ["unused", "undeclared"];
|
|
96
|
+
|
|
97
|
+
// DOMAIN-SPECIFIC SCOPE (iui1.3): deps ALWAYS inspects manifests AND lockfiles — lockfiles carry the
|
|
98
|
+
// pinned/transitive version truth that manifests lack. *.lock is intentionally NOT in this domain's
|
|
99
|
+
// default exclude. The shared meta exclude no longer drops *.lock globally either; lockfile inclusion
|
|
100
|
+
// is a per-domain decision and for deps it is a hard include. (User-supplied excludes still win and
|
|
101
|
+
// are disclosed as a coverage gap if they remove a lockfile/manifest from scope.)
|
|
102
|
+
const DOMAIN_SCOPE_INCLUDES = "In scope for deps (always inspect): manifests (package.json, requirements.txt/pyproject.toml, go.mod, Cargo.toml, Gemfile, pom.xml, composer.json) AND lockfiles (yarn.lock, package-lock.json, pnpm-lock.yaml, composer.lock, Gemfile.lock, Podfile.lock, Cargo.lock, go.sum) — derive pinned/transitive versions and findings from these.";
|
|
103
|
+
const scope = `Scope: paths = ${JSON.stringify(paths)}. Exclude (do not scan/report): ${JSON.stringify(exclude)}. ${DOMAIN_SCOPE_INCLUDES}`;
|
|
104
|
+
|
|
105
|
+
// DEPENDENCY INSPECTION POLICY — the defining constraint of this leaf.
|
|
106
|
+
// Injected into every finder and the recon lane. Note: it deliberately uses the
|
|
107
|
+
// generic words "installs"/"mutation"/"network" rather than literal command tokens
|
|
108
|
+
// like "npm install" so policy checks can cleanly prove no positive mutation command
|
|
109
|
+
// is ever instructed.
|
|
110
|
+
const DEPS_POLICY = [
|
|
111
|
+
"DEPENDENCY INSPECTION POLICY (read-only):",
|
|
112
|
+
"- Inspect manifests and lockfiles ONLY. Derive versions and findings from local files.",
|
|
113
|
+
"- Never run installs, upgrades, or any package-manager mutation.",
|
|
114
|
+
"- Never fetch from the network or any advisory API/database.",
|
|
115
|
+
"- Never run audit/outdated tooling that mutates state or reaches the network.",
|
|
116
|
+
"- For CVE/outdated claims not provable from local files, set REDUCED confidence and note the gap.",
|
|
117
|
+
].join("\n");
|
|
118
|
+
|
|
119
|
+
// ---- lane coverage telemetry (iui1.2) ----
|
|
120
|
+
// Tracks expected/completed/dropped lane counts per phase so a dropped finder/verifier lane
|
|
121
|
+
// (onFailure returnNull + filter(Boolean)) surfaces as degraded coverage instead of a silent
|
|
122
|
+
// null. The meta reads laneCoverage.dropped to block materialization on partial coverage.
|
|
123
|
+
const laneCoverage = { expected: 0, completed: 0, dropped: 0, byPhase: {}, droppedLabels: [] };
|
|
124
|
+
function tallyPhase(name, results, labelOf) {
|
|
125
|
+
const expected = results.length;
|
|
126
|
+
let completed = 0;
|
|
127
|
+
for (let i = 0; i < results.length; i++) {
|
|
128
|
+
if (results[i] === null || results[i] === undefined) laneCoverage.droppedLabels.push(labelOf ? labelOf(i) : `${name}:${i + 1}`);
|
|
129
|
+
else completed++;
|
|
130
|
+
}
|
|
131
|
+
const dropped = expected - completed;
|
|
132
|
+
laneCoverage.expected += expected;
|
|
133
|
+
laneCoverage.completed += completed;
|
|
134
|
+
laneCoverage.dropped += dropped;
|
|
135
|
+
const prev = laneCoverage.byPhase[name] || { expected: 0, completed: 0, dropped: 0 };
|
|
136
|
+
laneCoverage.byPhase[name] = { expected: prev.expected + expected, completed: prev.completed + completed, dropped: prev.dropped + dropped };
|
|
137
|
+
return results;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ---- standardized return envelope ----
|
|
141
|
+
function envelope(status, extra) {
|
|
142
|
+
return { domain: DOMAIN, schemaVersion: SCHEMA_VERSION, status, abortReason: null, reportPath: null, laneCoverage, ...extra };
|
|
143
|
+
}
|
|
144
|
+
const emptyCounts = { total: 0, critical: 0, high: 0, medium: 0, low: 0 };
|
|
145
|
+
const emptyUpgradePlan = { safeBatch: [], breakingChanges: [] };
|
|
146
|
+
|
|
147
|
+
// ---- shared recon schema (tolerates a prose string via formatRecon) ----
|
|
148
|
+
const RECON_SCHEMA = {
|
|
149
|
+
type: "object", additionalProperties: false,
|
|
150
|
+
properties: {
|
|
151
|
+
languages: { type: "array", items: { type: "string" } },
|
|
152
|
+
frameworks: { type: "array", items: { type: "string" } },
|
|
153
|
+
packageManagers: { type: "array", items: { type: "string" } },
|
|
154
|
+
entryPoints: { type: "array", items: { type: "string" } },
|
|
155
|
+
testLayout: { type: "string" },
|
|
156
|
+
buildTooling: { type: "string" },
|
|
157
|
+
concurrencyModel: { type: "string" },
|
|
158
|
+
errorHandling: { type: "string" },
|
|
159
|
+
externalResources: { type: "array", items: { type: "string" } },
|
|
160
|
+
notes: { type: "string" },
|
|
161
|
+
},
|
|
162
|
+
required: ["languages", "notes"],
|
|
163
|
+
};
|
|
164
|
+
function formatRecon(r) {
|
|
165
|
+
if (typeof r === "string") return r;
|
|
166
|
+
if (!r || typeof r !== "object") return "No recon available.";
|
|
167
|
+
const L = (k, v) => (v && (Array.isArray(v) ? v.length : true) ? `${k}: ${Array.isArray(v) ? v.join(", ") : v}` : null);
|
|
168
|
+
return [
|
|
169
|
+
L("Languages", r.languages), L("Frameworks", r.frameworks), L("Package managers", r.packageManagers),
|
|
170
|
+
L("Entry points", r.entryPoints), L("Test layout", r.testLayout), L("Build tooling", r.buildTooling),
|
|
171
|
+
L("Concurrency model", r.concurrencyModel), L("Error handling", r.errorHandling),
|
|
172
|
+
L("External resources", r.externalResources), L("Notes", r.notes),
|
|
173
|
+
].filter(Boolean).join("\n");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---- stable content fingerprint (djb2; deterministic, no crypto) ----
|
|
177
|
+
// <suite:fingerprintOf>
|
|
178
|
+
function fingerprintOf(f) {
|
|
179
|
+
const norm = (s) => (s || "").toString().toLowerCase().replace(/\s+/g, " ").trim();
|
|
180
|
+
const basis = `${DOMAIN}|${norm(f.file)}|${norm(f.category)}|${norm(f.description).slice(0, 160)}`;
|
|
181
|
+
let h = 5381;
|
|
182
|
+
for (let i = 0; i < basis.length; i++) h = ((h * 33) ^ basis.charCodeAt(i)) >>> 0;
|
|
183
|
+
return `${DOMAIN}-${h.toString(16)}`;
|
|
184
|
+
}
|
|
185
|
+
// </suite:fingerprintOf>
|
|
186
|
+
|
|
187
|
+
// ---- schemas ----
|
|
188
|
+
// severity excludes "critical": deps is a NON-SECURITY domain (contract §6).
|
|
189
|
+
const FINDINGS_SCHEMA = {
|
|
190
|
+
type: "object", additionalProperties: false,
|
|
191
|
+
properties: {
|
|
192
|
+
findings: {
|
|
193
|
+
type: "array",
|
|
194
|
+
items: {
|
|
195
|
+
type: "object", additionalProperties: false,
|
|
196
|
+
properties: {
|
|
197
|
+
category: { type: "string" },
|
|
198
|
+
package: { type: "string" },
|
|
199
|
+
file: { type: "string", description: "manifest/lockfile path" },
|
|
200
|
+
line: { type: "integer" },
|
|
201
|
+
severity: { type: "string", enum: ["high", "medium", "low"] },
|
|
202
|
+
description: { type: "string" },
|
|
203
|
+
currentVersion: { type: "string" },
|
|
204
|
+
targetVersion: { type: "string", description: "recommended version, or empty string if none" },
|
|
205
|
+
breaking: { type: "boolean", description: "is the recommended upgrade a breaking (major) change" },
|
|
206
|
+
cve: { type: "array", items: { type: "string" } },
|
|
207
|
+
advisory: { type: "string" },
|
|
208
|
+
proposedChange: { type: "string" },
|
|
209
|
+
confidence: { type: "integer" },
|
|
210
|
+
effort: { type: "string", enum: ["small", "medium", "large"] },
|
|
211
|
+
docImpact: { type: "string" },
|
|
212
|
+
},
|
|
213
|
+
required: ["category", "package", "file", "line", "severity", "description", "currentVersion", "targetVersion", "breaking", "cve", "advisory", "proposedChange", "confidence", "effort", "docImpact"],
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
required: ["findings"],
|
|
218
|
+
};
|
|
219
|
+
const VERDICT_SCHEMA = {
|
|
220
|
+
type: "object", additionalProperties: false,
|
|
221
|
+
properties: {
|
|
222
|
+
refuted: { type: "boolean" },
|
|
223
|
+
reasoning: { type: "string" },
|
|
224
|
+
adjustedConfidence: { type: "integer" },
|
|
225
|
+
},
|
|
226
|
+
required: ["refuted", "reasoning", "adjustedConfidence"],
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
// ---- lenses (read-only inspection; no command execution) ----
|
|
230
|
+
const LENS = {
|
|
231
|
+
"outdated": "Outdated packages: compare the versions DECLARED in manifests/lockfiles against your knowledge of newer releases. Classify each bump as patch/minor/major and set breaking=true for majors. Derive current/target versions from the local files only.",
|
|
232
|
+
"cve": "Known vulnerabilities: read the pinned versions in the lockfile/manifest and flag packages fixed at versions you know to be vulnerable from advisories in your training data. Do NOT fetch advisory databases. For any CVE claim you cannot confirm from local files alone, set REDUCED confidence and note the gap in the description.",
|
|
233
|
+
"unused": "Declared dependencies never imported/used anywhere in the code. Cross-check the manifest against actual imports/requires. Be CONSERVATIVE — check build-only, type-only, plugin, config-only, and CLI-invoked deps before flagging. A wrong removal breaks the build.",
|
|
234
|
+
"undeclared": "Imported/required packages that are NOT declared in the manifest (relying on transitive resolution). Flag each, citing the importing file.",
|
|
235
|
+
"license": "License risk: copyleft (GPL/AGPL) or unknown/missing licenses among dependencies that may conflict with the project's own license. Read license metadata/files locally only.",
|
|
236
|
+
"version-conflict": "Duplicate or conflicting versions of the same package across the tree; peer-dependency conflicts; lockfile drift vs manifest. Cite both manifest and lockfile evidence.",
|
|
237
|
+
"deprecated": "Dependencies marked deprecated by their maintainers, or unmaintained (no releases in a long time, archived repos). Note the deprecation source if visible in local metadata.",
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
function finderPrompt(cat, recon, roundNote) {
|
|
241
|
+
return [
|
|
242
|
+
`You are the "${cat}" dependency analyst for a REPORT-ONLY audit. Do NOT modify any files.`,
|
|
243
|
+
DEPS_POLICY,
|
|
244
|
+
scope,
|
|
245
|
+
`Repo profile (recon):\n${formatRecon(recon)}`,
|
|
246
|
+
`Your track: ${LENS[cat]}`,
|
|
247
|
+
roundNote || "",
|
|
248
|
+
`For EVERY finding set category to exactly "${cat}". Fill package, currentVersion, targetVersion (empty string if none), breaking, cve (list, possibly empty), advisory, proposedChange, confidence, effort, docImpact (empty string if none).`,
|
|
249
|
+
`Set confidence honestly (0-100): high only when the claim is provable from local files. Quality over quantity — a wrong finding is worse than a missed one.`,
|
|
250
|
+
`Return findings via the structured output.`,
|
|
251
|
+
].filter(Boolean).join("\n\n");
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function skepticPrompt(f) {
|
|
255
|
+
return [
|
|
256
|
+
"You are a skeptic. Your job is to REFUTE the dependency finding below — prove it is wrong or a false positive.",
|
|
257
|
+
DEPS_POLICY,
|
|
258
|
+
scope,
|
|
259
|
+
`Finding (${f.category}) for package "${f.package}" in ${f.file}:${f.line}:`,
|
|
260
|
+
`Description: ${f.description}`,
|
|
261
|
+
`Proposed change: ${f.proposedChange}`,
|
|
262
|
+
"Investigate with your read/search tools. For 'unused': hunt exhaustively for ANY usage including dynamic imports, config refs, build/CLI usage, type-only imports. For upgrades: confirm whether the recommended target is genuinely breaking. For CVEs: confirm the pinned version actually falls in the vulnerable range.",
|
|
263
|
+
"Set refuted=true if the finding is wrong. Default to refuted=true when genuinely uncertain.",
|
|
264
|
+
].join("\n\n");
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ---- 1. Recon (use injected recon if present, else profile once) ----
|
|
268
|
+
await phase("recon");
|
|
269
|
+
const recon = RT.recon
|
|
270
|
+
? RT.recon
|
|
271
|
+
: await agent(
|
|
272
|
+
["Profile this repository's dependency setup for a READ-ONLY audit.", scope,
|
|
273
|
+
DEPS_POLICY,
|
|
274
|
+
"Report: languages/frameworks; package managers; manifest files (package.json, pyproject.toml, requirements.txt, go.mod, Cargo.toml, Gemfile, etc.) and their paths; lockfiles present; the project's own license.",
|
|
275
|
+
"Also note: entry points, test layout, build tooling, concurrency model, error-handling conventions, external resources.",
|
|
276
|
+
"Explore with your read/search tools only. Return the structured recon fields."].join("\n\n"),
|
|
277
|
+
{ label: "recon", schema: RECON_SCHEMA, tier: TIER_RECON, onFailure: "returnNull" },
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
// ---- 2. Find + dedup ----
|
|
281
|
+
function dedup(findings) {
|
|
282
|
+
const seen = new Set(); const out = [];
|
|
283
|
+
for (const f of findings) {
|
|
284
|
+
if (!f) continue;
|
|
285
|
+
const key = `${f.category}::${(f.package || "").trim()}::${(f.file || "").trim()}`;
|
|
286
|
+
if (seen.has(key)) continue;
|
|
287
|
+
seen.add(key); out.push(f);
|
|
288
|
+
}
|
|
289
|
+
return out;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async function findRound(roundNote) {
|
|
293
|
+
await phase("find");
|
|
294
|
+
// arity-1 thunks (api) => ... so parallel() runs them CONCURRENTLY (zero-arg thunks fail unless { sequential: true } is explicit).
|
|
295
|
+
const results = await parallel(categories.map((cat) => (api) =>
|
|
296
|
+
api.agent(finderPrompt(cat, recon, roundNote), { label: `find:${cat}`, schema: FINDINGS_SCHEMA, tier: TIER_FINDER, onFailure: "returnNull" })));
|
|
297
|
+
tallyPhase("find", results, (i) => `find:${categories[i]}`);
|
|
298
|
+
return results.filter(Boolean).flatMap((r) => (r.findings || []).map((f) => ({ ...f, category: f.category || "unknown" })));
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
let findings = dedup(await findRound(null));
|
|
302
|
+
await log(`Round 1: ${findings.length} dependency findings across ${categories.length} tracks`);
|
|
303
|
+
|
|
304
|
+
if (depth === "thorough") {
|
|
305
|
+
const known = findings.map((f) => `- ${f.category} ${f.package} — ${f.description}`).join("\n");
|
|
306
|
+
const round2 = await findRound(`SECOND pass. Already found below — find only NEW issues, do not repeat:\n${known}`);
|
|
307
|
+
findings = dedup(findings.concat(round2));
|
|
308
|
+
await log(`After round 2: ${findings.length} findings`);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// positional id (in-run reference) + stable content fingerprint (cross-run dedupe key)
|
|
312
|
+
findings = findings.map((f, i) => ({ ...f, id: `${f.category}-${i + 1}`, fingerprint: fingerprintOf(f) }));
|
|
313
|
+
|
|
314
|
+
if (findings.length === 0) {
|
|
315
|
+
return envelope("empty", { summary: "Dependencies look healthy.", counts: emptyCounts, findings: [], truncatedFindings: false, reportMarkdown: null, upgradePlan: emptyUpgradePlan });
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ---- 3. Verify (high-FP profile on the "remove/declare this" categories) ----
|
|
319
|
+
// quick: verify nothing. normal: verify HIGH_RISK categories (unused, undeclared). thorough: verify ALL.
|
|
320
|
+
function shouldVerify(f) {
|
|
321
|
+
if (depth === "quick") return false;
|
|
322
|
+
if (depth === "thorough") return true;
|
|
323
|
+
return HIGH_RISK.includes(f.category);
|
|
324
|
+
}
|
|
325
|
+
const toVerify = findings.filter(shouldVerify);
|
|
326
|
+
const passThrough = findings.filter((f) => !shouldVerify(f));
|
|
327
|
+
|
|
328
|
+
let verified = passThrough;
|
|
329
|
+
if (toVerify.length > 0) {
|
|
330
|
+
await phase("verify");
|
|
331
|
+
const checked = await parallel(toVerify.map((f) => (api) =>
|
|
332
|
+
api.agent(skepticPrompt(f), { label: `verify:${f.id}`, schema: VERDICT_SCHEMA, tier: TIER_VERIFY, onFailure: "returnNull" })
|
|
333
|
+
.then((v) => ({ f, keep: !!(v && !v.refuted), conf: v ? v.adjustedConfidence : undefined }))));
|
|
334
|
+
tallyPhase("verify", checked, (i) => `verify:${toVerify[i].id}`);
|
|
335
|
+
const survivors = checked.filter(Boolean).filter((c) => c.keep)
|
|
336
|
+
.map((c) => ({ ...c.f, confidence: c.conf != null ? c.conf : c.f.confidence }));
|
|
337
|
+
verified = passThrough.concat(survivors);
|
|
338
|
+
await log(`Verified: ${survivors.length}/${toVerify.length} high-risk findings survived; ${verified.length} total`);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (verified.length === 0) {
|
|
342
|
+
return envelope("empty", { summary: "No dependency issues survived verification.", counts: emptyCounts, findings: [], truncatedFindings: false, reportMarkdown: null, upgradePlan: emptyUpgradePlan });
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ---- 4. Synthesize (PURE JS — dedup, rank, build upgradePlan, render; the host persists the returned object) ----
|
|
346
|
+
await phase("synthesize");
|
|
347
|
+
const SEVW = { high: 3, medium: 2, low: 1 };
|
|
348
|
+
const EFFD = { small: 1, medium: 0.8, large: 0.6 };
|
|
349
|
+
function score(f) { return (SEVW[f.severity] || 1) * ((f.confidence || 0) / 100) * (EFFD[f.effort] || 0.8); }
|
|
350
|
+
|
|
351
|
+
const ranked = verified.map((f) => ({ ...f })).sort((a, b) => score(b) - score(a));
|
|
352
|
+
ranked.forEach((f, i) => { f.rank = i + 1; });
|
|
353
|
+
|
|
354
|
+
const counts = {
|
|
355
|
+
total: ranked.length,
|
|
356
|
+
critical: 0,
|
|
357
|
+
high: ranked.filter((f) => f.severity === "high").length,
|
|
358
|
+
medium: ranked.filter((f) => f.severity === "medium").length,
|
|
359
|
+
low: ranked.filter((f) => f.severity === "low").length,
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
// Per-domain carve-out (SUITE-CONTRACT § Per-domain carve-outs; contract §2):
|
|
363
|
+
// upgradePlan sequences the surviving findings into a non-breaking safe batch and a
|
|
364
|
+
// breaking-changes list. Built in PURE JS (the Claude source delegated this to a model
|
|
365
|
+
// lane; OpenCode synthesis is model-free). safeBatch = non-breaking bumps safe to apply
|
|
366
|
+
// together; breakingChanges = major/breaking upgrades each with a one-line migration note.
|
|
367
|
+
function buildUpgradePlan(rows) {
|
|
368
|
+
const safeBatch = [];
|
|
369
|
+
const breakingChanges = [];
|
|
370
|
+
for (const f of rows) {
|
|
371
|
+
const cur = f.currentVersion || "?";
|
|
372
|
+
const tgt = f.targetVersion || "latest";
|
|
373
|
+
const label = `${f.package}: ${cur} -> ${tgt}`;
|
|
374
|
+
if (f.breaking) {
|
|
375
|
+
const note = f.advisory || f.description || "breaking upgrade; migrate before applying";
|
|
376
|
+
breakingChanges.push(`${label} — ${note}`);
|
|
377
|
+
} else {
|
|
378
|
+
safeBatch.push(label);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return { safeBatch, breakingChanges };
|
|
382
|
+
}
|
|
383
|
+
const upgradePlan = buildUpgradePlan(ranked);
|
|
384
|
+
|
|
385
|
+
function mdCell(s) { return String(s == null ? "" : s).replace(/\|/g, "\\|").replace(/\n+/g, " "); }
|
|
386
|
+
function renderMarkdown(rows, c, plan) {
|
|
387
|
+
const lines = [];
|
|
388
|
+
lines.push(`# Dependency Audit Report (${DOMAIN})`, "");
|
|
389
|
+
lines.push("> Report-only. No files were modified, nothing was installed or upgraded.", "");
|
|
390
|
+
lines.push("## Summary", `- Total: ${c.total} (high: ${c.high}, medium: ${c.medium}, low: ${c.low})`, "");
|
|
391
|
+
lines.push("## Upgrade plan", "");
|
|
392
|
+
lines.push("### Safe batch (non-breaking)");
|
|
393
|
+
if (plan.safeBatch.length) for (const s of plan.safeBatch) lines.push(`- ${mdCell(s)}`);
|
|
394
|
+
else lines.push("- (none)");
|
|
395
|
+
lines.push("### Breaking changes");
|
|
396
|
+
if (plan.breakingChanges.length) for (const s of plan.breakingChanges) lines.push(`- ${mdCell(s)}`);
|
|
397
|
+
else lines.push("- (none)");
|
|
398
|
+
lines.push("");
|
|
399
|
+
lines.push("## Ranked findings", "");
|
|
400
|
+
lines.push("| Rank | Category | Package | Severity | Current -> Target | Breaking | CVE |");
|
|
401
|
+
lines.push("| ---- | -------- | ------- | -------- | ----------------- | -------- | --- |");
|
|
402
|
+
for (const f of rows) {
|
|
403
|
+
lines.push(`| ${f.rank} | ${mdCell(f.category)} | ${mdCell(f.package)} | ${mdCell(f.severity)} | ${mdCell(f.currentVersion)} -> ${mdCell(f.targetVersion)} | ${f.breaking ? "yes" : "no"} | ${mdCell((f.cve || []).join(", "))} |`);
|
|
404
|
+
}
|
|
405
|
+
lines.push("", "## Detail");
|
|
406
|
+
for (const f of rows) {
|
|
407
|
+
lines.push("", `### ${f.rank}. ${mdCell(f.category)} — ${mdCell(f.package)} (${f.severity}, conf ${f.confidence})`);
|
|
408
|
+
lines.push(`- **What:** ${f.description}`);
|
|
409
|
+
lines.push(`- **Manifest:** ${mdCell(f.file)}:${f.line || 0}`);
|
|
410
|
+
lines.push(`- **Current -> target:** ${f.currentVersion || "?"} -> ${f.targetVersion || "(none)"}`);
|
|
411
|
+
lines.push(`- **Breaking:** ${f.breaking ? "yes" : "no"}`);
|
|
412
|
+
if (f.advisory) lines.push(`- **Advisory:** ${f.advisory}`);
|
|
413
|
+
if (f.cve && f.cve.length) lines.push(`- **CVE:** ${(f.cve || []).join(", ")}`);
|
|
414
|
+
lines.push(`- **Proposed change:** ${f.proposedChange}`);
|
|
415
|
+
if (f.docImpact) lines.push(`- **Doc impact:** ${f.docImpact}`);
|
|
416
|
+
lines.push(`- **Fingerprint:** \`${f.fingerprint}\``);
|
|
417
|
+
}
|
|
418
|
+
return lines.join("\n");
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Size-fit to the 256 KB host cap: drop reportMarkdown first, then halve findings, until it fits.
|
|
422
|
+
function utf8ByteLength(value) {
|
|
423
|
+
const s = String(value ?? "");
|
|
424
|
+
let bytes = 0;
|
|
425
|
+
for (let i = 0; i < s.length; i += 1) {
|
|
426
|
+
const code = s.charCodeAt(i);
|
|
427
|
+
if (code <= 0x7f) bytes += 1;
|
|
428
|
+
else if (code <= 0x7ff) bytes += 2;
|
|
429
|
+
else if (code >= 0xd800 && code <= 0xdbff && i + 1 < s.length) {
|
|
430
|
+
const next = s.charCodeAt(i + 1);
|
|
431
|
+
if (next >= 0xdc00 && next <= 0xdfff) {
|
|
432
|
+
bytes += 4;
|
|
433
|
+
i += 1;
|
|
434
|
+
} else bytes += 3;
|
|
435
|
+
} else bytes += 3;
|
|
436
|
+
}
|
|
437
|
+
return bytes;
|
|
438
|
+
}
|
|
439
|
+
function jsonUtf8ByteLength(value) {
|
|
440
|
+
return utf8ByteLength(JSON.stringify(value));
|
|
441
|
+
}
|
|
442
|
+
function fitWithinBudget(status, summary) {
|
|
443
|
+
const LIMIT = 230000; // headroom under MAX_RESULT_BYTES (262144) for the {output:...} wrapper + envelope fields
|
|
444
|
+
let returned = ranked.slice(0, MAX_RETURN_FINDINGS);
|
|
445
|
+
let truncated = ranked.length > returned.length;
|
|
446
|
+
let reportMarkdown = renderMarkdown(ranked, counts, upgradePlan);
|
|
447
|
+
const sizeOf = () => jsonUtf8ByteLength(envelope(status, { summary, counts, findings: returned, truncatedFindings: truncated, reportMarkdown, upgradePlan }));
|
|
448
|
+
if (sizeOf() > LIMIT) reportMarkdown = null;
|
|
449
|
+
while (sizeOf() > LIMIT && returned.length > 10) {
|
|
450
|
+
returned = returned.slice(0, Math.ceil(returned.length / 2));
|
|
451
|
+
truncated = true;
|
|
452
|
+
}
|
|
453
|
+
return envelope(status, { summary, counts, findings: returned, truncatedFindings: truncated, reportMarkdown, upgradePlan });
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const summary = `Found ${counts.total} dependency finding(s): ${counts.high} high, ${counts.medium} medium, ${counts.low} low. Report-only — nothing installed or upgraded.`;
|
|
457
|
+
return fitWithinBudget("ok", summary);
|