sentinelayer-cli 0.25.1 → 0.25.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/agents/jules/tools/investor-dd.js +279 -0
- package/src/legacy-cli.js +15 -0
- package/src/review/investor-dd-orchestrator.js +7 -16
- package/src/review/investor-dd-persona-runner.js +91 -27
- package/src/review/investor-dd-progress.js +13 -27
- package/src/review/scan-modes.js +2 -2
package/package.json
CHANGED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { frontendAnalyze } from "./frontend-analyze.js";
|
|
2
|
+
|
|
3
|
+
const SEVERITIES = new Set(["P0", "P1", "P2", "P3"]);
|
|
4
|
+
|
|
5
|
+
function toPosix(value) {
|
|
6
|
+
return String(value || "").replace(/\\/g, "/");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function normalizeSeverity(value, fallback = "P2") {
|
|
10
|
+
const normalized = String(value || "").trim().toUpperCase();
|
|
11
|
+
return SEVERITIES.has(normalized) ? normalized : fallback;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function normalizeFiles(files) {
|
|
15
|
+
if (!Array.isArray(files)) return [];
|
|
16
|
+
return files.map((file) => toPosix(file).trim()).filter(Boolean);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function isInScope(file, scopeFiles) {
|
|
20
|
+
const normalized = toPosix(file);
|
|
21
|
+
if (!normalized || scopeFiles.length === 0) return true;
|
|
22
|
+
return scopeFiles.some((candidate) =>
|
|
23
|
+
candidate === normalized ||
|
|
24
|
+
candidate.endsWith(`/${normalized}`) ||
|
|
25
|
+
normalized.endsWith(`/${candidate}`)
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function createFinding({
|
|
30
|
+
kind,
|
|
31
|
+
severity,
|
|
32
|
+
file = "",
|
|
33
|
+
line = 0,
|
|
34
|
+
evidence = "",
|
|
35
|
+
rootCause = "",
|
|
36
|
+
recommendedFix = "",
|
|
37
|
+
confidence = 0.7,
|
|
38
|
+
} = {}) {
|
|
39
|
+
return {
|
|
40
|
+
persona: "frontend",
|
|
41
|
+
tool: "frontend-analyze",
|
|
42
|
+
kind: String(kind || "frontend.finding"),
|
|
43
|
+
severity: normalizeSeverity(severity),
|
|
44
|
+
file: toPosix(file),
|
|
45
|
+
line: Number.isFinite(Number(line)) ? Math.max(0, Math.floor(Number(line))) : 0,
|
|
46
|
+
evidence: String(evidence || "").trim().slice(0, 400),
|
|
47
|
+
rootCause: String(rootCause || "").trim(),
|
|
48
|
+
recommendedFix: String(recommendedFix || "").trim(),
|
|
49
|
+
confidence: Math.max(0, Math.min(1, Number(confidence) || 0)),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function dedupeFindings(findings) {
|
|
54
|
+
const seen = new Set();
|
|
55
|
+
const deduped = [];
|
|
56
|
+
for (const finding of findings) {
|
|
57
|
+
const key = [
|
|
58
|
+
finding.kind,
|
|
59
|
+
finding.file,
|
|
60
|
+
finding.line,
|
|
61
|
+
finding.evidence,
|
|
62
|
+
].join("|");
|
|
63
|
+
if (seen.has(key)) continue;
|
|
64
|
+
seen.add(key);
|
|
65
|
+
deduped.push(finding);
|
|
66
|
+
}
|
|
67
|
+
return deduped;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function analyze(operation, rootPath) {
|
|
71
|
+
try {
|
|
72
|
+
return frontendAnalyze({ operation, path: rootPath });
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Deterministic Investor-DD adapter for Jules/frontend.
|
|
80
|
+
*
|
|
81
|
+
* `frontendAnalyze` is intentionally repo-scoped, so the Investor-DD runner
|
|
82
|
+
* invokes this as a single `scope: "repo"` tool per frontend persona run.
|
|
83
|
+
*/
|
|
84
|
+
export async function runFrontendInvestorDdAnalyze({ rootPath, files = [] } = {}) {
|
|
85
|
+
const scopeFiles = normalizeFiles(files);
|
|
86
|
+
const findings = [];
|
|
87
|
+
|
|
88
|
+
const securitySinks = analyze("find_security_sinks", rootPath);
|
|
89
|
+
for (const sink of securitySinks?.sinks || []) {
|
|
90
|
+
if (!isInScope(sink.file, scopeFiles)) continue;
|
|
91
|
+
findings.push(createFinding({
|
|
92
|
+
kind: `frontend.security.${String(sink.type || "sink").replace(/\W+/g, "_")}`,
|
|
93
|
+
severity: sink.severity || "P2",
|
|
94
|
+
file: sink.file,
|
|
95
|
+
line: sink.line || 0,
|
|
96
|
+
evidence: `${sink.type || "frontend security sink"} detected`,
|
|
97
|
+
rootCause: "Frontend markup or script execution sink can create XSS or client-side code execution risk.",
|
|
98
|
+
recommendedFix: "Remove the sink or sanitize/escape untrusted content with a reviewed allowlist sanitizer.",
|
|
99
|
+
confidence: sink.severity === "P0" ? 0.85 : 0.75,
|
|
100
|
+
}));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const envExposure = analyze("find_env_exposure", rootPath);
|
|
104
|
+
for (const exposure of envExposure?.findings || []) {
|
|
105
|
+
if (!isInScope(exposure.file, scopeFiles)) continue;
|
|
106
|
+
findings.push(createFinding({
|
|
107
|
+
kind: "frontend.env_exposure",
|
|
108
|
+
severity: exposure.severity || (exposure.sensitive ? "P1" : "P3"),
|
|
109
|
+
file: exposure.file,
|
|
110
|
+
line: exposure.line || 0,
|
|
111
|
+
evidence: `${exposure.variable || "public env variable"} referenced in frontend source`,
|
|
112
|
+
rootCause: "Public frontend environment prefixes are shipped to the browser; sensitive names can leak credentials or encourage unsafe configuration.",
|
|
113
|
+
recommendedFix: "Keep secrets server-side and expose only explicitly public, non-sensitive configuration to client bundles.",
|
|
114
|
+
confidence: exposure.sensitive ? 0.8 : 0.45,
|
|
115
|
+
}));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const missingCleanup = analyze("find_missing_cleanup", rootPath);
|
|
119
|
+
for (const cleanup of missingCleanup?.findings || []) {
|
|
120
|
+
if (!isInScope(cleanup.file, scopeFiles)) continue;
|
|
121
|
+
findings.push(createFinding({
|
|
122
|
+
kind: "frontend.effect_missing_cleanup",
|
|
123
|
+
severity: cleanup.severity || "P2",
|
|
124
|
+
file: cleanup.file,
|
|
125
|
+
evidence: cleanup.pattern || "Effect with timer/subscription has no cleanup return",
|
|
126
|
+
rootCause: "Effects that subscribe or schedule timers without cleanup leak work across remounts and route changes.",
|
|
127
|
+
recommendedFix: "Return a cleanup function that unsubscribes, removes listeners, or clears timers.",
|
|
128
|
+
confidence: 0.7,
|
|
129
|
+
}));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const staleClosures = analyze("find_stale_closures", rootPath);
|
|
133
|
+
for (const closure of staleClosures?.findings || []) {
|
|
134
|
+
if (!isInScope(closure.file, scopeFiles)) continue;
|
|
135
|
+
findings.push(createFinding({
|
|
136
|
+
kind: "frontend.stale_closure",
|
|
137
|
+
severity: closure.severity || "P2",
|
|
138
|
+
file: closure.file,
|
|
139
|
+
line: closure.line || 0,
|
|
140
|
+
evidence: closure.pattern || "useEffect with empty dependency array may capture stale values",
|
|
141
|
+
rootCause: "Effects with empty dependencies can retain initial state/props while the UI continues to change.",
|
|
142
|
+
recommendedFix: "Declare dependencies, use stable refs intentionally, or move logic out of the effect.",
|
|
143
|
+
confidence: 0.55,
|
|
144
|
+
}));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const stateHooks = analyze("count_state_hooks", rootPath);
|
|
148
|
+
for (const component of stateHooks?.refactorCandidates || []) {
|
|
149
|
+
if (!isInScope(component.file, scopeFiles)) continue;
|
|
150
|
+
findings.push(createFinding({
|
|
151
|
+
kind: "frontend.state_god_component",
|
|
152
|
+
severity: component.risk === "god_component" ? "P2" : "P3",
|
|
153
|
+
file: component.file,
|
|
154
|
+
evidence: `${component.useStateCount} useState calls (${component.risk})`,
|
|
155
|
+
rootCause: "High local state count usually indicates a component with too many responsibilities and fragile render behavior.",
|
|
156
|
+
recommendedFix: "Split the component or consolidate related state transitions with a reducer/domain hook.",
|
|
157
|
+
confidence: component.risk === "god_component" ? 0.7 : 0.45,
|
|
158
|
+
}));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const accessibility = analyze("check_accessibility", rootPath);
|
|
162
|
+
for (const check of accessibility?.findings || []) {
|
|
163
|
+
const severity = normalizeSeverity(check.severity, "P3");
|
|
164
|
+
if (String(check.severity || "").toLowerCase() === "pass") continue;
|
|
165
|
+
if (Number(check.matchCount || 0) <= 0 && !String(check.check || "").includes("skip navigation")) continue;
|
|
166
|
+
findings.push(createFinding({
|
|
167
|
+
kind: "frontend.accessibility_check",
|
|
168
|
+
severity,
|
|
169
|
+
evidence: `${check.check || "accessibility heuristic"}; matches=${check.matchCount || 0}`,
|
|
170
|
+
rootCause: "Accessibility regressions block keyboard and assistive-technology users from completing core flows.",
|
|
171
|
+
recommendedFix: "Verify semantic labels, keyboard reachability, focus order, skip navigation, and automated axe coverage.",
|
|
172
|
+
confidence: 0.45,
|
|
173
|
+
}));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const mobile = analyze("check_mobile_responsive", rootPath);
|
|
177
|
+
for (const check of mobile?.findings || []) {
|
|
178
|
+
const rawSeverity = String(check.severity || "");
|
|
179
|
+
if (rawSeverity === "pass" || rawSeverity === "info") continue;
|
|
180
|
+
findings.push(createFinding({
|
|
181
|
+
kind: "frontend.mobile_responsive",
|
|
182
|
+
severity: check.severity || "P2",
|
|
183
|
+
evidence: `${check.check || "responsive heuristic"}; present=${check.present}; count=${check.count || 0}`,
|
|
184
|
+
rootCause: "Missing viewport or responsive styling evidence can make primary flows fail on mobile.",
|
|
185
|
+
recommendedFix: "Add viewport metadata and validate mobile/tablet breakpoints for the core user journey.",
|
|
186
|
+
confidence: 0.55,
|
|
187
|
+
}));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const errorBoundaries = analyze("check_error_boundaries", rootPath);
|
|
191
|
+
if (errorBoundaries && errorBoundaries.severity !== "pass") {
|
|
192
|
+
findings.push(createFinding({
|
|
193
|
+
kind: "frontend.error_boundary_gap",
|
|
194
|
+
severity: errorBoundaries.severity || "P2",
|
|
195
|
+
evidence: `errorBoundaryFiles=${errorBoundaries.errorBoundaryFiles || 0}; routeCount=${errorBoundaries.routeCount || 0}; coverage=${errorBoundaries.coverage || "n/a"}`,
|
|
196
|
+
rootCause: "Route-level runtime exceptions can blank the UI without a recoverable boundary.",
|
|
197
|
+
recommendedFix: "Add framework-native error boundaries for routed surfaces and test the failure state.",
|
|
198
|
+
confidence: 0.65,
|
|
199
|
+
}));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const images = analyze("check_image_optimization", rootPath);
|
|
203
|
+
if (images && images.severity !== "pass") {
|
|
204
|
+
findings.push(createFinding({
|
|
205
|
+
kind: "frontend.image_optimization",
|
|
206
|
+
severity: images.severity || "P2",
|
|
207
|
+
evidence: `rawImgTags=${images.rawImgTags || 0}; nextImageUsage=${images.nextImageUsage || 0}; missingDimensions=${images.missingDimensions || 0}`,
|
|
208
|
+
rootCause: "Unoptimized or dimensionless images can hurt LCP and cause layout shifts.",
|
|
209
|
+
recommendedFix: "Use framework image components or explicit dimensions/lazy loading for non-critical images.",
|
|
210
|
+
confidence: 0.55,
|
|
211
|
+
}));
|
|
212
|
+
} else if (images && Number(images.missingDimensions || 0) > 0) {
|
|
213
|
+
findings.push(createFinding({
|
|
214
|
+
kind: "frontend.image_dimensions_missing",
|
|
215
|
+
severity: "P2",
|
|
216
|
+
evidence: `missingDimensions=${images.missingDimensions}`,
|
|
217
|
+
rootCause: "Images without stable dimensions can cause cumulative layout shift.",
|
|
218
|
+
recommendedFix: "Set width/height or reserve aspect-ratio boxes for rendered images.",
|
|
219
|
+
confidence: 0.65,
|
|
220
|
+
}));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const fonts = analyze("check_font_loading", rootPath);
|
|
224
|
+
if (fonts && fonts.severity !== "pass") {
|
|
225
|
+
findings.push(createFinding({
|
|
226
|
+
kind: "frontend.font_loading",
|
|
227
|
+
severity: fonts.severity || "P2",
|
|
228
|
+
evidence: `fontDisplayUsage=${fonts.fontDisplayUsage || 0}; googleFontsUsage=${fonts.googleFontsUsage || 0}`,
|
|
229
|
+
rootCause: "Remote fonts without font-display controls can block text rendering or shift layout.",
|
|
230
|
+
recommendedFix: "Use font-display swap/optional, preload critical fonts, or self-host critical font assets.",
|
|
231
|
+
confidence: 0.55,
|
|
232
|
+
}));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const css = analyze("check_css_health", rootPath);
|
|
236
|
+
if (css && css.severity !== "pass") {
|
|
237
|
+
findings.push(createFinding({
|
|
238
|
+
kind: "frontend.css_health",
|
|
239
|
+
severity: css.severity || "P2",
|
|
240
|
+
evidence: `importantCount=${css.importantCount || 0}; tailwindConfigured=${Boolean(css.tailwindConfigured)}`,
|
|
241
|
+
rootCause: "Large numbers of !important declarations make UI state and responsive overrides brittle.",
|
|
242
|
+
recommendedFix: "Refactor repeated overrides into component variants or ordered utility/style layers.",
|
|
243
|
+
confidence: 0.45,
|
|
244
|
+
}));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const testCoverage = analyze("find_test_coverage", rootPath);
|
|
248
|
+
if (testCoverage && Number(testCoverage.componentCount || 0) > 0 && Number(testCoverage.untestedCount || 0) > 0) {
|
|
249
|
+
findings.push(createFinding({
|
|
250
|
+
kind: "frontend.test_coverage_gap",
|
|
251
|
+
severity: Number(testCoverage.testCount || 0) === 0 ? "P2" : "P3",
|
|
252
|
+
evidence: `components=${testCoverage.componentCount}; tests=${testCoverage.testCount}; untested=${testCoverage.untestedCount}; ratio=${testCoverage.coverageRatio || "n/a"}`,
|
|
253
|
+
rootCause: "Interactive components without nearby tests are likely to regress without being caught pre-merge.",
|
|
254
|
+
recommendedFix: "Add component/unit tests for primary states and at least one integration smoke for the core route.",
|
|
255
|
+
confidence: 0.55,
|
|
256
|
+
}));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return dedupeFindings(findings);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export const FRONTEND_TOOLS = Object.freeze({
|
|
263
|
+
"frontend-analyze": {
|
|
264
|
+
id: "frontend-analyze",
|
|
265
|
+
scope: "repo",
|
|
266
|
+
description:
|
|
267
|
+
"Run Jules Tanaka's deterministic frontend analyzer once for the routed frontend surface and convert UI/UX, accessibility, runtime, and web-vital heuristics into Investor-DD findings.",
|
|
268
|
+
schema: {
|
|
269
|
+
type: "object",
|
|
270
|
+
properties: {
|
|
271
|
+
rootPath: { type: "string" },
|
|
272
|
+
files: { type: "array", items: { type: "string" } },
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
handler: runFrontendInvestorDdAnalyze,
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
export const FRONTEND_TOOL_IDS = Object.freeze(Object.keys(FRONTEND_TOOLS));
|
package/src/legacy-cli.js
CHANGED
|
@@ -1148,6 +1148,8 @@ async function runLocalOmarGateCommand(args) {
|
|
|
1148
1148
|
const requireUsageLedger = hasCommandOption(args, "--require-usage-ledger");
|
|
1149
1149
|
const model = getCommandOptionValue(args, "--model") || "gpt-5.3-codex";
|
|
1150
1150
|
const provider = getCommandOptionValue(args, "--provider") || "sentinelayer";
|
|
1151
|
+
const personaCsvFlag = getCommandOptionValue(args, "--persona") || "";
|
|
1152
|
+
const skipPersonaCsvFlag = getCommandOptionValue(args, "--skip-persona") || "";
|
|
1151
1153
|
|
|
1152
1154
|
const targetPath = path.resolve(process.cwd(), pathArg);
|
|
1153
1155
|
if (!fs.existsSync(targetPath) || !fs.statSync(targetPath).isDirectory()) {
|
|
@@ -1155,6 +1157,11 @@ async function runLocalOmarGateCommand(args) {
|
|
|
1155
1157
|
}
|
|
1156
1158
|
|
|
1157
1159
|
const { runInvestorDd } = await import("./review/investor-dd-orchestrator.js");
|
|
1160
|
+
const { parsePersonaCsv, resolveFilteredPersonas } = await import("./review/scan-modes.js");
|
|
1161
|
+
const personaFilter = resolveFilteredPersonas("full-depth", {
|
|
1162
|
+
includeOnly: parsePersonaCsv(personaCsvFlag),
|
|
1163
|
+
skipPersonas: parsePersonaCsv(skipPersonaCsvFlag),
|
|
1164
|
+
});
|
|
1158
1165
|
const reportEmailClient = emailOnComplete
|
|
1159
1166
|
? await import("./review/dd-report-email-client.js")
|
|
1160
1167
|
: null;
|
|
@@ -1164,12 +1171,20 @@ async function runLocalOmarGateCommand(args) {
|
|
|
1164
1171
|
printInfo(
|
|
1165
1172
|
`Budget: $${maxCostUsd.toFixed(2)} / ${maxRuntimeMinutes}min / ${maxParallel} parallel`,
|
|
1166
1173
|
);
|
|
1174
|
+
printInfo(`Personas: ${personaFilter.personas.join(", ")}`);
|
|
1175
|
+
if (personaFilter.unknown.length > 0) {
|
|
1176
|
+
printInfo(`Unknown persona IDs ignored: ${personaFilter.unknown.join(", ")}`);
|
|
1177
|
+
}
|
|
1178
|
+
if (personaFilter.dropped.length > 0) {
|
|
1179
|
+
printInfo(`Skipped personas: ${personaFilter.dropped.join(", ")}`);
|
|
1180
|
+
}
|
|
1167
1181
|
if (dryRun) printInfo("Mode: dry-run (plan + stub report only)");
|
|
1168
1182
|
}
|
|
1169
1183
|
const result = await runInvestorDd({
|
|
1170
1184
|
rootPath: targetPath,
|
|
1171
1185
|
outputDir: outputDirArg,
|
|
1172
1186
|
budgetOptions: { maxCostUsd, maxRuntimeMinutes, maxParallel },
|
|
1187
|
+
personas: personaFilter.personas,
|
|
1173
1188
|
dryRun,
|
|
1174
1189
|
devTestBot: {
|
|
1175
1190
|
enabled: devTestBotEnabled,
|
|
@@ -41,21 +41,9 @@ import { renderInvestorDdHtml } from "./investor-dd-html-report.js";
|
|
|
41
41
|
import { runDevTestBotPhase } from "./investor-dd-devtestbot.js";
|
|
42
42
|
import { redactDdEmailError } from "./dd-report-email-client.js";
|
|
43
43
|
import { buildInvestorDdProgress } from "./investor-dd-progress.js";
|
|
44
|
+
import { FULL_DEPTH_PERSONAS } from "./scan-modes.js";
|
|
44
45
|
|
|
45
|
-
const INVESTOR_DD_PERSONAS = Object.freeze([
|
|
46
|
-
"security",
|
|
47
|
-
"backend",
|
|
48
|
-
"code-quality",
|
|
49
|
-
"testing",
|
|
50
|
-
"data-layer",
|
|
51
|
-
"reliability",
|
|
52
|
-
"release",
|
|
53
|
-
"observability",
|
|
54
|
-
"infrastructure",
|
|
55
|
-
"supply-chain",
|
|
56
|
-
"documentation",
|
|
57
|
-
"ai-governance",
|
|
58
|
-
]);
|
|
46
|
+
const INVESTOR_DD_PERSONAS = Object.freeze([...FULL_DEPTH_PERSONAS]);
|
|
59
47
|
|
|
60
48
|
/**
|
|
61
49
|
* Walk the target repo and return a list of relative POSIX file paths.
|
|
@@ -148,7 +136,10 @@ function buildSummaryMarkdown({ runId, summary, routing, byPersona }) {
|
|
|
148
136
|
lines.push("");
|
|
149
137
|
lines.push("| Persona | Files routed | Files visited | Findings |");
|
|
150
138
|
lines.push("|---|---:|---:|---:|");
|
|
151
|
-
|
|
139
|
+
const reportPersonas = Array.isArray(summary.personas) && summary.personas.length > 0
|
|
140
|
+
? summary.personas
|
|
141
|
+
: INVESTOR_DD_PERSONAS;
|
|
142
|
+
for (const personaId of reportPersonas) {
|
|
152
143
|
const record = byPersona[personaId] || {};
|
|
153
144
|
const routed = (routing[personaId] || []).length;
|
|
154
145
|
const visited = Array.isArray(record.visited) ? record.visited.length : 0;
|
|
@@ -264,7 +255,7 @@ async function triggerReportEmail({ reportEmail, runResult, dryRun, emit }) {
|
|
|
264
255
|
* @param {string} params.rootPath
|
|
265
256
|
* @param {string} [params.outputDir] - Defaults to `<rootPath>/.sentinelayer/runs/<runId>`.
|
|
266
257
|
* @param {object} [params.budgetOptions] - Overrides from CLI: { maxUsd, maxRuntimeMinutes, maxParallel }.
|
|
267
|
-
* @param {string[]} [params.personas] - Override persona list; defaults to all
|
|
258
|
+
* @param {string[]} [params.personas] - Override persona list; defaults to all 13.
|
|
268
259
|
* @param {Function} [params.onEvent] - Extra event sink (NDJSON stream is always written).
|
|
269
260
|
* @param {boolean} [params.dryRun] - If true, skip tool execution, emit plan.json + stub report only.
|
|
270
261
|
* @param {string[]|null} [params.compliancePacks] - Compliance pack IDs to run (default: all seven).
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Unified per-persona runner for investor-DD (#investor-dd-4..15).
|
|
3
3
|
*
|
|
4
|
-
* Wires the
|
|
4
|
+
* Wires the 13 domain personas (security, backend, code-quality, testing,
|
|
5
5
|
* data-layer, reliability, release, observability, infrastructure,
|
|
6
|
-
* supply-chain, documentation, ai-governance) into the
|
|
6
|
+
* supply-chain, frontend, documentation, ai-governance) into the review
|
|
7
7
|
* loop. Each persona dispatches its declared domain tools against every
|
|
8
8
|
* file the router assigns to it, collects findings, and returns a
|
|
9
9
|
* per-persona coverage proof.
|
|
@@ -12,9 +12,8 @@
|
|
|
12
12
|
* (LLM-driven) layer sits on top in a later PR. Determinism is the
|
|
13
13
|
* investor-DD ground-truth floor; the LLM layer is additive.
|
|
14
14
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* via a dedicated live-validator dispatcher.
|
|
15
|
+
* Repo-scoped tools such as Jules' frontend analyzer run once per persona
|
|
16
|
+
* with the full routed file set; file-scoped tools retain the per-file loop.
|
|
18
17
|
*/
|
|
19
18
|
|
|
20
19
|
import { AI_GOVERNANCE_TOOLS } from "../agents/ai-governance/tools/index.js";
|
|
@@ -29,11 +28,12 @@ import { RELIABILITY_TOOLS } from "../agents/reliability/tools/index.js";
|
|
|
29
28
|
import { SECURITY_TOOLS } from "../agents/security/tools/index.js";
|
|
30
29
|
import { SUPPLY_CHAIN_TOOLS } from "../agents/supply-chain/tools/index.js";
|
|
31
30
|
import { TESTING_TOOLS } from "../agents/testing/tools/index.js";
|
|
31
|
+
import { FRONTEND_TOOLS } from "../agents/jules/tools/investor-dd.js";
|
|
32
32
|
|
|
33
33
|
import { checkBudget, createBudgetState } from "./investor-dd-file-loop.js";
|
|
34
34
|
|
|
35
35
|
/**
|
|
36
|
-
* Registry of persona → tool map.
|
|
36
|
+
* Registry of persona → tool map.
|
|
37
37
|
*/
|
|
38
38
|
export const INVESTOR_DD_PERSONA_TOOL_REGISTRY = Object.freeze({
|
|
39
39
|
security: SECURITY_TOOLS,
|
|
@@ -46,6 +46,7 @@ export const INVESTOR_DD_PERSONA_TOOL_REGISTRY = Object.freeze({
|
|
|
46
46
|
observability: OBSERVABILITY_TOOLS,
|
|
47
47
|
infrastructure: INFRASTRUCTURE_TOOLS,
|
|
48
48
|
"supply-chain": SUPPLY_CHAIN_TOOLS,
|
|
49
|
+
frontend: FRONTEND_TOOLS,
|
|
49
50
|
documentation: DOCUMENTATION_TOOLS,
|
|
50
51
|
"ai-governance": AI_GOVERNANCE_TOOLS,
|
|
51
52
|
});
|
|
@@ -66,31 +67,15 @@ export function getPersonaTools(personaId) {
|
|
|
66
67
|
return Object.values(map);
|
|
67
68
|
}
|
|
68
69
|
|
|
69
|
-
|
|
70
|
-
* Dispatch every tool for the persona against a single file scope. Each
|
|
71
|
-
* tool handler is invoked with `{ rootPath, files: [file] }` because
|
|
72
|
-
* every persona's tool contract accepts this shape (see #A13-#A22).
|
|
73
|
-
*
|
|
74
|
-
* @param {object} params
|
|
75
|
-
* @param {string} params.personaId
|
|
76
|
-
* @param {string} params.file - Single file (relative to rootPath).
|
|
77
|
-
* @param {string} params.rootPath - Repo root.
|
|
78
|
-
* @param {object} params.budget - Shared budget state.
|
|
79
|
-
* @param {Function} [params.onEvent]
|
|
80
|
-
* @returns {Promise<{findings: Array, toolInvocations: Array, stoppedEarly: boolean}>}
|
|
81
|
-
*/
|
|
82
|
-
export async function runPersonaOnFile({
|
|
70
|
+
async function runTools({
|
|
83
71
|
personaId,
|
|
84
72
|
file,
|
|
73
|
+
files = null,
|
|
85
74
|
rootPath,
|
|
86
75
|
budget,
|
|
76
|
+
tools,
|
|
87
77
|
onEvent = () => {},
|
|
88
78
|
} = {}) {
|
|
89
|
-
if (!personaId) throw new TypeError("runPersonaOnFile requires personaId");
|
|
90
|
-
if (!file) throw new TypeError("runPersonaOnFile requires file");
|
|
91
|
-
if (!rootPath) throw new TypeError("runPersonaOnFile requires rootPath");
|
|
92
|
-
|
|
93
|
-
const tools = getPersonaTools(personaId);
|
|
94
79
|
const findings = [];
|
|
95
80
|
const toolInvocations = [];
|
|
96
81
|
let stoppedEarly = false;
|
|
@@ -112,7 +97,7 @@ export async function runPersonaOnFile({
|
|
|
112
97
|
onEvent({ type: "persona_file_tool_call", personaId, file, tool: tool.id });
|
|
113
98
|
|
|
114
99
|
try {
|
|
115
|
-
const results = await tool.handler({ rootPath, files: [file] });
|
|
100
|
+
const results = await tool.handler({ rootPath, files: Array.isArray(files) ? files : [file] });
|
|
116
101
|
if (budget) budget.toolCalls = (budget.toolCalls || 0) + 1;
|
|
117
102
|
const normalized = Array.isArray(results) ? results : [];
|
|
118
103
|
for (const f of normalized) {
|
|
@@ -142,6 +127,40 @@ export async function runPersonaOnFile({
|
|
|
142
127
|
return { findings, toolInvocations, stoppedEarly };
|
|
143
128
|
}
|
|
144
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Dispatch every tool for the persona against a single file scope. Each
|
|
132
|
+
* tool handler is invoked with `{ rootPath, files: [file] }` because
|
|
133
|
+
* every persona's tool contract accepts this shape (see #A13-#A22).
|
|
134
|
+
*
|
|
135
|
+
* @param {object} params
|
|
136
|
+
* @param {string} params.personaId
|
|
137
|
+
* @param {string} params.file - Single file (relative to rootPath).
|
|
138
|
+
* @param {string} params.rootPath - Repo root.
|
|
139
|
+
* @param {object} params.budget - Shared budget state.
|
|
140
|
+
* @param {Function} [params.onEvent]
|
|
141
|
+
* @returns {Promise<{findings: Array, toolInvocations: Array, stoppedEarly: boolean}>}
|
|
142
|
+
*/
|
|
143
|
+
export async function runPersonaOnFile({
|
|
144
|
+
personaId,
|
|
145
|
+
file,
|
|
146
|
+
rootPath,
|
|
147
|
+
budget,
|
|
148
|
+
onEvent = () => {},
|
|
149
|
+
} = {}) {
|
|
150
|
+
if (!personaId) throw new TypeError("runPersonaOnFile requires personaId");
|
|
151
|
+
if (!file) throw new TypeError("runPersonaOnFile requires file");
|
|
152
|
+
if (!rootPath) throw new TypeError("runPersonaOnFile requires rootPath");
|
|
153
|
+
|
|
154
|
+
return runTools({
|
|
155
|
+
personaId,
|
|
156
|
+
file,
|
|
157
|
+
rootPath,
|
|
158
|
+
budget,
|
|
159
|
+
tools: getPersonaTools(personaId),
|
|
160
|
+
onEvent,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
145
164
|
/**
|
|
146
165
|
* Run a single persona across its assigned files in the router output.
|
|
147
166
|
*
|
|
@@ -167,6 +186,50 @@ export async function runPersonaAcrossFiles({
|
|
|
167
186
|
const visited = [];
|
|
168
187
|
const skipped = [];
|
|
169
188
|
let terminationReason = "ok";
|
|
189
|
+
const tools = getPersonaTools(personaId);
|
|
190
|
+
const repoScopedTools = tools.filter((tool) => tool.scope === "repo");
|
|
191
|
+
const fileScopedTools = tools.filter((tool) => tool.scope !== "repo");
|
|
192
|
+
|
|
193
|
+
if (repoScopedTools.length > 0) {
|
|
194
|
+
const budgetCheck = checkBudget(safeBudget);
|
|
195
|
+
if (!budgetCheck.ok) {
|
|
196
|
+
terminationReason = budgetCheck.reason;
|
|
197
|
+
skipped.push(...files);
|
|
198
|
+
onEvent({ type: "persona_file_skipped", personaId, file: "__repo__", stopReason: budgetCheck.reason });
|
|
199
|
+
} else {
|
|
200
|
+
onEvent({ type: "persona_file_start", personaId, file: "__repo__", fileCount: files.length });
|
|
201
|
+
const { findings, toolInvocations, stoppedEarly } = await runTools({
|
|
202
|
+
personaId,
|
|
203
|
+
file: "__repo__",
|
|
204
|
+
files,
|
|
205
|
+
rootPath,
|
|
206
|
+
budget: safeBudget,
|
|
207
|
+
tools: repoScopedTools,
|
|
208
|
+
onEvent,
|
|
209
|
+
});
|
|
210
|
+
perFile.push({ file: "__repo__", scope: "repo", files, findings, toolInvocations, stoppedEarly });
|
|
211
|
+
allFindings.push(...findings);
|
|
212
|
+
visited.push(...files);
|
|
213
|
+
onEvent({
|
|
214
|
+
type: "persona_file_complete",
|
|
215
|
+
personaId,
|
|
216
|
+
file: "__repo__",
|
|
217
|
+
findingCount: findings.length,
|
|
218
|
+
toolCount: toolInvocations.length,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (fileScopedTools.length === 0) {
|
|
224
|
+
return {
|
|
225
|
+
personaId,
|
|
226
|
+
perFile,
|
|
227
|
+
findings: allFindings,
|
|
228
|
+
visited,
|
|
229
|
+
skipped,
|
|
230
|
+
terminationReason,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
170
233
|
|
|
171
234
|
for (const file of files) {
|
|
172
235
|
const budgetCheck = checkBudget(safeBudget);
|
|
@@ -178,11 +241,12 @@ export async function runPersonaAcrossFiles({
|
|
|
178
241
|
}
|
|
179
242
|
|
|
180
243
|
onEvent({ type: "persona_file_start", personaId, file });
|
|
181
|
-
const { findings, toolInvocations, stoppedEarly } = await
|
|
244
|
+
const { findings, toolInvocations, stoppedEarly } = await runTools({
|
|
182
245
|
personaId,
|
|
183
246
|
file,
|
|
184
247
|
rootPath,
|
|
185
248
|
budget: safeBudget,
|
|
249
|
+
tools: fileScopedTools,
|
|
186
250
|
onEvent,
|
|
187
251
|
});
|
|
188
252
|
perFile.push({ file, findings, toolInvocations, stoppedEarly });
|
|
@@ -1,20 +1,8 @@
|
|
|
1
|
+
import { FULL_DEPTH_PERSONAS } from "./scan-modes.js";
|
|
2
|
+
|
|
1
3
|
export const INVESTOR_DD_PROGRESS_VERSION = "investor_dd_progress_v1";
|
|
2
4
|
|
|
3
|
-
export const INVESTOR_DD_EXPECTED_PERSONAS = Object.freeze([
|
|
4
|
-
"security",
|
|
5
|
-
"backend",
|
|
6
|
-
"code-quality",
|
|
7
|
-
"testing",
|
|
8
|
-
"data-layer",
|
|
9
|
-
"reliability",
|
|
10
|
-
"release",
|
|
11
|
-
"observability",
|
|
12
|
-
"infrastructure",
|
|
13
|
-
"supply-chain",
|
|
14
|
-
"documentation",
|
|
15
|
-
"ai-governance",
|
|
16
|
-
"frontend",
|
|
17
|
-
]);
|
|
5
|
+
export const INVESTOR_DD_EXPECTED_PERSONAS = Object.freeze([...FULL_DEPTH_PERSONAS]);
|
|
18
6
|
|
|
19
7
|
const REQUIRED_FOR_SELLABLE = Object.freeze([
|
|
20
8
|
"persona_roster",
|
|
@@ -335,17 +323,15 @@ export function buildInvestorDdProgress({
|
|
|
335
323
|
),
|
|
336
324
|
},
|
|
337
325
|
findingCount: Array.isArray(findings) ? findings.length : 0,
|
|
338
|
-
nextRecommendedSlices:
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
"
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
"require live reconciliation and report-email proof for sellable DD closeout",
|
|
349
|
-
],
|
|
326
|
+
nextRecommendedSlices: [
|
|
327
|
+
...(byId(capabilities, "persona_roster")?.status === "complete"
|
|
328
|
+
? []
|
|
329
|
+
: missingPersonas.includes("frontend")
|
|
330
|
+
? ["add the missing frontend/Jules persona to the default Investor-DD roster"]
|
|
331
|
+
: ["run the full 13-persona roster before claiming sellable DD coverage"]),
|
|
332
|
+
"wire Senti session id and live usage counters into Investor-DD runs",
|
|
333
|
+
"add per-agent token/time/LOC/customer-price/margin telemetry",
|
|
334
|
+
"require live reconciliation and report-email proof for sellable DD closeout",
|
|
335
|
+
],
|
|
350
336
|
};
|
|
351
337
|
}
|
package/src/review/scan-modes.js
CHANGED
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
// frontend / observability / reliability.
|
|
35
35
|
// - "compliance" — cross-cutting; distributed across security / ai-governance /
|
|
36
36
|
// supply-chain / infrastructure / documentation via subagent dispatch.
|
|
37
|
-
const FULL_DEPTH_PERSONAS = [
|
|
37
|
+
export const FULL_DEPTH_PERSONAS = Object.freeze([
|
|
38
38
|
"security",
|
|
39
39
|
"backend",
|
|
40
40
|
"code-quality",
|
|
@@ -48,7 +48,7 @@ const FULL_DEPTH_PERSONAS = [
|
|
|
48
48
|
"frontend",
|
|
49
49
|
"documentation",
|
|
50
50
|
"ai-governance",
|
|
51
|
-
];
|
|
51
|
+
]);
|
|
52
52
|
|
|
53
53
|
const SCAN_MODES = {
|
|
54
54
|
baseline: ["security"],
|