@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.
@@ -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 globs = Array.isArray(ws) ? ws : Array.isArray(ws?.packages) ? ws.packages : [];
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
- export const PARSER_VERSION = 17; // 17: Go method symbols receiver-qualified (Recv.M + member_of)
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/.test(content)) {
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.",
@@ -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
- const data = buildBehaviorReportData(graph, loadLedger(root), { repoRoot: root, dynamicProof: dyn });
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.
@@ -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
- continue;
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 text = `${node.external_id} ${symbolFile(node)} ${symbolTitle(node)}`.toLowerCase();
141
- const tiers = [
142
- [/payment|stripe|capture|refund|charge|billing/, 10],
143
- [/auth|token|session|password|credential|jwt|oauth/, 9],
144
- [/order|cart|checkout|invoice|transaction/, 7],
145
- [/customer|user|account|profile|pii|gdpr/, 6],
146
- [/notification|email|sms|webhook|push/, 3]
147
- ];
148
- for (const [re, weight] of tiers) {
149
- if (re.test(text))
150
- return weight;
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 churn = gitChurn(opts.repoRoot ?? graph.workspace.root, files, opts.churnWindow ?? "180 days ago");
285
- const firstCommitTs = gitFirstCommitBatch(opts.repoRoot ?? graph.workspace.root, files);
286
- const nowSec = Math.floor(Date.now() / 1000);
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
- `${git_churn} git churn line${git_churn === 1 ? "" : "s"} in 180 days${git_churn > 500 ? " (score capped at 500)" : ""}`
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
- const score = Math.round(pExact * iExact * d * 10) / 10;
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
- `${git_churn} git churn line${git_churn === 1 ? "" : "s"} attributed to this symbol in 180 days`,
397
- `route weight ${route_weight}, data sensitivity ${data_sensitivity}, fan-out ${fan_out}`
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
- if (used < maxPerFile) {
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
- if (surfaced.length < limit)
449
- surfaced.push(...overflow.slice(0, limit - surfaced.length));
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 associated = rows.filter((r) => r.evidence_tier === "associated" || r.evidence_tier === "runtime").length;
110
- const candidate = rows.filter((r) => r.evidence_tier === "candidate").length;
111
- const noneRows = rows.filter((r) => isNoneTier(r.evidence_tier));
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 { total: rows.length, proven, associated, candidate, none: noneRows.length, reachableUntested, noSignal: noneRows.length - reachableUntested };
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 (a file-level
406
- * reference split across its symbols). Display rounds; sub-1 shows "<1". */
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
- if (n > 0 && n < 1)
409
- return "<1";
410
- return String(Math.round(n));
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} caller${refs === "1" ? "" : "s"}, ${risk.fan_out ?? 0} downstream call${(risk.fan_out ?? 0) === 1 ? "" : "s"}, ${risk.git_churn} line${risk.git_churn === 1 ? "" : "s"} changed in 180 days — and no test proves its behavior.`
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) >= 9
746
- ? " Include a negative case: invalid or expired credentials must fail closed."
747
- : (risk.data_sensitivity ?? 1) >= 7
748
- ? " Include a failure case: a rejected transaction must leave no partial state."
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 (bucket)
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)} incoming refs`, "info"]);
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(5,1fr);gap:10px;margin:16px 0 0}
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">Risks <span class="tc" id="t-risk">—</span></button>
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. Identical on every run.</p>
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">These are the flows with the highest blast radius and the weakest test coverage, <b>sorted highest risk first</b>. Each one tells you exactly what to do next.</p>
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+') identical graph, identical ranking</span></div>';
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:"called but untested",t:"reach"},
493
- {lbl:"No signal",num:S.noSignal,sub:"nothing touches it",t:"nosig"},
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>\`:\`<span class="badge b-signal"><span class="d"></span>high</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.8",
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",