@orangepro/orangepro-mcp 0.2.9 → 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";
@@ -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([
@@ -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
  }
@@ -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|refund|charge(?!r)|billing|payout|chargeback/, 10],
143
- [/auth(?!or\b)|token(?!iz)|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);
@@ -390,14 +445,16 @@ export function rankRiskGaps(graph, opts = {}) {
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;
393
- const disconnected = (incoming.get(s.external_id) ?? 0) === 0 && fan_out === 0;
448
+ const disconnected = incoming_refs === 0 && fan_out === 0;
394
449
  if (disconnected)
395
450
  score = Math.round(score * 0.25 * 10) / 10;
396
451
  const reasons = [
397
452
  `ORS ${score} ≈ P${p} × I${i} × D${d}`,
398
453
  `${incoming_refs} incoming structural reference${incoming_refs === 1 ? "" : "s"} (method-attributed)`,
399
- `${git_churn} git churn line${git_churn === 1 ? "" : "s"} attributed to this symbol in 180 days`,
400
- `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}`
401
458
  ];
402
459
  if (isEntry)
403
460
  reasons.push("near an API/route/handler entry point");
@@ -414,6 +471,7 @@ export function rankRiskGaps(graph, opts = {}) {
414
471
  risk_score: score,
415
472
  incoming_refs,
416
473
  git_churn,
474
+ churn_available: churnAvailable,
417
475
  entry_point: isEntry,
418
476
  reasons,
419
477
  probability: p,
@@ -438,7 +496,7 @@ export function rankRiskGaps(graph, opts = {}) {
438
496
  const perFile = new Map();
439
497
  // Multi-program repos flood identical titles (76 x main) across files; the
440
498
  // per-FILE cap cannot see it. Same diversity principle, second axis.
441
- const maxPerTitle = 2;
499
+ const maxPerTitle = Math.max(1, opts.maxPerTitle ?? 2);
442
500
  const perTitle = new Map();
443
501
  const surfaced = [];
444
502
  const overflow = [];
@@ -457,8 +515,21 @@ export function rankRiskGaps(graph, opts = {}) {
457
515
  if (surfaced.length >= limit)
458
516
  break;
459
517
  }
460
- if (surfaced.length < limit)
461
- 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
+ }
462
533
  // Guarantee: the surfaced list is ALWAYS highest-risk-first, even when the
463
534
  // per-file diversity backfill re-admits overflow items (which otherwise land
464
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,10 @@ 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);
411
425
  }
412
426
  function displayTitle(title, file) {
413
427
  if (title.includes("."))
@@ -430,9 +444,12 @@ function riskContext(risk) {
430
444
  ? `${5 - (risk.flow_position ?? 0)} call${5 - (risk.flow_position ?? 0) === 1 ? "" : "s"} from the nearest entry point`
431
445
  : "deep in the call graph";
432
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)";
433
450
  const parts = [
434
451
  `Sits at ${pos}${sens ? ` on ${sens} paths` : ""}.`,
435
- `${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.`
436
453
  ];
437
454
  return parts.join(" ");
438
455
  }
@@ -748,12 +765,11 @@ function riskTodo(risk, verb, path, generatedTests) {
748
765
  : risk.entry_point
749
766
  ? `invokes ${displayTitle(risk.title, risk.file)} through its entry point`
750
767
  : `calls ${displayTitle(risk.title, risk.file)} directly`;
751
- const s = risk.data_sensitivity ?? 1;
752
- const sens = s >= 10
753
- ? " Include a failure case: a rejected transaction must leave no partial state."
754
- : s >= 9
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
755
771
  ? " Include a negative case: invalid or expired credentials must fail closed."
756
- : s >= 7
772
+ : (risk.data_sensitivity ?? 1) >= 7
757
773
  ? " Include a failure case: a rejected transaction must leave no partial state."
758
774
  : "";
759
775
  if (risk.integration_signal === "candidate") {
@@ -789,9 +805,11 @@ function riskRows(risks, graph) {
789
805
  const methodMatch = risk.title.match(/^(GET|POST|PUT|PATCH|DELETE)\s+(.+)$/i);
790
806
  const tags = [];
791
807
  const bucket = riskBucket(risk.risk_score, maxRiskScore);
792
- if (bucket)
808
+ if (risk.churn_available === false)
809
+ tags.push(["provisional rank", "info"]);
810
+ else if (bucket)
793
811
  tags.push([`${bucket} risk`, "risk"]);
794
- tags.push([`${fmtRefs(risk.incoming_refs)} incoming refs`, "info"]);
812
+ tags.push([`${fmtRefs(risk.incoming_refs)} weighted refs`, "info"]);
795
813
  if (risk.entry_point)
796
814
  tags.push(["Entry point", "entry"]);
797
815
  return {
@@ -833,7 +851,23 @@ export function buildBehaviorReportData(graph, ledger, opts = {}) {
833
851
  const flowIds = flowSymbolIds(graph);
834
852
  const summary = summaryFromRows(rows, flowIds);
835
853
  const repoRoot = opts.repoRoot ?? graph.workspace.root;
836
- const riskGaps = rankRiskGaps(graph, { repoRoot, limit: opts.riskLimit ?? 20, maxPerFile: 3 });
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
+ };
837
871
  const lists = behaviorLists(rows, flowIds);
838
872
  const risks = riskRows(riskGaps, graph);
839
873
  const sortedBehaviors = [...lists.behaviors].sort((a, b) => tierRank(a) - tierRank(b));
@@ -843,6 +877,7 @@ export function buildBehaviorReportData(graph, ledger, opts = {}) {
843
877
  scanned: (graph.updated_at || graph.created_at || new Date(0).toISOString()).slice(0, 10),
844
878
  framework: frameworkLabel(graph),
845
879
  analysisKind: summary.proven > 0 ? "static+dynamic" : "static",
880
+ provenance,
846
881
  summary,
847
882
  proofGuidance: proofGuidance(ledger, summary, opts.dynamicProof),
848
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.9",
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",