@orangepro/orangepro-mcp 0.2.8 → 0.2.10
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/dist/local/analyze/analyzer.js +105 -1
- package/dist/local/analyze/confirm.js +49 -2
- package/dist/local/analyze/parseCache.js +5 -1
- package/dist/local/analyze/symbols.js +51 -4
- package/dist/local/generate/promptV5.js +2 -0
- package/dist/local/operations.js +4 -1
- package/dist/local/score/risk.js +109 -26
- package/dist/local/viz/behaviorReportData.js +66 -22
- package/dist/local/viz/behaviorReportHtml.js +24 -8
- package/package.json +1 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import ts from "typescript";
|
|
3
4
|
import { LOCAL_GRAPH_SCHEMA_VERSION } from "../graph/ontology.js";
|
|
4
5
|
import { makeCandidateEdge, makeEdge, makeNode, makeProofEdges, makeTestCaseNode } from "../graph/factories.js";
|
|
5
6
|
import { hashString } from "../util/hash.js";
|
|
@@ -47,7 +48,7 @@ function extractionBackend(language) {
|
|
|
47
48
|
return "tsc"; // TS/JS compiler path (or unextracted)
|
|
48
49
|
return treeSitterReady(language) ? "ts" : "rx"; // tree-sitter AST vs regex fallback
|
|
49
50
|
}
|
|
50
|
-
import { runConfirmer } from "./confirm.js";
|
|
51
|
+
import { repoRootOf, resolveSpecifier, workspacePackages, runConfirmer } from "./confirm.js";
|
|
51
52
|
const DETECTOR = "repo_analyzer";
|
|
52
53
|
// Global ceiling on extracted code symbols. A SINGLE counter shared across the walk,
|
|
53
54
|
// so a low value lets whichever language is walked first (e.g. a Go `server/`) eat the
|
|
@@ -205,6 +206,69 @@ function packagePublicEntryPaths(root, relPaths) {
|
|
|
205
206
|
addCandidate(packageDir, `${conventional}${extension}`);
|
|
206
207
|
}
|
|
207
208
|
}
|
|
209
|
+
// Public package entries are often zero-logic barrels. Follow only explicit,
|
|
210
|
+
// relative AST re-exports so the callable implementation remains public
|
|
211
|
+
// without widening every internal helper into the denominator. Handles both
|
|
212
|
+
// ESM barrels and the conventional CommonJS `module.exports = require(...)`.
|
|
213
|
+
const queued = [...entries];
|
|
214
|
+
const visited = new Set();
|
|
215
|
+
const addRelativeTarget = (fromFile, rawTarget) => {
|
|
216
|
+
if (!rawTarget.startsWith("."))
|
|
217
|
+
return;
|
|
218
|
+
const base = path.posix.normalize(path.posix.join(path.posix.dirname(fromFile), rawTarget));
|
|
219
|
+
if (base === ".." || base.startsWith("../"))
|
|
220
|
+
return;
|
|
221
|
+
const candidates = new Set([base]);
|
|
222
|
+
const withoutExt = base.replace(RUNTIME_SOURCE_EXT_RE, "");
|
|
223
|
+
for (const extension of extensions) {
|
|
224
|
+
candidates.add(`${withoutExt}${extension}`);
|
|
225
|
+
candidates.add(`${withoutExt}/index${extension}`);
|
|
226
|
+
}
|
|
227
|
+
for (const candidate of candidates) {
|
|
228
|
+
if (normalizedPaths.has(candidate) && !entries.has(candidate)) {
|
|
229
|
+
entries.add(candidate);
|
|
230
|
+
queued.push(candidate);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
while (queued.length) {
|
|
235
|
+
const entry = queued.shift();
|
|
236
|
+
if (!entry || visited.has(entry))
|
|
237
|
+
continue;
|
|
238
|
+
visited.add(entry);
|
|
239
|
+
try {
|
|
240
|
+
const source = ts.createSourceFile(entry, readFileSync(path.join(root, entry), "utf8"), ts.ScriptTarget.Latest, false, ts.ScriptKind.TSX);
|
|
241
|
+
for (const statement of source.statements) {
|
|
242
|
+
if (ts.isExportDeclaration(statement) && statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier)) {
|
|
243
|
+
addRelativeTarget(entry, statement.moduleSpecifier.text);
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
if (!ts.isExpressionStatement(statement))
|
|
247
|
+
continue;
|
|
248
|
+
let assignment = ts.isBinaryExpression(statement.expression) && statement.expression.operatorToken.kind === ts.SyntaxKind.EqualsToken
|
|
249
|
+
? statement.expression
|
|
250
|
+
: undefined;
|
|
251
|
+
while (assignment) {
|
|
252
|
+
const left = assignment.left;
|
|
253
|
+
const isModuleExports = ts.isPropertyAccessExpression(left)
|
|
254
|
+
&& ts.isIdentifier(left.expression)
|
|
255
|
+
&& left.expression.text === "module"
|
|
256
|
+
&& left.name.text === "exports";
|
|
257
|
+
if (isModuleExports && ts.isCallExpression(assignment.right) && ts.isIdentifier(assignment.right.expression) && assignment.right.expression.text === "require") {
|
|
258
|
+
const [arg] = assignment.right.arguments;
|
|
259
|
+
if (arg && ts.isStringLiteral(arg))
|
|
260
|
+
addRelativeTarget(entry, arg.text);
|
|
261
|
+
}
|
|
262
|
+
assignment = ts.isBinaryExpression(assignment.right) && assignment.right.operatorToken.kind === ts.SyntaxKind.EqualsToken
|
|
263
|
+
? assignment.right
|
|
264
|
+
: undefined;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
// An unreadable barrel cannot widen the public surface; keep the manifest entry only.
|
|
270
|
+
}
|
|
271
|
+
}
|
|
208
272
|
return entries;
|
|
209
273
|
}
|
|
210
274
|
const FLOW_LINK_STOPWORDS = new Set([
|
|
@@ -1972,6 +2036,46 @@ export function analyzeRepo(root, opts = {}) {
|
|
|
1972
2036
|
seenPair.add(key);
|
|
1973
2037
|
candidates.push({ testRel, testAbs, implRel, implAbs });
|
|
1974
2038
|
}
|
|
2039
|
+
// Import-derived pairing: a test file is a candidate for every in-repo
|
|
2040
|
+
// impl file it imports — an import IS the relationship; resolution
|
|
2041
|
+
// (relative, tsconfig paths, npm/pnpm workspace names) decides
|
|
2042
|
+
// membership. Adds PAIRS only; the assertion-aware confirmer remains
|
|
2043
|
+
// the sole judge.
|
|
2044
|
+
{
|
|
2045
|
+
const pairRoot = repoRootOf(root);
|
|
2046
|
+
const wsPkgs = workspacePackages(pairRoot);
|
|
2047
|
+
const relByAbs = new Map(resolveFiles.map((f) => [f.abs, f.rel]));
|
|
2048
|
+
const IMPORT_SPEC_RE = /(?:import|export)[^'"\n]*from\s*['"]([^'"]+)['"]|require\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
2049
|
+
for (const tf of resolveFiles) {
|
|
2050
|
+
if (tf.role !== "test")
|
|
2051
|
+
continue;
|
|
2052
|
+
let srcTxt = "";
|
|
2053
|
+
try {
|
|
2054
|
+
srcTxt = readFileSync(tf.abs, "utf8");
|
|
2055
|
+
}
|
|
2056
|
+
catch {
|
|
2057
|
+
continue;
|
|
2058
|
+
}
|
|
2059
|
+
let m;
|
|
2060
|
+
IMPORT_SPEC_RE.lastIndex = 0;
|
|
2061
|
+
while ((m = IMPORT_SPEC_RE.exec(srcTxt)) !== null) {
|
|
2062
|
+
const spec = m[1] ?? m[2];
|
|
2063
|
+
if (!spec)
|
|
2064
|
+
continue;
|
|
2065
|
+
const resolvedAbs = resolveSpecifier(spec, tf.abs, pairRoot, wsPkgs);
|
|
2066
|
+
if (!resolvedAbs)
|
|
2067
|
+
continue;
|
|
2068
|
+
const implRel = relByAbs.get(resolvedAbs);
|
|
2069
|
+
if (!implRel || !eligibleSymbolsByFile.has(implRel))
|
|
2070
|
+
continue;
|
|
2071
|
+
const key = `${tf.rel}|${implRel}`;
|
|
2072
|
+
if (seenPair.has(key))
|
|
2073
|
+
continue;
|
|
2074
|
+
seenPair.add(key);
|
|
2075
|
+
candidates.push({ testRel: tf.rel, testAbs: tf.abs, implRel, implAbs: resolvedAbs });
|
|
2076
|
+
}
|
|
2077
|
+
}
|
|
2078
|
+
}
|
|
1975
2079
|
const confirmBudget = Math.max(1, Number(process.env.ORANGEPRO_MAX_CONFIRM_FILES) || 1500);
|
|
1976
2080
|
const riskSymbolLimit = Math.max(1, Number(process.env.ORANGEPRO_CONFIRM_RISK_SYMBOLS) || DEFAULT_CONFIRM_RISK_SYMBOLS);
|
|
1977
2081
|
const involved = new Set();
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
// is the SOLE producer of hard TESTED_BY/COVERS edges.
|
|
23
23
|
import ts from "typescript";
|
|
24
24
|
import path from "node:path";
|
|
25
|
+
import { readFileSync as fsRead, existsSync as fsExists } from "node:fs";
|
|
25
26
|
import { loadTsConfigFor, resolveImport } from "../resolve/resolver.js";
|
|
26
27
|
import { walkBarrel } from "../resolve/barrelWalker.js";
|
|
27
28
|
import { isSelfAssertingCallee } from "./selfAssert.js";
|
|
@@ -33,10 +34,45 @@ const norm = (p) => path.resolve(p);
|
|
|
33
34
|
* the target repo. JSX is preserved and emit/lib-checks are off — we only read
|
|
34
35
|
* symbols, never compile.
|
|
35
36
|
*/
|
|
37
|
+
export function repoRootOf(anchor) {
|
|
38
|
+
let dir = anchor;
|
|
39
|
+
for (let i = 0; i < 15; i++) {
|
|
40
|
+
try {
|
|
41
|
+
const pj = JSON.parse(fsRead(path.join(dir, "package.json"), "utf8"));
|
|
42
|
+
if (pj && pj.workspaces)
|
|
43
|
+
return dir;
|
|
44
|
+
}
|
|
45
|
+
catch { /* not here; walk up */ }
|
|
46
|
+
if (fsExists(path.join(dir, "pnpm-workspace.yaml")))
|
|
47
|
+
return dir;
|
|
48
|
+
if (fsExists(path.join(dir, ".git")) || fsExists(path.join(dir, "go.mod")))
|
|
49
|
+
return dir;
|
|
50
|
+
const parent = path.dirname(dir);
|
|
51
|
+
if (parent === dir)
|
|
52
|
+
return dir;
|
|
53
|
+
dir = parent;
|
|
54
|
+
}
|
|
55
|
+
return dir;
|
|
56
|
+
}
|
|
36
57
|
export function buildConfirmProgram(absFiles, anchorFile) {
|
|
37
58
|
const base = loadTsConfigFor(anchorFile).options;
|
|
59
|
+
// Workspace-aware resolution: synthesize compilerOptions.paths from the
|
|
60
|
+
// repo's workspaces manifest so the STANDARD compiler resolves
|
|
61
|
+
// "@scope/pkg" and "@scope/pkg/lib/x" to source. Adds no confirmation
|
|
62
|
+
// path — only lets the existing assertion-aware bar evaluate tests it
|
|
63
|
+
// previously could not resolve at all.
|
|
64
|
+
const wsRoot = repoRootOf(anchorFile);
|
|
65
|
+
const wsPaths = {};
|
|
66
|
+
for (const [name, dir] of workspacePackages(wsRoot)) {
|
|
67
|
+
wsPaths[name] = [dir + "/src/index", dir + "/index", dir + "/src"];
|
|
68
|
+
wsPaths[name + "/lib/*"] = [dir + "/src/*"];
|
|
69
|
+
wsPaths[name + "/dist/*"] = [dir + "/src/*"];
|
|
70
|
+
wsPaths[name + "/*"] = [dir + "/src/*", dir + "/*"];
|
|
71
|
+
}
|
|
38
72
|
const options = {
|
|
39
73
|
...base,
|
|
74
|
+
baseUrl: base.baseUrl ?? wsRoot,
|
|
75
|
+
paths: { ...wsPaths, ...base.paths },
|
|
40
76
|
noEmit: true,
|
|
41
77
|
allowJs: true,
|
|
42
78
|
checkJs: false,
|
|
@@ -2423,11 +2459,22 @@ function tsconfigPathsFor(fileAbs, stopDir) {
|
|
|
2423
2459
|
return null;
|
|
2424
2460
|
}
|
|
2425
2461
|
/** Workspace member name → package dir, from the root package.json workspaces globs. */
|
|
2426
|
-
function workspacePackages(repoRoot) {
|
|
2462
|
+
export function workspacePackages(repoRoot) {
|
|
2463
|
+
const pnpmGlobs = [];
|
|
2464
|
+
try {
|
|
2465
|
+
const y = fsRead(path.join(repoRoot, "pnpm-workspace.yaml"), "utf8");
|
|
2466
|
+
for (const line of y.split("\n")) {
|
|
2467
|
+
const m = /^\s*-\s*['"]?([^'"#\n]+?)['"]?\s*$/.exec(line);
|
|
2468
|
+
if (m)
|
|
2469
|
+
pnpmGlobs.push(m[1].trim());
|
|
2470
|
+
}
|
|
2471
|
+
}
|
|
2472
|
+
catch { /* not a pnpm workspace */ }
|
|
2427
2473
|
const out = new Map();
|
|
2428
2474
|
const rootPkg = readJsonSafe(_jn(repoRoot, "package.json"));
|
|
2429
2475
|
const ws = rootPkg?.workspaces;
|
|
2430
|
-
const
|
|
2476
|
+
const npmGlobs = Array.isArray(ws) ? ws : Array.isArray(ws?.packages) ? ws.packages : [];
|
|
2477
|
+
const globs = [...npmGlobs, ...pnpmGlobs];
|
|
2431
2478
|
const dirs = [];
|
|
2432
2479
|
for (const g of globs) {
|
|
2433
2480
|
if (g.endsWith("/*")) {
|
|
@@ -55,7 +55,11 @@ import { createRequire } from "node:module";
|
|
|
55
55
|
// Swift protocol methods, and Rust trait signatures while dropping Rust aliases.
|
|
56
56
|
// v16: symbol extraction now carries source line spans for runtime coverage
|
|
57
57
|
// report ingestion; warm v15 entries lack the ranges and cannot be mapped.
|
|
58
|
-
|
|
58
|
+
// v17: Go method symbols are receiver-qualified (Recv.M + member_of).
|
|
59
|
+
// v18: TS/JS extracts direct callable CommonJS exports.
|
|
60
|
+
// v19: chained assignments (`exports = module.exports = fn`) also expose the
|
|
61
|
+
// callable subject; warm v18 entries can still omit conventional CJS entries.
|
|
62
|
+
export const PARSER_VERSION = 19;
|
|
59
63
|
/** Tool package version, folded into the cache guard so UPGRADES auto-invalidate
|
|
60
64
|
* the cache — bumping PARSER_VERSION by hand is a discipline; this is a lock.
|
|
61
65
|
* (The stale-cache incident: upgraded binary served old per-file results.) */
|
|
@@ -283,10 +283,11 @@ function collectTsJsExports(content) {
|
|
|
283
283
|
const localDecls = new Map();
|
|
284
284
|
const classNodes = new Map(); // for default-subject member extraction
|
|
285
285
|
const defaultExprs = [];
|
|
286
|
+
const commonJsAssignments = [];
|
|
286
287
|
for (const stmt of sf.statements) {
|
|
287
288
|
if (ts.isFunctionDeclaration(stmt) && stmt.name) {
|
|
288
289
|
const lines = nodeLines(sf, stmt);
|
|
289
|
-
localDecls.set(stmt.name.text, { kind: "function", eligible: true, lines });
|
|
290
|
+
localDecls.set(stmt.name.text, { kind: "function", eligible: true, callable: true, lines });
|
|
290
291
|
if (hasExportModifier(stmt))
|
|
291
292
|
record(stmt.name.text, "function", false, undefined, lines);
|
|
292
293
|
}
|
|
@@ -297,7 +298,7 @@ function collectTsJsExports(content) {
|
|
|
297
298
|
if (ts.canHaveModifiers(stmt) && ts.getModifiers(stmt)?.some((mod) => mod.kind === ts.SyntaxKind.DeclareKeyword))
|
|
298
299
|
continue;
|
|
299
300
|
const lines = nodeLines(sf, stmt);
|
|
300
|
-
localDecls.set(stmt.name.text, { kind: "class", eligible: true, lines });
|
|
301
|
+
localDecls.set(stmt.name.text, { kind: "class", eligible: true, callable: true, lines });
|
|
301
302
|
classNodes.set(stmt.name.text, stmt);
|
|
302
303
|
if (hasExportModifier(stmt)) {
|
|
303
304
|
record(stmt.name.text, "class", false, undefined, lines);
|
|
@@ -312,7 +313,7 @@ function collectTsJsExports(content) {
|
|
|
312
313
|
const init = decl.initializer ? unwrapInitializer(decl.initializer) : undefined;
|
|
313
314
|
const callable = !!init && (ts.isArrowFunction(init) || ts.isFunctionExpression(init));
|
|
314
315
|
const lines = nodeLines(sf, decl);
|
|
315
|
-
localDecls.set(decl.name.text, { kind: "const", eligible: isComponentLikeConst(decl.name.text, init), lines });
|
|
316
|
+
localDecls.set(decl.name.text, { kind: "const", eligible: isComponentLikeConst(decl.name.text, init), callable, lines });
|
|
316
317
|
if (exported)
|
|
317
318
|
record(decl.name.text, "const", callable, undefined, lines); // regular exports keep the strict callable rule
|
|
318
319
|
}
|
|
@@ -322,6 +323,27 @@ function collectTsJsExports(content) {
|
|
|
322
323
|
// (`export { default } from './x'`) are ExportDeclarations, never collected.
|
|
323
324
|
defaultExprs.push(stmt.expression);
|
|
324
325
|
}
|
|
326
|
+
else if (ts.isExpressionStatement(stmt) && ts.isBinaryExpression(stmt.expression) && stmt.expression.operatorToken.kind === ts.SyntaxKind.EqualsToken) {
|
|
327
|
+
let assignment = stmt.expression;
|
|
328
|
+
while (assignment) {
|
|
329
|
+
const left = assignment.left;
|
|
330
|
+
if (ts.isPropertyAccessExpression(left)) {
|
|
331
|
+
const directDefault = ts.isIdentifier(left.expression) && left.expression.text === "module" && left.name.text === "exports";
|
|
332
|
+
const namedOnExports = ts.isIdentifier(left.expression) && left.expression.text === "exports";
|
|
333
|
+
const namedOnModule = ts.isPropertyAccessExpression(left.expression)
|
|
334
|
+
&& ts.isIdentifier(left.expression.expression)
|
|
335
|
+
&& left.expression.expression.text === "module"
|
|
336
|
+
&& left.expression.name.text === "exports";
|
|
337
|
+
if (directDefault)
|
|
338
|
+
commonJsAssignments.push({ exportName: null, expression: assignment.right });
|
|
339
|
+
else if (namedOnExports || namedOnModule)
|
|
340
|
+
commonJsAssignments.push({ exportName: left.name.text, expression: assignment.right });
|
|
341
|
+
}
|
|
342
|
+
assignment = ts.isBinaryExpression(assignment.right) && assignment.right.operatorToken.kind === ts.SyntaxKind.EqualsToken
|
|
343
|
+
? assignment.right
|
|
344
|
+
: undefined;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
325
347
|
}
|
|
326
348
|
// A default export is the file's primary behavior. Record its LOCAL subject
|
|
327
349
|
// only when it is a function/class or a component-like const — skips require()
|
|
@@ -341,6 +363,31 @@ function collectTsJsExports(content) {
|
|
|
341
363
|
recordClassMethods(sf, node, name); // default-exported class → its methods
|
|
342
364
|
}
|
|
343
365
|
}
|
|
366
|
+
// CommonJS public surface, judged by the same AST-backed callable bar as ESM:
|
|
367
|
+
// module.exports = localFn, exports.name = localFn, or an inline function.
|
|
368
|
+
// Re-export shims and config/object assignments remain excluded.
|
|
369
|
+
for (const assignment of commonJsAssignments) {
|
|
370
|
+
const expr = unwrapInitializer(assignment.expression);
|
|
371
|
+
if (ts.isIdentifier(expr)) {
|
|
372
|
+
const d = localDecls.get(expr.text);
|
|
373
|
+
if (!d || !d.callable)
|
|
374
|
+
continue;
|
|
375
|
+
record(expr.text, d.kind, d.kind === "const", undefined, d.lines);
|
|
376
|
+
if (d.kind === "class") {
|
|
377
|
+
const node = classNodes.get(expr.text);
|
|
378
|
+
if (node)
|
|
379
|
+
recordClassMethods(sf, node, expr.text);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
else if (ts.isFunctionExpression(expr) || ts.isArrowFunction(expr)) {
|
|
383
|
+
const name = assignment.exportName ?? (ts.isFunctionExpression(expr) && expr.name ? expr.name.text : undefined);
|
|
384
|
+
if (name)
|
|
385
|
+
record(name, "function", false, undefined, nodeLines(sf, expr));
|
|
386
|
+
}
|
|
387
|
+
else if (ts.isClassExpression(expr) && expr.name) {
|
|
388
|
+
record(expr.name.text, "class", false, undefined, nodeLines(sf, expr));
|
|
389
|
+
}
|
|
390
|
+
}
|
|
344
391
|
}
|
|
345
392
|
return acc;
|
|
346
393
|
}
|
|
@@ -381,7 +428,7 @@ export function extractSymbolsWithMeta(content, language) {
|
|
|
381
428
|
// TS/JS from the AST — comment/string-safe. Cheap gate: any "export"
|
|
382
429
|
// followed by whitespace (matches the old `export\s+`, so `export\tfunction`
|
|
383
430
|
// / `export\nclass` are NOT skipped).
|
|
384
|
-
if (/export\s
|
|
431
|
+
if (/export\s|module\s*\.\s*exports|exports\s*\./.test(content)) {
|
|
385
432
|
for (const [name, sym] of collectTsJsExports(content))
|
|
386
433
|
consider(name, sym.kind, sym.callable, sym.member_of, sym);
|
|
387
434
|
}
|
|
@@ -159,6 +159,8 @@ export function buildBatchGenerationSystemPromptV5() {
|
|
|
159
159
|
"- Never mock, stub, or spy on the behavior-under-test itself. The subject must execute for real. Mock only true external I/O boundaries — network calls, the system clock, third-party SDKs, outbound HTTP. If the behavior calls internal services in the same codebase, let them run (or use real test doubles at the I/O edge, never at the subject). A test that mocks the subject proves nothing and will be rejected.",
|
|
160
160
|
"- Each test is complete and runnable (all imports, setup, assertions, cleanup).",
|
|
161
161
|
"- Start each test with: // Concern: <concern> | Technique: <technique>",
|
|
162
|
+
"- When asserting an exact return value (string, number, constant), copy the expected value VERBATIM from the provided source code. Never invent an expected value.",
|
|
163
|
+
"- If the exact value is not visible in the provided source, assert structure instead (non-nil, error vs no-error, type, boolean outcome) — never a guessed literal.",
|
|
162
164
|
"- Assert all targets listed in each scenario.",
|
|
163
165
|
"- Do not copy source excerpts verbatim. Use them to understand, then write original code.",
|
|
164
166
|
"- Reuse SUBJECT IMPORTS. Do not invent module paths.",
|
package/dist/local/operations.js
CHANGED
|
@@ -2003,7 +2003,10 @@ export function opBehaviorCoverageHtml(root, outputPath = "orangepro-behavior-co
|
|
|
2003
2003
|
// proof-attempts sidecar ONLY when it anchors to the current graph+commit
|
|
2004
2004
|
// (stale evidence is dropped — fail closed; display copy only, no tier math).
|
|
2005
2005
|
const dyn = dynamicProof ?? sidecarDynamicProof(root, graph);
|
|
2006
|
-
|
|
2006
|
+
// Artifacts may be written from a different working directory when callers
|
|
2007
|
+
// use `opro start <source>`. Risk scoring must follow the analyzed source
|
|
2008
|
+
// root recorded in the graph, never the artifact/output root.
|
|
2009
|
+
const data = buildBehaviorReportData(graph, loadLedger(root), { repoRoot: graph.workspace.root, dynamicProof: dyn });
|
|
2007
2010
|
// Delta-since-last-run: best-effort read of the previous snapshot; a missing
|
|
2008
2011
|
// or unreadable baseline means first run (banner hidden). Display-only —
|
|
2009
2012
|
// the delta never touches tiers, ranks, or counts.
|
package/dist/local/score/risk.js
CHANGED
|
@@ -27,10 +27,49 @@ function confirmedBehaviorIds(graph) {
|
|
|
27
27
|
return ids;
|
|
28
28
|
}
|
|
29
29
|
const GIT_CHURN_BATCH = 200;
|
|
30
|
+
export function inspectRiskInputHealth(root, churnWindow) {
|
|
31
|
+
const unavailableWindow = churnWindow ?? "180 days before HEAD";
|
|
32
|
+
if (!root)
|
|
33
|
+
return { sourceRoot: null, gitRoot: null, commit: null, commitDate: null, history: "unavailable", churnWindow: unavailableWindow, churnAvailable: false, reason: "source root unavailable" };
|
|
34
|
+
try {
|
|
35
|
+
const gitRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 4000 }).trim();
|
|
36
|
+
const commit = execFileSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 4000 }).trim();
|
|
37
|
+
const commitDate = execFileSync("git", ["show", "-s", "--format=%cI", "HEAD"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 4000 }).trim();
|
|
38
|
+
const shallow = execFileSync("git", ["rev-parse", "--is-shallow-repository"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 4000 }).trim() === "true";
|
|
39
|
+
let partial = false;
|
|
40
|
+
try {
|
|
41
|
+
partial = execFileSync("git", ["config", "--get-regexp", "^remote\\..*\\.promisor$"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 4000 })
|
|
42
|
+
.split(/\r?\n/)
|
|
43
|
+
.some((line) => /\btrue$/i.test(line.trim()));
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
// A normal full clone has no promisor-remote configuration.
|
|
47
|
+
}
|
|
48
|
+
const commitMs = Date.parse(commitDate);
|
|
49
|
+
const resolvedWindow = churnWindow ?? (Number.isFinite(commitMs) ? new Date(commitMs - 180 * 24 * 60 * 60 * 1000).toISOString() : unavailableWindow);
|
|
50
|
+
return {
|
|
51
|
+
sourceRoot: root,
|
|
52
|
+
gitRoot,
|
|
53
|
+
commit,
|
|
54
|
+
commitDate,
|
|
55
|
+
history: shallow ? "shallow" : partial ? "partial" : "full",
|
|
56
|
+
churnWindow: resolvedWindow,
|
|
57
|
+
churnAvailable: !shallow && !partial,
|
|
58
|
+
reason: shallow
|
|
59
|
+
? "shallow Git history cannot support a complete churn window"
|
|
60
|
+
: partial
|
|
61
|
+
? "partial-clone Git objects cannot guarantee a complete offline churn window"
|
|
62
|
+
: undefined
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return { sourceRoot: root, gitRoot: null, commit: null, commitDate: null, history: "unavailable", churnWindow: unavailableWindow, churnAvailable: false, reason: "Git history could not be read from the analyzed source root" };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
30
69
|
function gitChurn(root, files, window) {
|
|
31
70
|
const out = new Map();
|
|
32
71
|
if (!root || files.length === 0)
|
|
33
|
-
return out;
|
|
72
|
+
return { values: out, complete: Boolean(root) };
|
|
34
73
|
for (let i = 0; i < files.length; i += GIT_CHURN_BATCH) {
|
|
35
74
|
const batch = files.slice(i, i + GIT_CHURN_BATCH);
|
|
36
75
|
try {
|
|
@@ -51,10 +90,10 @@ function gitChurn(root, files, window) {
|
|
|
51
90
|
}
|
|
52
91
|
}
|
|
53
92
|
catch {
|
|
54
|
-
|
|
93
|
+
return { values: new Map(), complete: false };
|
|
55
94
|
}
|
|
56
95
|
}
|
|
57
|
-
return out;
|
|
96
|
+
return { values: out, complete: true };
|
|
58
97
|
}
|
|
59
98
|
function gitFirstCommitBatch(root, files) {
|
|
60
99
|
const out = new Map();
|
|
@@ -137,18 +176,25 @@ function deriveRouteWeight(node) {
|
|
|
137
176
|
return 2;
|
|
138
177
|
}
|
|
139
178
|
function deriveDataSensitivity(node) {
|
|
140
|
-
const
|
|
141
|
-
const
|
|
142
|
-
[/
|
|
143
|
-
|
|
144
|
-
[
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
179
|
+
const raw = `${node.external_id} ${symbolFile(node)} ${symbolTitle(node)}`;
|
|
180
|
+
const tokens = new Set(raw
|
|
181
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
182
|
+
.toLowerCase()
|
|
183
|
+
.split(/[^a-z0-9]+/)
|
|
184
|
+
.filter(Boolean));
|
|
185
|
+
const has = (...values) => values.some((value) => tokens.has(value));
|
|
186
|
+
// `capture` alone is not a payment signal (for example CapturePanic). It is
|
|
187
|
+
// payment-sensitive only when the same symbol/path also contains payment context.
|
|
188
|
+
if (has("payment", "stripe", "refund", "charge", "billing", "payout", "chargeback") || (has("capture") && has("payment", "stripe", "transaction")))
|
|
189
|
+
return 10;
|
|
190
|
+
if (has("auth", "token", "session", "password", "credential", "jwt", "oauth"))
|
|
191
|
+
return 9;
|
|
192
|
+
if (has("order", "cart", "checkout", "invoice", "transaction"))
|
|
193
|
+
return 7;
|
|
194
|
+
if (has("customer", "user", "account", "profile", "pii", "gdpr"))
|
|
195
|
+
return 6;
|
|
196
|
+
if (has("notification", "email", "sms", "webhook", "push"))
|
|
197
|
+
return 3;
|
|
152
198
|
return 1;
|
|
153
199
|
}
|
|
154
200
|
export function buildFlowDepthContext(graph) {
|
|
@@ -281,9 +327,16 @@ export function rankRiskGaps(graph, opts = {}) {
|
|
|
281
327
|
symbolsByFile.set(file, [s]);
|
|
282
328
|
}
|
|
283
329
|
const files = [...new Set(symbols.map(symbolFile))];
|
|
284
|
-
const
|
|
285
|
-
const
|
|
286
|
-
const
|
|
330
|
+
const repoRoot = opts.repoRoot ?? graph.workspace.root;
|
|
331
|
+
const inputHealth = inspectRiskInputHealth(repoRoot, opts.churnWindow);
|
|
332
|
+
const churnWindow = inputHealth.churnWindow;
|
|
333
|
+
const churnResult = inputHealth.churnAvailable ? gitChurn(repoRoot, files, churnWindow) : { values: new Map(), complete: false };
|
|
334
|
+
const churn = churnResult.values;
|
|
335
|
+
const churnAvailable = inputHealth.churnAvailable && churnResult.complete;
|
|
336
|
+
const firstCommitTs = churnAvailable ? gitFirstCommitBatch(repoRoot, files) : new Map();
|
|
337
|
+
const commitMs = Date.parse(inputHealth.commitDate ?? "");
|
|
338
|
+
const graphMs = Date.parse(graph.updated_at || graph.created_at || "");
|
|
339
|
+
const nowSec = Math.floor((Number.isFinite(commitMs) ? commitMs : Number.isFinite(graphMs) ? graphMs : 0) / 1000);
|
|
287
340
|
// Method-level attribution. CALLS edges are already symbol-granular and count
|
|
288
341
|
// at full weight. IMPORTS edges are file-granular: previously every symbol in
|
|
289
342
|
// an imported file inherited the file's full import count, which made all 17
|
|
@@ -347,11 +400,13 @@ export function rankRiskGaps(graph, opts = {}) {
|
|
|
347
400
|
const score = Math.round((incoming_refs * 0.4 + churnForScore * 0.4 + (isEntry ? 20 : 0)) * 10) / 10;
|
|
348
401
|
const reasons = [
|
|
349
402
|
`${incoming_refs} incoming structural reference${incoming_refs === 1 ? "" : "s"}`,
|
|
350
|
-
|
|
403
|
+
churnAvailable
|
|
404
|
+
? `${git_churn} git churn line${git_churn === 1 ? "" : "s"} in 180 days${git_churn > 500 ? " (score capped at 500)" : ""}`
|
|
405
|
+
: "Git churn unavailable — provisional static-only ranking"
|
|
351
406
|
];
|
|
352
407
|
if (isEntry)
|
|
353
408
|
reasons.push("near an API/route/handler entry point");
|
|
354
|
-
return { id: s.external_id, title: s.title || s.external_id, file, risk_score: score, incoming_refs, git_churn, entry_point: isEntry, reasons };
|
|
409
|
+
return { id: s.external_id, title: s.title || s.external_id, file, risk_score: score, incoming_refs, git_churn, churn_available: churnAvailable, entry_point: isEntry, reasons };
|
|
355
410
|
})
|
|
356
411
|
.sort((a, b) => b.risk_score - a.risk_score || b.incoming_refs - a.incoming_refs || b.git_churn - a.git_churn || a.id.localeCompare(b.id))
|
|
357
412
|
.slice(0, limit);
|
|
@@ -389,17 +444,24 @@ export function rankRiskGaps(graph, opts = {}) {
|
|
|
389
444
|
const i = Math.round(iExact);
|
|
390
445
|
const d = rawScores[idx].d;
|
|
391
446
|
const detectionTier = detectionFor(s.external_id);
|
|
392
|
-
|
|
447
|
+
let score = Math.round(pExact * iExact * d * 10) / 10;
|
|
448
|
+
const disconnected = incoming_refs === 0 && fan_out === 0;
|
|
449
|
+
if (disconnected)
|
|
450
|
+
score = Math.round(score * 0.25 * 10) / 10;
|
|
393
451
|
const reasons = [
|
|
394
452
|
`ORS ${score} ≈ P${p} × I${i} × D${d}`,
|
|
395
453
|
`${incoming_refs} incoming structural reference${incoming_refs === 1 ? "" : "s"} (method-attributed)`,
|
|
396
|
-
|
|
397
|
-
|
|
454
|
+
churnAvailable
|
|
455
|
+
? `${git_churn} git churn line${git_churn === 1 ? "" : "s"} attributed to this symbol in 180 days`
|
|
456
|
+
: "Git churn unavailable — provisional static-only ranking",
|
|
457
|
+
`route weight ${route_weight}, data sensitivity ${data_sensitivity}, flow position ${flow_position}, complexity ${complexity_proxy}, fan-out ${fan_out}`
|
|
398
458
|
];
|
|
399
459
|
if (isEntry)
|
|
400
460
|
reasons.push("near an API/route/handler entry point");
|
|
401
461
|
if (is_new_code)
|
|
402
462
|
reasons.push("new code (< 30 days)");
|
|
463
|
+
if (disconnected)
|
|
464
|
+
reasons.push("no callers and no callees — structurally disconnected, score dampened");
|
|
403
465
|
if (detectionTier === "candidate")
|
|
404
466
|
reasons.push("lexical candidate test match only — unconfirmed");
|
|
405
467
|
return {
|
|
@@ -409,6 +471,7 @@ export function rankRiskGaps(graph, opts = {}) {
|
|
|
409
471
|
risk_score: score,
|
|
410
472
|
incoming_refs,
|
|
411
473
|
git_churn,
|
|
474
|
+
churn_available: churnAvailable,
|
|
412
475
|
entry_point: isEntry,
|
|
413
476
|
reasons,
|
|
414
477
|
probability: p,
|
|
@@ -431,11 +494,18 @@ export function rankRiskGaps(graph, opts = {}) {
|
|
|
431
494
|
return ranked.slice(0, limit);
|
|
432
495
|
const maxPerFile = Math.max(1, opts.maxPerFile);
|
|
433
496
|
const perFile = new Map();
|
|
497
|
+
// Multi-program repos flood identical titles (76 x main) across files; the
|
|
498
|
+
// per-FILE cap cannot see it. Same diversity principle, second axis.
|
|
499
|
+
const maxPerTitle = Math.max(1, opts.maxPerTitle ?? 2);
|
|
500
|
+
const perTitle = new Map();
|
|
434
501
|
const surfaced = [];
|
|
435
502
|
const overflow = [];
|
|
436
503
|
for (const gap of ranked) {
|
|
437
504
|
const used = perFile.get(gap.file) ?? 0;
|
|
438
|
-
|
|
505
|
+
const tKey = (gap.title || "").split("(")[0].trim();
|
|
506
|
+
const tUsed = perTitle.get(tKey) ?? 0;
|
|
507
|
+
if (used < maxPerFile && tUsed < maxPerTitle) {
|
|
508
|
+
perTitle.set(tKey, tUsed + 1);
|
|
439
509
|
perFile.set(gap.file, used + 1);
|
|
440
510
|
surfaced.push(gap);
|
|
441
511
|
}
|
|
@@ -445,8 +515,21 @@ export function rankRiskGaps(graph, opts = {}) {
|
|
|
445
515
|
if (surfaced.length >= limit)
|
|
446
516
|
break;
|
|
447
517
|
}
|
|
448
|
-
|
|
449
|
-
|
|
518
|
+
// A report-level title cap is a hard product constraint: relaxing it during
|
|
519
|
+
// backfill recreates duplicate Invoke/Config cards. We may relax only the
|
|
520
|
+
// per-file cap to fill remaining slots with distinct behavior titles.
|
|
521
|
+
if (surfaced.length < limit) {
|
|
522
|
+
for (const gap of overflow) {
|
|
523
|
+
const tKey = (gap.title || "").split("(")[0].trim();
|
|
524
|
+
const tUsed = perTitle.get(tKey) ?? 0;
|
|
525
|
+
if (tUsed >= maxPerTitle)
|
|
526
|
+
continue;
|
|
527
|
+
perTitle.set(tKey, tUsed + 1);
|
|
528
|
+
surfaced.push(gap);
|
|
529
|
+
if (surfaced.length >= limit)
|
|
530
|
+
break;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
450
533
|
// Guarantee: the surfaced list is ALWAYS highest-risk-first, even when the
|
|
451
534
|
// per-file diversity backfill re-admits overflow items (which otherwise land
|
|
452
535
|
// appended after lower-scored rows).
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import { buildRtm } from "../rtm.js";
|
|
3
|
-
import { isEntryPoint, rankRiskGaps } from "../score/risk.js";
|
|
4
|
+
import { inspectRiskInputHealth, isEntryPoint, rankRiskGaps } from "../score/risk.js";
|
|
5
|
+
import { ORANGEPRO_VERSION } from "../version.js";
|
|
4
6
|
import { PROOF_BLOCKER_GUIDE } from "../proofDoctor.js";
|
|
5
7
|
/** Short human phrase per R-1 needs_setup category, for the "blocked because: …" panel copy. */
|
|
6
8
|
const BLOCK_CATEGORY_LABEL = {
|
|
@@ -105,13 +107,27 @@ function isNoneTier(tier) {
|
|
|
105
107
|
return tier !== "proven" && tier !== "associated" && tier !== "runtime" && tier !== "candidate";
|
|
106
108
|
}
|
|
107
109
|
function summaryFromRows(rows, flowIds) {
|
|
110
|
+
// buildRtm intentionally unions valid proof rows that fall outside the static
|
|
111
|
+
// denominator. They remain visible and count as Dynamically Proven, but must
|
|
112
|
+
// never increase the "Methods found" denominator.
|
|
113
|
+
const denominatorRows = rows.filter((r) => r.off_denominator !== true);
|
|
108
114
|
const proven = rows.filter((r) => r.evidence_tier === "proven").length;
|
|
109
|
-
const
|
|
110
|
-
const
|
|
111
|
-
const
|
|
115
|
+
const provenOutsideDenominator = rows.filter((r) => r.off_denominator === true && r.evidence_tier === "proven").length;
|
|
116
|
+
const associated = denominatorRows.filter((r) => r.evidence_tier === "associated" || r.evidence_tier === "runtime").length;
|
|
117
|
+
const candidate = denominatorRows.filter((r) => r.evidence_tier === "candidate").length;
|
|
118
|
+
const noneRows = denominatorRows.filter((r) => isNoneTier(r.evidence_tier));
|
|
112
119
|
// DISPLAY-ONLY split of `none`: a none-tier symbol that shows up in a static flow is "Reachable Untested".
|
|
113
120
|
const reachableUntested = noneRows.filter((r) => flowIds.has(r.behavior_id)).length;
|
|
114
|
-
return {
|
|
121
|
+
return {
|
|
122
|
+
total: denominatorRows.length,
|
|
123
|
+
proven,
|
|
124
|
+
...(provenOutsideDenominator > 0 ? { provenOutsideDenominator } : {}),
|
|
125
|
+
associated,
|
|
126
|
+
candidate,
|
|
127
|
+
none: noneRows.length,
|
|
128
|
+
reachableUntested,
|
|
129
|
+
noSignal: noneRows.length - reachableUntested
|
|
130
|
+
};
|
|
115
131
|
}
|
|
116
132
|
/** Verbatim 0-dynamic-proof explainer copy. Rendered only when summary.proven === 0. */
|
|
117
133
|
const ZERO_PROOF_EXPLAINER = {
|
|
@@ -402,12 +418,16 @@ function riskGeneratedTests(graph, gap, riskIds, isFirstRowForFile) {
|
|
|
402
418
|
runnable: t.runnable !== false
|
|
403
419
|
}));
|
|
404
420
|
}
|
|
405
|
-
/** Incoming refs are method-attributed and can be fractional
|
|
406
|
-
*
|
|
421
|
+
/** Incoming refs are method-attributed and can be fractional when a file-level
|
|
422
|
+
* reference is split across its symbols. Preserve that weighting honestly. */
|
|
407
423
|
function fmtRefs(n) {
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
424
|
+
return Number.isInteger(n) ? String(n) : n.toFixed(1);
|
|
425
|
+
}
|
|
426
|
+
function displayTitle(title, file) {
|
|
427
|
+
if (title.includes("."))
|
|
428
|
+
return title;
|
|
429
|
+
const pkg = file && file.includes("/") ? file.split("/").slice(-2, -1)[0] : "";
|
|
430
|
+
return pkg ? `${pkg}.${title}` : title;
|
|
411
431
|
}
|
|
412
432
|
/** Deterministic 1–2 line behavior context from graph facts only — no LLM.
|
|
413
433
|
* Sensitivity label mirrors deriveDataSensitivity's tiers. */
|
|
@@ -424,9 +444,12 @@ function riskContext(risk) {
|
|
|
424
444
|
? `${5 - (risk.flow_position ?? 0)} call${5 - (risk.flow_position ?? 0) === 1 ? "" : "s"} from the nearest entry point`
|
|
425
445
|
: "deep in the call graph";
|
|
426
446
|
const refs = fmtRefs(risk.incoming_refs);
|
|
447
|
+
const churn = risk.churn_available !== false
|
|
448
|
+
? `${risk.git_churn} line${risk.git_churn === 1 ? "" : "s"} changed in 180 days`
|
|
449
|
+
: "Git churn unavailable (provisional static-only ranking)";
|
|
427
450
|
const parts = [
|
|
428
451
|
`Sits at ${pos}${sens ? ` on ${sens} paths` : ""}.`,
|
|
429
|
-
`${refs}
|
|
452
|
+
`${refs} weighted incoming reference${risk.incoming_refs === 1 ? "" : "s"}, ${risk.fan_out ?? 0} downstream call${(risk.fan_out ?? 0) === 1 ? "" : "s"}, ${churn} — and no test proves its behavior.`
|
|
430
453
|
];
|
|
431
454
|
return parts.join(" ");
|
|
432
455
|
}
|
|
@@ -740,13 +763,15 @@ function riskTodo(risk, verb, path, generatedTests) {
|
|
|
740
763
|
const call = verb !== "BEHAVIOR"
|
|
741
764
|
? `issues ${verb} ${path}`
|
|
742
765
|
: risk.entry_point
|
|
743
|
-
? `invokes ${risk.title} through its entry point`
|
|
744
|
-
: `calls ${risk.title} directly`;
|
|
745
|
-
const sens = (risk.data_sensitivity ?? 1) >=
|
|
746
|
-
? " Include a
|
|
747
|
-
: (risk.data_sensitivity ?? 1) >=
|
|
748
|
-
? " Include a
|
|
749
|
-
:
|
|
766
|
+
? `invokes ${displayTitle(risk.title, risk.file)} through its entry point`
|
|
767
|
+
: `calls ${displayTitle(risk.title, risk.file)} directly`;
|
|
768
|
+
const sens = (risk.data_sensitivity ?? 1) >= 10
|
|
769
|
+
? " Include a failure case: a rejected payment must leave no partial state."
|
|
770
|
+
: (risk.data_sensitivity ?? 1) >= 9
|
|
771
|
+
? " Include a negative case: invalid or expired credentials must fail closed."
|
|
772
|
+
: (risk.data_sensitivity ?? 1) >= 7
|
|
773
|
+
? " Include a failure case: a rejected transaction must leave no partial state."
|
|
774
|
+
: "";
|
|
750
775
|
if (risk.integration_signal === "candidate") {
|
|
751
776
|
return `A similarly named test exists but nothing links it. Write a test that imports and ${call}, asserting the observable outcome — that upgrades this from unconfirmed candidate to a hard link.${sens}`;
|
|
752
777
|
}
|
|
@@ -780,9 +805,11 @@ function riskRows(risks, graph) {
|
|
|
780
805
|
const methodMatch = risk.title.match(/^(GET|POST|PUT|PATCH|DELETE)\s+(.+)$/i);
|
|
781
806
|
const tags = [];
|
|
782
807
|
const bucket = riskBucket(risk.risk_score, maxRiskScore);
|
|
783
|
-
if (
|
|
808
|
+
if (risk.churn_available === false)
|
|
809
|
+
tags.push(["provisional rank", "info"]);
|
|
810
|
+
else if (bucket)
|
|
784
811
|
tags.push([`${bucket} risk`, "risk"]);
|
|
785
|
-
tags.push([`${fmtRefs(risk.incoming_refs)}
|
|
812
|
+
tags.push([`${fmtRefs(risk.incoming_refs)} weighted refs`, "info"]);
|
|
786
813
|
if (risk.entry_point)
|
|
787
814
|
tags.push(["Entry point", "entry"]);
|
|
788
815
|
return {
|
|
@@ -790,7 +817,7 @@ function riskRows(risks, graph) {
|
|
|
790
817
|
...(() => {
|
|
791
818
|
const generatedTests = riskGeneratedTests(graph, risk, riskIds, firstRowForFile.get(risk.file) === risk.id);
|
|
792
819
|
const verb = methodMatch?.[1]?.toUpperCase() ?? "BEHAVIOR";
|
|
793
|
-
const path = qualify(risk, methodMatch?.[2] ?? risk.title);
|
|
820
|
+
const path = qualify(risk, methodMatch?.[2] ?? displayTitle(risk.title, risk.file));
|
|
794
821
|
const generatedCategories = [...new Set([
|
|
795
822
|
...generatedTests.map((t) => (t.bucket ? BUCKET_TO_CONCERN[t.bucket] : undefined)),
|
|
796
823
|
// An integration/api/e2e-layer draft targets integration_flow. This
|
|
@@ -824,7 +851,23 @@ export function buildBehaviorReportData(graph, ledger, opts = {}) {
|
|
|
824
851
|
const flowIds = flowSymbolIds(graph);
|
|
825
852
|
const summary = summaryFromRows(rows, flowIds);
|
|
826
853
|
const repoRoot = opts.repoRoot ?? graph.workspace.root;
|
|
827
|
-
const riskGaps = rankRiskGaps(graph, { repoRoot, limit: opts.riskLimit ?? 20 });
|
|
854
|
+
const riskGaps = rankRiskGaps(graph, { repoRoot, limit: opts.riskLimit ?? 20, maxPerFile: 3, maxPerTitle: 1 });
|
|
855
|
+
const riskHealth = inspectRiskInputHealth(repoRoot);
|
|
856
|
+
const churnAvailable = riskHealth.churnAvailable && riskGaps.every((risk) => risk.churn_available !== false);
|
|
857
|
+
const provenance = {
|
|
858
|
+
source: path.basename(repoRoot || graph.workspace.name || "repo"),
|
|
859
|
+
gitRoot: riskHealth.gitRoot ? path.basename(riskHealth.gitRoot) : null,
|
|
860
|
+
commit: riskHealth.commit,
|
|
861
|
+
history: riskHealth.history,
|
|
862
|
+
churn: churnAvailable ? "available" : "unavailable",
|
|
863
|
+
churnWindow: riskHealth.churnWindow,
|
|
864
|
+
toolVersion: ORANGEPRO_VERSION,
|
|
865
|
+
inputFingerprint: createHash("sha256")
|
|
866
|
+
.update(JSON.stringify({ root: graph.workspace.root_hash, commit: riskHealth.commit, history: riskHealth.history, churn: churnAvailable, window: riskHealth.churnWindow, version: ORANGEPRO_VERSION }))
|
|
867
|
+
.digest("hex")
|
|
868
|
+
.slice(0, 16),
|
|
869
|
+
reason: churnAvailable ? undefined : (riskHealth.reason ?? "Git churn scan did not complete")
|
|
870
|
+
};
|
|
828
871
|
const lists = behaviorLists(rows, flowIds);
|
|
829
872
|
const risks = riskRows(riskGaps, graph);
|
|
830
873
|
const sortedBehaviors = [...lists.behaviors].sort((a, b) => tierRank(a) - tierRank(b));
|
|
@@ -834,6 +877,7 @@ export function buildBehaviorReportData(graph, ledger, opts = {}) {
|
|
|
834
877
|
scanned: (graph.updated_at || graph.created_at || new Date(0).toISOString()).slice(0, 10),
|
|
835
878
|
framework: frameworkLabel(graph),
|
|
836
879
|
analysisKind: summary.proven > 0 ? "static+dynamic" : "static",
|
|
880
|
+
provenance,
|
|
837
881
|
summary,
|
|
838
882
|
proofGuidance: proofGuidance(ledger, summary, opts.dynamicProof),
|
|
839
883
|
pipeline: pipeline(graph, ledger, summary),
|
|
@@ -33,9 +33,11 @@ a{color:var(--blue);text-decoration:none}
|
|
|
33
33
|
.hdr-name{font-size:15px;font-weight:650;letter-spacing:-.01em}
|
|
34
34
|
.hdr-right{font-size:11.5px;color:var(--muted);display:flex;gap:10px;align-items:center}
|
|
35
35
|
.hdr-tag{background:var(--s2);border:1px solid var(--bd);border-radius:4px;padding:2px 7px;font-family:var(--mono);font-size:10.5px;color:var(--ink2)}
|
|
36
|
+
.provenance{margin:10px 0 0;padding:8px 10px;border:1px solid var(--bd);border-radius:7px;background:var(--s1);color:var(--muted);font:10.5px/1.45 var(--mono)}
|
|
37
|
+
.provenance.warn{border-color:var(--rbd);background:var(--rbg);color:var(--red)}
|
|
36
38
|
|
|
37
39
|
/* KPI STRIP */
|
|
38
|
-
.kpis{display:grid;grid-template-columns:repeat(
|
|
40
|
+
.kpis{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin:16px 0 0}
|
|
39
41
|
.kpi{background:var(--s1);border:1px solid var(--bd);border-radius:9px;padding:13px 14px;position:relative;overflow:hidden}
|
|
40
42
|
.kpi::before{content:"";position:absolute;inset:0;pointer-events:none;opacity:.4;background:radial-gradient(90px 70px at 100% 0%,var(--kw,transparent),transparent 70%)}
|
|
41
43
|
.kpi-lbl{font-size:10px;color:var(--muted);font-weight:600;letter-spacing:.05em;text-transform:uppercase;position:relative}
|
|
@@ -46,6 +48,8 @@ a{color:var(--blue);text-decoration:none}
|
|
|
46
48
|
.kpi[data-t="signal"]{--kw:var(--abg)} .kpi[data-t="signal"] .kpi-num{color:var(--amber)}
|
|
47
49
|
.kpi[data-t="reach"]{--kw:var(--bbg)} .kpi[data-t="reach"] .kpi-num{color:var(--blue)}
|
|
48
50
|
.kpi[data-t="nosig"]{--kw:var(--rbg)} .kpi[data-t="nosig"] .kpi-num{color:var(--red)}
|
|
51
|
+
.kpi[data-t="priority"]{--kw:var(--obg)} .kpi[data-t="priority"] .kpi-num{color:var(--orange)}
|
|
52
|
+
.metric-scope{margin:9px 2px 0;color:var(--muted);font-size:11.5px;line-height:1.5}
|
|
49
53
|
|
|
50
54
|
/* TABS */
|
|
51
55
|
nav.tabs{display:flex;gap:2px;margin:18px 0 0;border-bottom:1px solid var(--bd)}
|
|
@@ -261,12 +265,14 @@ nav.tabs{display:flex;gap:2px;margin:18px 0 0;border-bottom:1px solid var(--bd)}
|
|
|
261
265
|
<span id="scan-date">—</span>
|
|
262
266
|
</div>
|
|
263
267
|
</header>
|
|
268
|
+
<div class="provenance" id="provenance"></div>
|
|
264
269
|
|
|
265
270
|
<section class="kpis" id="kpis"></section>
|
|
271
|
+
<p class="metric-scope" id="metric-scope"></p>
|
|
266
272
|
|
|
267
273
|
<nav class="tabs" role="tablist">
|
|
268
274
|
<button class="tab" role="tab" aria-selected="true" data-tab="codebase">Your Code</button>
|
|
269
|
-
<button class="tab" role="tab" aria-selected="false" data-tab="risks">
|
|
275
|
+
<button class="tab" role="tab" aria-selected="false" data-tab="risks">Priority gaps <span class="tc" id="t-risk">—</span></button>
|
|
270
276
|
<button class="tab" role="tab" aria-selected="false" data-tab="flows">Flows <span class="tc" id="t-flow">—</span></button>
|
|
271
277
|
<button class="tab" role="tab" aria-selected="false" data-tab="behaviors">Behaviors <span class="tc" id="t-beh">—</span></button>
|
|
272
278
|
</nav>
|
|
@@ -276,7 +282,7 @@ nav.tabs{display:flex;gap:2px;margin:18px 0 0;border-bottom:1px solid var(--bd)}
|
|
|
276
282
|
<p class="bridge">We scanned your repo and found <b id="br-methods">—</b> public methods across <b id="br-services">—</b> services, with <b id="br-tests">—</b> test files. Here's what we're working with.</p>
|
|
277
283
|
<div id="delta-banner"></div>
|
|
278
284
|
<div class="card" id="sysmap-card" hidden>
|
|
279
|
-
<p class="card-lbl">Your system, as the graph sees it — entry lanes flowing into the services they reach. Node size = flow traffic; color = dominant evidence tier; red ring = top-20 risk.
|
|
285
|
+
<p class="card-lbl">Your system, as the graph sees it — entry lanes flowing into the services they reach. Node size = flow traffic; color = dominant evidence tier; red ring = top-20 risk. Repeatable for the same commit, Git history, configuration, and OrangePro version.</p>
|
|
280
286
|
<div id="sysmap"></div>
|
|
281
287
|
</div>
|
|
282
288
|
<div class="cols2">
|
|
@@ -325,7 +331,7 @@ nav.tabs{display:flex;gap:2px;margin:18px 0 0;border-bottom:1px solid var(--bd)}
|
|
|
325
331
|
|
|
326
332
|
<!-- TAB 4: RISKS — actionable -->
|
|
327
333
|
<section class="panel" id="panel-risks" role="tabpanel">
|
|
328
|
-
<p class="bridge">
|
|
334
|
+
<p class="bridge">This is the <b>priority-gap worklist</b>, ranked by blast radius and test weakness. It is separate from the coverage-status cards above: <b>Reachable · no test signal</b> is one strict coverage bucket, not the number of priority gaps.</p>
|
|
329
335
|
<p class="bridge" id="risk-cap-note" style="font-size:12px;opacity:.75"></p>
|
|
330
336
|
<div class="risk-tools" id="risk-tools"></div>
|
|
331
337
|
<div id="risk-list"></div>
|
|
@@ -356,7 +362,7 @@ const D=window.DATA,$=(s)=>document.querySelector(s);
|
|
|
356
362
|
var when=new Date(d.baselineTs);
|
|
357
363
|
var ago=isNaN(when.getTime())?"last run":when.toLocaleString();
|
|
358
364
|
if(!d.changed){
|
|
359
|
-
el2.innerHTML='<div class="delta-wrap"><span class="delta-chip dc-none">No changes since last run ('+ago+')
|
|
365
|
+
el2.innerHTML='<div class="delta-wrap"><span class="delta-chip dc-none">No report changes since last run ('+ago+') for the recorded inputs</span></div>';
|
|
360
366
|
return;
|
|
361
367
|
}
|
|
362
368
|
var chips=[];
|
|
@@ -471,6 +477,11 @@ const S=D.summary;
|
|
|
471
477
|
$("#repo-name").textContent=D.repo;
|
|
472
478
|
$("#scan-date").textContent=new Date(D.scanned+"T00:00:00").toLocaleDateString("en-US",{month:"short",day:"numeric",year:"numeric"});
|
|
473
479
|
$("#framework").textContent=D.framework;
|
|
480
|
+
const P=D.provenance;
|
|
481
|
+
const prov=$("#provenance");
|
|
482
|
+
const provisional=P.churn!=="available";
|
|
483
|
+
prov.classList.toggle("warn",provisional);
|
|
484
|
+
prov.textContent=(provisional?"PROVISIONAL RANKING · ":"VERIFIED INPUTS · ")+"source "+P.source+" · commit "+(P.commit?P.commit.slice(0,12):"unavailable")+" · history "+P.history+" · churn "+P.churn+" ("+P.churnWindow+") · OrangePro "+P.toolVersion+" · input "+P.inputFingerprint+(P.reason?" · "+P.reason:"");
|
|
474
485
|
$("#fw-pill").textContent=D.framework;
|
|
475
486
|
|
|
476
487
|
// bridge text in codebase tab
|
|
@@ -484,18 +495,23 @@ $("#t-flow").textContent=D.flows.length;
|
|
|
484
495
|
$("#t-risk").textContent=D.risks.length;
|
|
485
496
|
|
|
486
497
|
// KPIs
|
|
498
|
+
const priorityGapCount=D.risks.length;
|
|
487
499
|
[
|
|
488
500
|
{lbl:"Methods found",num:S.total,sub:"public, with observable outcome",t:"total"},
|
|
489
501
|
{lbl:"Dynamically Proven",num:S.proven,sub:"test breaks if you change it",t:"proven"},
|
|
490
502
|
{lbl:"Test signal",num:S.associated,sub:"hard static test link, no proof",t:"signal"},
|
|
491
503
|
{lbl:"Candidate",num:S.candidate??0,sub:"lexical match only — unconfirmed",t:"cand"},
|
|
492
|
-
{lbl:"Reachable",num:S.reachableUntested,sub:"
|
|
493
|
-
{lbl:"
|
|
504
|
+
{lbl:"Reachable · no test signal",num:S.reachableUntested,sub:"coverage bucket — not the risk count",t:"reach"},
|
|
505
|
+
{lbl:"Unreached · no test signal",num:S.noSignal,sub:"no detected flow or test signal",t:"nosig"},
|
|
506
|
+
{lbl:"Priority gaps",num:priorityGapCount,sub:"top-ranked worklist across unproven tiers",t:"priority"},
|
|
494
507
|
].forEach(k=>{
|
|
495
508
|
const d=el("div","kpi",\`<div class="kpi-lbl">\${k.lbl}</div><div class="kpi-num">\${k.num}</div><div class="kpi-sub">\${k.sub}</div>\`);
|
|
496
509
|
d.dataset.t=k.t;
|
|
497
510
|
$("#kpis").append(d);
|
|
498
511
|
});
|
|
512
|
+
const outsideProofs=S.provenOutsideDenominator??0;
|
|
513
|
+
const coverageAccounted=(S.proven-outsideProofs)+S.associated+(S.candidate??0)+S.reachableUntested+S.noSignal;
|
|
514
|
+
$("#metric-scope").textContent="Coverage status classifies "+coverageAccounted.toLocaleString()+" of "+S.total.toLocaleString()+" mapped behaviors. Priority gaps is a separate ranked worklist of "+priorityGapCount.toLocaleString()+" unproven behaviors; it does not equal the Reachable count."+(outsideProofs?" "+outsideProofs.toLocaleString()+" additional dynamically proven behavior"+(outsideProofs===1?" is":"s are")+" shown outside the static denominator.":"");
|
|
499
515
|
|
|
500
516
|
// codebase
|
|
501
517
|
D.scan.services.forEach(([nm,ct])=>$("#svc-list").append(el("div","svc",\`<span class="nm">\${esc(nm)}</span><span class="ct">\${ct}</span>\`)));
|
|
@@ -630,7 +646,7 @@ document.onkeydown=e=>{if(e.key==="Escape"){drill.classList.remove("open");docum
|
|
|
630
646
|
// flows
|
|
631
647
|
const fl=$("#flow-list");
|
|
632
648
|
D.flows.forEach(f=>{
|
|
633
|
-
const rBadge=f.risk==="critical"?\`<span class="badge b-risk"><span class="d"></span>critical</span
|
|
649
|
+
const rBadge=f.risk==="critical"?\`<span class="badge b-risk"><span class="d"></span>critical</span>\`:f.risk==="high"?\`<span class="badge b-signal"><span class="d"></span>high</span>\`:\`<span class="badge b-info"><span class="d"></span>medium risk</span>\`;
|
|
634
650
|
const pBadge=f.proof==="proven"?\`<span class="badge b-proven"><span class="d"></span>dynamically proven</span>\`:f.proof==="assoc"?\`<span class="badge b-signal"><span class="d"></span>signal</span>\`:\`<span class="badge b-nosig"><span class="d"></span>no proof</span>\`;
|
|
635
651
|
|
|
636
652
|
const totalNodes=(f.trigger?1:0)+f.steps.length;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@orangepro/orangepro-mcp",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.10",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "OrangePro (`opro`) — a local-first, BYOK CLI + MCP server that builds an evidence graph from a local checkout, ingests runtime coverage, and generates grounded tests. Metadata-only exports; no source upload; generated tests stay local.",
|
|
6
6
|
"license": "MIT",
|