@orangepro/orangepro-mcp 0.2.7 → 0.2.9

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.
@@ -47,7 +47,7 @@ function extractionBackend(language) {
47
47
  return "tsc"; // TS/JS compiler path (or unextracted)
48
48
  return treeSitterReady(language) ? "ts" : "rx"; // tree-sitter AST vs regex fallback
49
49
  }
50
- import { runConfirmer } from "./confirm.js";
50
+ import { repoRootOf, resolveSpecifier, workspacePackages, runConfirmer } from "./confirm.js";
51
51
  const DETECTOR = "repo_analyzer";
52
52
  // Global ceiling on extracted code symbols. A SINGLE counter shared across the walk,
53
53
  // so a low value lets whichever language is walked first (e.g. a Go `server/`) eat the
@@ -70,13 +70,8 @@ const BEHAVIOR_SURFACE_FILE_RE = /(^|\/)[^/]*(api|controller|service|handler|res
70
70
  const BEHAVIOR_SURFACE_NAME_RE = /(^|[.#])(__init__|handle|handler|route|controller|service|resolver|processor|job|worker|queue|consumer|subscriber|listener|command|gateway|execute|process|consume|dispatch|schedule|upload|download|sync|checkout|charge|refund|capture|authorize|login|logout|register|find|search|list|create|update|delete|remove|archive|enable|disable|validate|get|add|sub|sum|total|save|load|render|run|main|root|child|mul|about|behavior)([A-Z0-9_]|$)/i;
71
71
  const CLIENT_FACTORY_NAME_RE = /(^|[.#])get[A-Z0-9_].*Client$/;
72
72
  const BEHAVIOR_OWNER_RE = /(Service|Controller|Resolver|Handler|Processor|Job|Worker|Queue|Consumer|Subscriber|Listener|Command|Gateway|Route|Router)$/;
73
- const UTILITY_DIRECTORY_EXCLUDE_RE = /\/(utils?|helpers?|tools?|loaders?|dml|dal|orchestration|codemods?|oas|models?|migrations?|migration-scripts?|instrumentation|medusa-telemetry|medusa-test-utils|eslint-plugin)\//i;
74
- const FRAMEWORK_INTERNAL_PATH_EXCLUDE_RE = /\/(http\/(?:routes-loader|routes-finder|routes-sorter|middlewares\/bodyparser)|medusa-app-loader|remote-query\/query)(?:\/|$)/i;
75
- const CLI_PACKAGE_PATH_EXCLUDE_RE = /\/(?:cli\/[^/]+\/src\/(?:commands|core|reporter)|packages\/[^/]+\/src\/commands)\//i;
76
- const BACKEND_RUNTIME_PATH_EXCLUDE_RE = /\/(?:packages\/core\/framework\/src|packages\/modules\/(?:workflow-engine-[^/]+|link-modules)\/src)(?:\/|$)/i;
77
- const UI_PRODUCT_PATH_EXCLUDE_RE = /\/(?:packages\/admin\/(?:dashboard|admin-bundler|admin-vite-plugin)\/src|packages\/design-system\/(?:toolbox|icons)\/src|www\/(?:apps|packages)\/[^/]+\/(?:app|src|providers|components|lib))(?:\/|$)/i;
78
- const SDK_CLIENT_PATH_EXCLUDE_RE = /\/(?:packages\/core\/js-sdk\/src|packages\/[^/]+\/(?:sdk|client)\/src)(?:\/|$)/i;
79
- const PLUGIN_ADMIN_PATH_EXCLUDE_RE = /\/(?:plugins\/[^/]+\/src\/admin|admin\/routes)\//i;
73
+ const UTILITY_DIRECTORY_EXCLUDE_RE = /\/(utils?|helpers?|tools?|loaders?|dml|dal|orchestration|codemods?|oas|models?|migrations?|migration-scripts?|instrumentation|eslint-plugin)\//i;
74
+ const PRESENTATION_ASSET_PATH_RE = /\/(?:icons?|assets?)\//i;
80
75
  const INFRA_METHOD_SUFFIX_RE = /^get[A-Z0-9_].*(Identifier|RegistrationKey|Config|Registry|Options|Settings|Path|Directory|TmpDir|Program|PackageManager|Command|Expression|Recommendation|CircularReferences|PivotTableName|PropertyName|PropertyKey|UnderlyingType|ComputedColumnRegistry|EntityOverrideRegistry|InverseRegistry|RelativeDate|SelectsAndRelations|SetDifference|ResolvedPlugins|Token|Scope|Module|Column|Pivot|Ttl|Timeout|Interval|Size|Limit|Offset|Prefix|Suffix|Pattern|Handler|Resource)$/;
81
76
  const BUILD_BOOTSTRAP_PREFIX_RE = /^(load|build|compile)(Modules?|Routes?|Routers?|Plugins?|Config|Schema|Program|Package|Project|Files?|Commands?|Migrations?|Definitions?|Manifest|Artifacts?)([A-Z0-9_]|$)/;
82
77
  // NOTE: the broad `.*Provider.*Service` clause was removed — it over-excluded FUNCTIONAL provider
@@ -106,23 +101,8 @@ function behaviorSurfaceExclusionReason(relPath, name, memberOf) {
106
101
  if (UTILITY_DIRECTORY_EXCLUDE_RE.test(path)) {
107
102
  return "Utility/model/tooling path — infrastructure plumbing, excluded from the behavior denominator.";
108
103
  }
109
- if (FRAMEWORK_INTERNAL_PATH_EXCLUDE_RE.test(path)) {
110
- return "Framework-internal route/query plumbing — excluded from the behavior denominator.";
111
- }
112
- if (CLI_PACKAGE_PATH_EXCLUDE_RE.test(path)) {
113
- return "CLI developer tooling path — excluded from the behavior denominator.";
114
- }
115
- if (BACKEND_RUNTIME_PATH_EXCLUDE_RE.test(path)) {
116
- return "Framework/runtime/link plumbing path — excluded from backend behavior denominator.";
117
- }
118
- if (UI_PRODUCT_PATH_EXCLUDE_RE.test(path)) {
119
- return "UI/docs/design-system path — excluded from backend behavior denominator.";
120
- }
121
- if (SDK_CLIENT_PATH_EXCLUDE_RE.test(path)) {
122
- return "SDK/client package path — excluded from backend behavior denominator.";
123
- }
124
- if (PLUGIN_ADMIN_PATH_EXCLUDE_RE.test(path)) {
125
- return "Plugin admin UI path — excluded from backend behavior denominator.";
104
+ if (PRESENTATION_ASSET_PATH_RE.test(path)) {
105
+ return "Presentation asset/icon path — excluded from the behavior denominator.";
126
106
  }
127
107
  if (FRAMEWORK_HOOK_METHOD_RE.test(methodName)) {
128
108
  return "Framework lifecycle hook — excluded from the behavior denominator.";
@@ -160,6 +140,73 @@ function behaviorSurfaceReason(relPath, name, memberOf) {
160
140
  }
161
141
  return null;
162
142
  }
143
+ const RUNTIME_SOURCE_EXT_RE = /\.(?:[cm]?[jt]sx?)$/i;
144
+ /**
145
+ * Resolve package-level public entry modules from package metadata and the two
146
+ * conventional source entry paths. This gives libraries a semantic behavior
147
+ * surface without treating every exported helper in the repository as product
148
+ * behavior.
149
+ */
150
+ function packagePublicEntryPaths(root, relPaths) {
151
+ const normalizedPaths = new Set(relPaths.map((relPath) => relPath.replace(/\\/g, "/")));
152
+ const entries = new Set();
153
+ const extensions = [".ts", ".tsx", ".js", ".jsx", ".mts", ".cts", ".mjs", ".cjs"];
154
+ const addCandidate = (packageDir, rawTarget) => {
155
+ if (!rawTarget || rawTarget.includes("*") || rawTarget.startsWith("#"))
156
+ return;
157
+ const cleanTarget = rawTarget.replace(/^\.\//, "").split(/[?#]/, 1)[0];
158
+ if (!cleanTarget || cleanTarget.startsWith("../") || path.isAbsolute(cleanTarget))
159
+ return;
160
+ const relativeTarget = packageDir ? `${packageDir}/${cleanTarget}` : cleanTarget;
161
+ const candidates = new Set([relativeTarget]);
162
+ const withoutExt = relativeTarget.replace(RUNTIME_SOURCE_EXT_RE, "");
163
+ for (const extension of extensions)
164
+ candidates.add(`${withoutExt}${extension}`);
165
+ // Published packages often expose dist/index.js while analyzing src/index.ts.
166
+ const sourceBase = cleanTarget.replace(/^(?:dist|lib|build)\//, "src/").replace(RUNTIME_SOURCE_EXT_RE, "");
167
+ const sourceTarget = packageDir ? `${packageDir}/${sourceBase}` : sourceBase;
168
+ for (const extension of extensions)
169
+ candidates.add(`${sourceTarget}${extension}`);
170
+ for (const candidate of candidates) {
171
+ if (normalizedPaths.has(candidate))
172
+ entries.add(candidate);
173
+ }
174
+ };
175
+ const collectTargets = (value, targets) => {
176
+ if (typeof value === "string") {
177
+ targets.push(value);
178
+ }
179
+ else if (Array.isArray(value)) {
180
+ for (const item of value)
181
+ collectTargets(item, targets);
182
+ }
183
+ else if (value && typeof value === "object") {
184
+ for (const item of Object.values(value))
185
+ collectTargets(item, targets);
186
+ }
187
+ };
188
+ for (const manifestPath of normalizedPaths) {
189
+ if (path.posix.basename(manifestPath) !== "package.json")
190
+ continue;
191
+ const packageDir = path.posix.dirname(manifestPath) === "." ? "" : path.posix.dirname(manifestPath);
192
+ try {
193
+ const manifest = JSON.parse(readFileSync(path.join(root, manifestPath), "utf8"));
194
+ const targets = [];
195
+ for (const field of ["exports", "main", "module", "browser"])
196
+ collectTargets(manifest[field], targets);
197
+ for (const target of targets)
198
+ addCandidate(packageDir, target);
199
+ }
200
+ catch {
201
+ // Malformed package metadata is reported elsewhere; it must not stop analysis.
202
+ }
203
+ for (const conventional of ["index", "src/index"]) {
204
+ for (const extension of extensions)
205
+ addCandidate(packageDir, `${conventional}${extension}`);
206
+ }
207
+ }
208
+ return entries;
209
+ }
163
210
  const FLOW_LINK_STOPWORDS = new Set([
164
211
  "test",
165
212
  "tests",
@@ -282,6 +329,7 @@ export function analyzeRepo(root, opts = {}) {
282
329
  const maxSymbols = Math.max(1, opts.maxSymbols ?? MAX_TOTAL_SYMBOLS);
283
330
  const ignore = loadIgnore(root);
284
331
  const { files, truncated: filesCapHit, max_files: maxFiles } = walkFilesWithMeta(root, ignore, { maxFiles: opts.maxFiles });
332
+ const publicEntryFiles = packagePublicEntryPaths(root, files.map((file) => file.relPath));
285
333
  const warnings = [];
286
334
  // Wall-clock budget for the per-file scan (DISCLOSED partial when hit; never silent).
287
335
  const now = opts.now ?? (() => Date.now());
@@ -741,7 +789,8 @@ export function analyzeRepo(root, opts = {}) {
741
789
  : callableBehaviorCandidate && !surfaceExclusionReason
742
790
  ? behaviorSurfaceReason(file.relPath, sym.name, sym.member_of)
743
791
  : null;
744
- const eligible = callableBehaviorCandidate && surfaceReason !== null;
792
+ const publicApiEntry = callableBehaviorCandidate && !surfaceExclusionReason && publicEntryFiles.has(file.relPath);
793
+ const eligible = callableBehaviorCandidate && (surfaceReason !== null || publicApiEntry);
745
794
  const behaviorSurfaceExcluded = callableBehaviorCandidate && surfaceExclusionReason !== null;
746
795
  const notEntryPointAdjacent = callableBehaviorCandidate && !eligible && !behaviorSurfaceExcluded;
747
796
  if (isBoilerplate)
@@ -757,7 +806,7 @@ export function analyzeRepo(root, opts = {}) {
757
806
  ...(sym.end_line ? { end_line: sym.end_line } : {}),
758
807
  ...(sym.member_of ? { member_of: sym.member_of } : {}),
759
808
  ...(sym.callable !== undefined ? { callable_const: sym.callable } : {}),
760
- ...(surfaceReason ? { behavior_surface: "entrypoint_adjacent" } : {}),
809
+ ...(surfaceReason ? { behavior_surface: "entrypoint_adjacent" } : publicApiEntry ? { behavior_surface: "public_api_entry" } : {}),
761
810
  ...(behaviorSurfaceExcluded ? { denominator_reason_code: "infra_behavior_surface" } : {}),
762
811
  ...(notEntryPointAdjacent ? { denominator_reason_code: "not_entry_point_adjacent" } : {})
763
812
  },
@@ -777,13 +826,15 @@ export function analyzeRepo(root, opts = {}) {
777
826
  ? BOILERPLATE_REASON
778
827
  : eligible && surfaceReason
779
828
  ? surfaceReason
780
- : behaviorSurfaceExcluded && surfaceExclusionReason
781
- ? surfaceExclusionReason
782
- : callableBehaviorCandidate
783
- ? "Callable export is not API/service/route/job-adjacent — kept for grounding, excluded from the behavior denominator."
784
- : sym.symbol_kind === "class"
785
- ? "Exported class container — kept for grounding; methods/functions carry behavior in v1."
786
- : "Exported const (not provably callable) — excluded from the denominator in v1."
829
+ : publicApiEntry
830
+ ? "Callable export from a package public entry module — countable library behavior surface."
831
+ : behaviorSurfaceExcluded && surfaceExclusionReason
832
+ ? surfaceExclusionReason
833
+ : callableBehaviorCandidate
834
+ ? "Callable export is not API/service/route/job-adjacent — kept for grounding, excluded from the behavior denominator."
835
+ : sym.symbol_kind === "class"
836
+ ? "Exported class container — kept for grounding; methods/functions carry behavior in v1."
837
+ : "Exported const (not provably callable) — excluded from the denominator in v1."
787
838
  }));
788
839
  edges.push(makeEdge({
789
840
  from_external_id: symId,
@@ -1921,6 +1972,46 @@ export function analyzeRepo(root, opts = {}) {
1921
1972
  seenPair.add(key);
1922
1973
  candidates.push({ testRel, testAbs, implRel, implAbs });
1923
1974
  }
1975
+ // Import-derived pairing: a test file is a candidate for every in-repo
1976
+ // impl file it imports — an import IS the relationship; resolution
1977
+ // (relative, tsconfig paths, npm/pnpm workspace names) decides
1978
+ // membership. Adds PAIRS only; the assertion-aware confirmer remains
1979
+ // the sole judge.
1980
+ {
1981
+ const pairRoot = repoRootOf(root);
1982
+ const wsPkgs = workspacePackages(pairRoot);
1983
+ const relByAbs = new Map(resolveFiles.map((f) => [f.abs, f.rel]));
1984
+ const IMPORT_SPEC_RE = /(?:import|export)[^'"\n]*from\s*['"]([^'"]+)['"]|require\(\s*['"]([^'"]+)['"]\s*\)/g;
1985
+ for (const tf of resolveFiles) {
1986
+ if (tf.role !== "test")
1987
+ continue;
1988
+ let srcTxt = "";
1989
+ try {
1990
+ srcTxt = readFileSync(tf.abs, "utf8");
1991
+ }
1992
+ catch {
1993
+ continue;
1994
+ }
1995
+ let m;
1996
+ IMPORT_SPEC_RE.lastIndex = 0;
1997
+ while ((m = IMPORT_SPEC_RE.exec(srcTxt)) !== null) {
1998
+ const spec = m[1] ?? m[2];
1999
+ if (!spec)
2000
+ continue;
2001
+ const resolvedAbs = resolveSpecifier(spec, tf.abs, pairRoot, wsPkgs);
2002
+ if (!resolvedAbs)
2003
+ continue;
2004
+ const implRel = relByAbs.get(resolvedAbs);
2005
+ if (!implRel || !eligibleSymbolsByFile.has(implRel))
2006
+ continue;
2007
+ const key = `${tf.rel}|${implRel}`;
2008
+ if (seenPair.has(key))
2009
+ continue;
2010
+ seenPair.add(key);
2011
+ candidates.push({ testRel: tf.rel, testAbs: tf.abs, implRel, implAbs: resolvedAbs });
2012
+ }
2013
+ }
2014
+ }
1924
2015
  const confirmBudget = Math.max(1, Number(process.env.ORANGEPRO_MAX_CONFIRM_FILES) || 1500);
1925
2016
  const riskSymbolLimit = Math.max(1, Number(process.env.ORANGEPRO_CONFIRM_RISK_SYMBOLS) || DEFAULT_CONFIRM_RISK_SYMBOLS);
1926
2017
  const involved = new Set();
@@ -15,6 +15,10 @@ const NEST_GQL_METHOD_DECORATOR = /@(Query|Mutation|Subscription|ResolveField)\s
15
15
  const NEST_PROCESSOR_DECORATOR = /@Processor\s*\(\s*(?:(["'`])([^"'`]*)\1|[A-Za-z_$][A-Za-z0-9_$.]*)?\s*\)(?:\s|@[A-Za-z_$][A-Za-z0-9_$]*(?:\s*\((?:[^()]|\([^()]*\))*\))?|\/\*[\s\S]*?\*\/|\/\/[^\n]*\n)*?(?:export\s+)?class\s+([A-Za-z_$][A-Za-z0-9_$]*)/g;
16
16
  const NEST_PROCESS_METHOD_DECORATOR = /@Process\s*\((?:[^()]|\([^()]*\))*\)(?:\s|@[A-Za-z_$][A-Za-z0-9_$]*(?:\s*\((?:[^()]|\([^()]*\))*\))?|\/\*[\s\S]*?\*\/|\/\/[^\n]*\n)*?(?:public\s+|private\s+|protected\s+|async\s+)*([A-Za-z_$][A-Za-z0-9_$]*)\s*\(/g;
17
17
  const NEST_CONTROLLER_DECORATOR = /@Controller\s*\(\s*(?:(["'`])([^"'`]*)\1)?\s*\)(?:\s|@[A-Za-z_$][A-Za-z0-9_$]*(?:\s*\((?:[^()]|\([^()]*\))*\))?|\/\*[\s\S]*?\*\/|\/\/[^\n]*\n)*?(?:export\s+)?class\s+([A-Za-z_$][A-Za-z0-9_$]*)/g;
18
+ const NEST_RESOLVER_DECORATOR = /@Resolver(?:\s*\((?:[^()]|\([^()]*\))*\))?(?:\s|@[A-Za-z_$][A-Za-z0-9_$]*(?:\s*\((?:[^()]|\([^()]*\))*\))?|\/\*[\s\S]*?\*\/|\/\/[^\n]*\n)*?(?:export\s+)?(?:abstract\s+)?class\s+([A-Za-z_$][A-Za-z0-9_$]*)/g;
19
+ const NEST_WORKER_PROCESS_METHOD = /(?:^|[\n;}])\s*(?:public\s+|protected\s+|override\s+|async\s+)*\b(process)\s*\(/g;
20
+ const NEST_CRON_METHOD_DECORATOR = /@Cron\s*\((?:[^()]|\([^()]*\))*\)(?:\s|@[A-Za-z_$][A-Za-z0-9_$]*(?:\s*\((?:[^()]|\([^()]*\))*\))?|\/\*[\s\S]*?\*\/|\/\/[^\n]*\n)*?(?:public\s+|private\s+|protected\s+|async\s+)*([A-Za-z_$][A-Za-z0-9_$]*)\s*\(/g;
21
+ const NEST_EVENT_METHOD_DECORATOR = /@(OnEvent|EventPattern|MessagePattern)\s*\(\s*(?:(["'`])([^"'`]*)\2|(?:[^()]|\([^()]*\))*)\s*\)(?:\s|@[A-Za-z_$][A-Za-z0-9_$]*(?:\s*\((?:[^()]|\([^()]*\))*\))?|\/\*[\s\S]*?\*\/|\/\/[^\n]*\n)*?(?:public\s+|private\s+|protected\s+|async\s+)*([A-Za-z_$][A-Za-z0-9_$]*)\s*\(/g;
18
22
  const EXPRESS_ROUTER_CALL = /\b(?:router|app)\s*\.\s*(get|post|put|delete|patch|options|head|all)\s*\(\s*(["'`])([^"'`]*)\2\s*,\s*([A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)?|\([^)]*\)\s*=>|async\s+\([^)]*\)\s*=>|function\s+[A-Za-z_$][A-Za-z0-9_$]*)/gi;
19
23
  const EXPRESS_ROUTE_CHAIN = /\b(?:router|app)\s*\.\s*route\s*\(\s*(["'`])([^"'`]*)\1\s*\)((?:\s*\.\s*(?:get|post|put|delete|patch|options|head|all)\s*\([^)]*\))+)/gi;
20
24
  const CHAINED_METHOD_CALL = /\.\s*(get|post|put|delete|patch|options|head|all)\s*\(\s*([A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)?|\([^)]*\)\s*=>|async\s+\([^)]*\)\s*=>|function\s+[A-Za-z_$][A-Za-z0-9_$]*)/gi;
@@ -26,6 +30,8 @@ export function extractBehaviorContracts(content, file) {
26
30
  ...extractNestContracts(content, file),
27
31
  ...extractNestGraphqlContracts(content, file),
28
32
  ...extractNestProcessorContracts(content, file),
33
+ ...extractNestScheduledContracts(content, file),
34
+ ...extractNestEventContracts(content, file),
29
35
  ...extractRouterContracts(content, file, EXPRESS_ROUTER_CALL, "express"),
30
36
  ...extractExpressRouteChains(content, file),
31
37
  ...extractRouterContracts(content, file, FASTIFY_CALL, "fastify")
@@ -49,104 +55,148 @@ function extractFileRouteContracts(content, file) {
49
55
  return contracts;
50
56
  }
51
57
  function extractNestContracts(content, file) {
52
- const controllers = [...content.matchAll(NEST_CONTROLLER_DECORATOR)].map((match) => ({
53
- index: match.index ?? 0,
54
- path: normalizeRoutePath(match[2] ?? ""),
55
- name: match[3]
56
- }));
58
+ const controllers = classRanges(content, NEST_CONTROLLER_DECORATOR, 3, 2);
57
59
  if (controllers.length === 0)
58
60
  return [];
59
61
  const contracts = [];
60
- for (const match of content.matchAll(NEST_METHOD_DECORATOR)) {
61
- const index = match.index ?? 0;
62
- const controller = nearestController(controllers, index);
63
- const method = httpMethod(match[1]);
64
- const routePath = joinRoutePaths(controller?.path ?? "", match[3] ?? match[5] ?? "");
65
- const handler = match[6];
66
- contracts.push(makeContract({
67
- file,
68
- framework: "nestjs",
69
- method,
70
- path: routePath,
71
- handler,
72
- controller: controller?.name
73
- }));
62
+ for (const controller of controllers) {
63
+ for (const match of controller.body.matchAll(NEST_METHOD_DECORATOR)) {
64
+ const method = httpMethod(match[1]);
65
+ const routePath = joinRoutePaths(normalizeRoutePath(controller.path ?? ""), match[3] ?? match[5] ?? "");
66
+ const handler = match[6];
67
+ contracts.push(makeContract({ file, framework: "nestjs", method, path: routePath, handler, controller: controller.name }));
68
+ }
74
69
  }
75
70
  return contracts;
76
71
  }
77
72
  function extractNestGraphqlContracts(content, file) {
78
- // GraphQL resolvers are first-class user-triggerable entry points. In
79
- // NestJS-heavy monorepos (Twenty: 415 @Query/@Mutation methods vs 139 HTTP
80
- // routes) skipping them left almost every user behavior without an Endpoint
81
- // anchor, so static flows rooted at internal orphan methods instead.
82
73
  if (!/@(Query|Mutation|Subscription)\s*\(/.test(content))
83
74
  return [];
84
- const resolvers = [...content.matchAll(CLASS_DECLARATION)].map((match) => ({
85
- index: match.index ?? 0,
86
- path: "",
87
- name: match[1]
88
- }));
75
+ // Only methods lexically inside an actual @Resolver class qualify. Looking
76
+ // for @Query anywhere in a file misclassified query builders and ORM metadata.
77
+ const resolvers = classRanges(content, NEST_RESOLVER_DECORATOR, 1);
89
78
  if (resolvers.length === 0)
90
79
  return [];
91
80
  const contracts = [];
92
- for (const match of content.matchAll(NEST_GQL_METHOD_DECORATOR)) {
93
- const resolver = nearestController(resolvers, match.index ?? 0);
94
- if (!resolver)
95
- continue;
96
- const opKind = match[1].toUpperCase();
97
- if (opKind === "RESOLVEFIELD")
98
- continue; // field resolvers are not user-triggerable operations
99
- const handler = match[2];
100
- contracts.push(makeContract({
101
- file,
102
- framework: "nestjs",
103
- kind: "graphql_operation",
104
- method: opKind,
105
- path: `graphql:${handler}`,
106
- handler,
107
- controller: resolver.name
108
- }));
81
+ for (const resolver of resolvers) {
82
+ for (const match of resolver.body.matchAll(NEST_GQL_METHOD_DECORATOR)) {
83
+ const opKind = match[1].toUpperCase();
84
+ if (opKind === "RESOLVEFIELD")
85
+ continue;
86
+ const handler = match[2];
87
+ contracts.push(makeContract({ file, framework: "nestjs", kind: "graphql_operation", method: opKind, path: `graphql:${handler}`, handler, controller: resolver.name }));
88
+ }
109
89
  }
110
90
  return contracts;
111
91
  }
112
92
  function extractNestProcessorContracts(content, file) {
113
- // Background jobs and crons are behaviors (June 27 definition: user- or
114
- // system-triggerable, cross-layer, observable outcome). Anchoring them lets
115
- // the flow walker show queue-driven chains instead of orphan roots.
116
- const processors = [...content.matchAll(NEST_PROCESSOR_DECORATOR)].map((match) => ({
117
- index: match.index ?? 0,
118
- path: normalizeRoutePath(match[2] ?? ""),
119
- name: match[3]
120
- }));
93
+ const processors = classRanges(content, NEST_PROCESSOR_DECORATOR, 3, 2);
121
94
  if (processors.length === 0)
122
95
  return [];
123
96
  const contracts = [];
124
- for (const match of content.matchAll(NEST_PROCESS_METHOD_DECORATOR)) {
125
- const processor = nearestController(processors, match.index ?? 0);
126
- if (!processor)
127
- continue;
128
- const handler = match[1];
129
- contracts.push(makeContract({
130
- file,
131
- framework: "nestjs",
132
- kind: "queue_processor",
133
- method: "JOB",
134
- path: `queue:${processor.path || processor.name}`,
135
- handler,
136
- controller: processor.name
137
- }));
97
+ for (const processor of processors) {
98
+ const handlers = new Set();
99
+ for (const match of processor.body.matchAll(NEST_PROCESS_METHOD_DECORATOR))
100
+ handlers.add(match[1]);
101
+ if (/\bextends\s+WorkerHost\b/.test(processor.header)) {
102
+ for (const match of processor.body.matchAll(NEST_WORKER_PROCESS_METHOD))
103
+ handlers.add(match[1]);
104
+ }
105
+ for (const handler of handlers) {
106
+ contracts.push(makeContract({ file, framework: "nestjs", kind: "queue_processor", method: "JOB", path: `queue:${processor.path || processor.name}`, handler, controller: processor.name }));
107
+ }
138
108
  }
139
109
  return contracts;
140
110
  }
141
- function nearestController(controllers, index) {
142
- let current;
143
- for (const controller of controllers) {
144
- if (controller.index <= index)
145
- current = controller;
146
- else
147
- break;
111
+ function extractNestScheduledContracts(content, file) {
112
+ const contracts = [];
113
+ for (const owner of classRanges(content, CLASS_DECLARATION, 1)) {
114
+ for (const match of owner.body.matchAll(NEST_CRON_METHOD_DECORATOR)) {
115
+ const handler = match[1];
116
+ contracts.push(makeContract({ file, framework: "nestjs", kind: "scheduled_task", method: "SCHEDULE", path: `schedule:${handler}`, handler, controller: owner.name }));
117
+ }
118
+ }
119
+ return contracts;
120
+ }
121
+ function extractNestEventContracts(content, file) {
122
+ const contracts = [];
123
+ for (const owner of classRanges(content, CLASS_DECLARATION, 1)) {
124
+ for (const match of owner.body.matchAll(NEST_EVENT_METHOD_DECORATOR)) {
125
+ const handler = match[4];
126
+ const event = match[3] ?? handler;
127
+ contracts.push(makeContract({ file, framework: "nestjs", kind: "event_consumer", method: "EVENT", path: `event:${event}`, handler, controller: owner.name }));
128
+ }
129
+ }
130
+ return contracts;
131
+ }
132
+ function classRanges(content, pattern, nameGroup, pathGroup) {
133
+ const ranges = [];
134
+ for (const match of content.matchAll(pattern)) {
135
+ const start = match.index ?? 0;
136
+ const open = content.indexOf("{", start + match[0].length);
137
+ if (open === -1)
138
+ continue;
139
+ const close = matchingBrace(content, open);
140
+ if (close === -1)
141
+ continue;
142
+ ranges.push({
143
+ name: match[nameGroup],
144
+ ...(pathGroup ? { path: match[pathGroup] ?? "" } : {}),
145
+ header: content.slice(start, open),
146
+ body: content.slice(open + 1, close)
147
+ });
148
+ }
149
+ return ranges;
150
+ }
151
+ function matchingBrace(content, open) {
152
+ let depth = 0;
153
+ let quote = "";
154
+ let lineComment = false;
155
+ let blockComment = false;
156
+ for (let i = open; i < content.length; i++) {
157
+ const c = content[i];
158
+ const next = content[i + 1] ?? "";
159
+ if (lineComment) {
160
+ if (c === "\n")
161
+ lineComment = false;
162
+ continue;
163
+ }
164
+ if (blockComment) {
165
+ if (c === "*" && next === "/") {
166
+ blockComment = false;
167
+ i++;
168
+ }
169
+ continue;
170
+ }
171
+ if (quote) {
172
+ if (c === "\\") {
173
+ i++;
174
+ continue;
175
+ }
176
+ if (c === quote)
177
+ quote = "";
178
+ continue;
179
+ }
180
+ if (c === "/" && next === "/") {
181
+ lineComment = true;
182
+ i++;
183
+ continue;
184
+ }
185
+ if (c === "/" && next === "*") {
186
+ blockComment = true;
187
+ i++;
188
+ continue;
189
+ }
190
+ if (c === '"' || c === "'" || c === "`") {
191
+ quote = c;
192
+ continue;
193
+ }
194
+ if (c === "{")
195
+ depth++;
196
+ else if (c === "}" && --depth === 0)
197
+ return i;
148
198
  }
149
- return current;
199
+ return -1;
150
200
  }
151
201
  function extractRouterContracts(content, file, pattern, framework) {
152
202
  const contracts = [];
@@ -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,
@@ -2376,6 +2412,130 @@ export function confirmPair(ctx, testAbsPath, implAbsPath, behaviorName) {
2376
2412
  reason: "behavior not evaluated"
2377
2413
  });
2378
2414
  }
2415
+ // Workspace/alias resolution is useful for candidate linkage in TS monorepos,
2416
+ // but resolution and a call reference are not assertion evidence. Keep this
2417
+ // resolver separate from the hard-proof confirmer below.
2418
+ import { readFileSync as _rf, existsSync as _ex, readdirSync as _rd } from "node:fs";
2419
+ import { dirname as _dn, join as _jn, resolve as _rs } from "node:path";
2420
+ const EXT_TRIES = ["", ".ts", ".tsx", ".js", ".jsx", "/index.ts", "/index.tsx", "/index.js"];
2421
+ const BUILT_DIRS = /\/(lib|dist|build|out)(\/|$)/;
2422
+ function tryFile(base) {
2423
+ for (const e of EXT_TRIES)
2424
+ if (_ex(base + e))
2425
+ return base + e;
2426
+ return null;
2427
+ }
2428
+ function readJsonSafe(p) {
2429
+ try {
2430
+ return JSON.parse(_rf(p, "utf8"));
2431
+ }
2432
+ catch {
2433
+ return null;
2434
+ }
2435
+ }
2436
+ /** Nearest tsconfig paths/baseUrl for a file, walking extends chains (depth-capped). */
2437
+ function tsconfigPathsFor(fileAbs, stopDir) {
2438
+ let dir = _dn(fileAbs);
2439
+ for (let i = 0; i < 12 && dir.startsWith(stopDir); i++) {
2440
+ const tc = _jn(dir, "tsconfig.json");
2441
+ if (_ex(tc)) {
2442
+ let merged = {};
2443
+ let cur = tc;
2444
+ for (let d = 0; d < 6 && cur; d++) {
2445
+ const j = readJsonSafe(cur);
2446
+ if (!j)
2447
+ break;
2448
+ const co = (j.compilerOptions ?? {});
2449
+ merged = { baseUrl: merged.baseUrl ?? (co.baseUrl ? _rs(_dn(cur), co.baseUrl) : undefined), paths: { ...co.paths, ...merged.paths } };
2450
+ cur = typeof j.extends === "string" ? (tryFile(_rs(_dn(cur), j.extends)) ?? (_ex(_rs(_dn(cur), j.extends + ".json")) ? _rs(_dn(cur), j.extends + ".json") : null)) : null;
2451
+ }
2452
+ return { baseUrl: merged.baseUrl ?? _dn(tc), paths: merged.paths ?? {} };
2453
+ }
2454
+ const parent = _dn(dir);
2455
+ if (parent === dir)
2456
+ break;
2457
+ dir = parent;
2458
+ }
2459
+ return null;
2460
+ }
2461
+ /** Workspace member name → package dir, from the root package.json workspaces globs. */
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 */ }
2473
+ const out = new Map();
2474
+ const rootPkg = readJsonSafe(_jn(repoRoot, "package.json"));
2475
+ const ws = rootPkg?.workspaces;
2476
+ const npmGlobs = Array.isArray(ws) ? ws : Array.isArray(ws?.packages) ? ws.packages : [];
2477
+ const globs = [...npmGlobs, ...pnpmGlobs];
2478
+ const dirs = [];
2479
+ for (const g of globs) {
2480
+ if (g.endsWith("/*")) {
2481
+ const base = _jn(repoRoot, g.slice(0, -2));
2482
+ try {
2483
+ for (const d of _rd(base))
2484
+ dirs.push(_jn(base, d));
2485
+ }
2486
+ catch { /* absent */ }
2487
+ }
2488
+ else
2489
+ dirs.push(_jn(repoRoot, g));
2490
+ }
2491
+ for (const d of dirs) {
2492
+ const pj = readJsonSafe(_jn(d, "package.json"));
2493
+ const name = pj?.name;
2494
+ if (typeof name === "string")
2495
+ out.set(name, d);
2496
+ }
2497
+ return out;
2498
+ }
2499
+ /** Resolve one import specifier from a test file to an in-repo file, or null. */
2500
+ export function resolveSpecifier(spec, fromFile, repoRoot, wsPkgs) {
2501
+ if (spec.startsWith("."))
2502
+ return tryFile(_rs(_dn(fromFile), spec));
2503
+ const tc = tsconfigPathsFor(fromFile, repoRoot);
2504
+ if (tc) {
2505
+ for (const [pat, targets] of Object.entries(tc.paths)) {
2506
+ const star = pat.indexOf("*");
2507
+ const matches = star >= 0 ? spec.startsWith(pat.slice(0, star)) && spec.endsWith(pat.slice(star + 1)) : spec === pat;
2508
+ if (!matches)
2509
+ continue;
2510
+ const wild = star >= 0 ? spec.slice(pat.slice(0, star).length, spec.length - pat.slice(star + 1).length) : "";
2511
+ for (const t of targets) {
2512
+ const hit = tryFile(_rs(tc.baseUrl, t.replace("*", wild)));
2513
+ if (hit)
2514
+ return hit;
2515
+ }
2516
+ }
2517
+ if (!spec.startsWith("@") && !spec.includes(":")) {
2518
+ const viaBase = tryFile(_rs(tc.baseUrl, spec));
2519
+ if (viaBase)
2520
+ return viaBase;
2521
+ }
2522
+ }
2523
+ // workspace package name (longest-prefix match), with built→src subpath mapping
2524
+ for (const [name, dir] of wsPkgs) {
2525
+ if (spec !== name && !spec.startsWith(name + "/"))
2526
+ continue;
2527
+ const sub = spec === name ? "" : spec.slice(name.length + 1);
2528
+ const candidates = sub
2529
+ ? [sub, sub.replace(BUILT_DIRS, "/src/"), "src/" + sub.replace(/^(lib|dist|build|out)\//, "")]
2530
+ : ["src/index", "index"];
2531
+ for (const c of candidates) {
2532
+ const hit = tryFile(_jn(dir, c));
2533
+ if (hit)
2534
+ return hit;
2535
+ }
2536
+ }
2537
+ return null;
2538
+ }
2379
2539
  /**
2380
2540
  * Run the confirmer over every candidate (test -> impl) pair (the resolver-derived
2381
2541
  * MAY_RELATE_TO links). For each denominator-eligible exported symbol of the impl
package/dist/local/cli.js CHANGED
@@ -22,6 +22,7 @@ import { WORKSPACE_DIR } from "./workspace.js";
22
22
  import { summarizeCorpusScope } from "./corpusScope.js";
23
23
  import { jobJsonPath, jobLogPath, listJobs, newJobId, readJobRecord, updateJobRecord, writeJobRecord } from "./jobs/jobStore.js";
24
24
  import { runGenerateJob } from "./jobs/runner.js";
25
+ import { ORANGEPRO_VERSION } from "./version.js";
25
26
  function out(line = "") {
26
27
  process.stdout.write(line + "\n");
27
28
  }
@@ -157,13 +158,21 @@ function bar(value) {
157
158
  }
158
159
  async function main() {
159
160
  const argv = process.argv.slice(2);
161
+ if (argv.length === 1 && (argv[0] === "--version" || argv[0] === "-v" || argv[0] === "version")) {
162
+ out(ORANGEPRO_VERSION);
163
+ return 0;
164
+ }
160
165
  const [rawCommand, ...rawRest] = argv;
161
166
  const command = !rawCommand || rawCommand.startsWith("--") ? "start" : rawCommand;
162
167
  const rest = !rawCommand || rawCommand.startsWith("--") ? argv : rawRest;
163
168
  const { positionals, flags } = parseArgs(rest);
164
169
  const json = asBool(flags.json, false);
165
170
  const cwd = process.cwd();
166
- if (command === "help" || flags.help) {
171
+ if (command === "help" || flags.help || flags.version) {
172
+ if (flags.version) {
173
+ out(ORANGEPRO_VERSION);
174
+ return 0;
175
+ }
167
176
  out(HELP);
168
177
  return 0;
169
178
  }
@@ -11,7 +11,7 @@ const MAX_REQUIREMENTS = 60;
11
11
  * REQ-md-the-author-should-do-the-following-if-applicable and surfaced as the
12
12
  * report's top suggested next action. Product docs (README, docs/) still count.
13
13
  */
14
- const GOVERNANCE_MD_RE = /(^|\/)\.github\/|(^|\/)(CONTRIBUTING|CODE_OF_CONDUCT|PULL_REQUEST_TEMPLATE|ISSUE_TEMPLATE|SECURITY|SUPPORT|CHANGELOG|LICENSE|GOVERNANCE|MAINTAINERS|CODEOWNERS|AUTHORS)[^\/]*$|(^|\/)\.changeset\/|\/templates?\/|(^|\/)(AGENTS|CLAUDE|GEMINI|COPILOT)\.md$|(^|\/)\.cursor(rules)?\//i;
14
+ const GOVERNANCE_MD_RE = /(^|\/)\.github\/|(^|\/)(CONTRIBUTING|CODE_OF_CONDUCT|PULL_REQUEST_TEMPLATE|ISSUE_TEMPLATE|SECURITY|SUPPORT|CHANGELOG|LICENSE|GOVERNANCE|MAINTAINERS|CODEOWNERS|AUTHORS)[^\/]*$|(^|\/)\.changeset\/|(^|\/)(AGENTS|CLAUDE|GEMINI|COPILOT)\.md$|(^|\/)\.cursor(rules)?\//i;
15
15
  /** Words that suggest a heading describes a requirement/feature behavior. */
16
16
  const REQUIREMENT_HINTS = [
17
17
  "requirement",
@@ -103,16 +103,13 @@ export function rankEntries(graph, entries, adjacency) {
103
103
  gap.id,
104
104
  gap.risk_score
105
105
  ]));
106
- // Endpoint-anchored flows first. An Endpoint entry IS the definition of a
107
- // user-triggerable behavior (June 27 agreement); orphan call-graph roots are
108
- // useful but must never crowd endpoints out of the global cap — on Twenty,
109
- // saturated risk ties let ~25 internal orphan methods consume all 500 flow
110
- // slots while every HTTP/GraphQL entry point went unrendered.
106
+ // One score space across every topology. Endpoint fallback weight keeps real
107
+ // external triggers prominent, while a materially riskier internal/library
108
+ // behavior can still outrank them. A hard Endpoint/Behavior partition made
109
+ // server-shaped repos look good but hid critical roots in mixed/library repos.
111
110
  const score = (e) => Math.max(riskScores.get(e.start) ?? 0, fallbackScore(e, adjacency));
112
- const byScore = (a, b) => score(b) - score(a) || a.external_id.localeCompare(b.external_id) || a.start.localeCompare(b.start);
113
- const endpoints = entries.filter((e) => e.kind === "Endpoint").sort(byScore);
114
- const behaviors = entries.filter((e) => e.kind !== "Endpoint").sort(byScore);
115
- return [...endpoints, ...behaviors];
111
+ const byScore = (a, b) => score(b) - score(a) || (a.kind === b.kind ? 0 : a.kind === "Endpoint" ? -1 : 1) || a.external_id.localeCompare(b.external_id) || a.start.localeCompare(b.start);
112
+ return [...entries].sort(byScore);
116
113
  }
117
114
  function prunePrefixSubsumed(flows) {
118
115
  const sorted = [...flows].sort((a, b) => b.path.length - a.path.length || a.id.localeCompare(b.id));
@@ -552,7 +552,7 @@ export function gatherContext(graph, behavior, framework, fileReader) {
552
552
  acceptance_criteria: acceptance,
553
553
  workflow_steps: workflow,
554
554
  framework,
555
- test_layer: inferLayer(behavior, framework, graph),
555
+ test_layer: inferTestLayer(behavior, framework, graph),
556
556
  code_context: dedupe(codeContext),
557
557
  source_excerpts: excerpts,
558
558
  weak_context: dedupe(weakContext),
@@ -602,7 +602,7 @@ function flowChainFor(graph, behavior) {
602
602
  };
603
603
  });
604
604
  }
605
- function inferLayer(behavior, framework, graph) {
605
+ export function inferTestLayer(behavior, framework, graph) {
606
606
  const fw = framework.toLowerCase();
607
607
  if (fw.includes("playwright") || fw.includes("cypress"))
608
608
  return "e2e";
@@ -613,22 +613,40 @@ function inferLayer(behavior, framework, graph) {
613
613
  const hint = String(behavior.properties.test_layer ?? "");
614
614
  if (hint)
615
615
  return hint;
616
- // Graph-aware default (hop-count methodology): a behavior that an endpoint
617
- // implements, or that participates in a multi-step call chain, is an
618
- // INTEGRATION target the code→flows→behaviors journey is the product;
619
- // "unit" is only for genuinely 0-hop leaf functions. The old blanket
620
- // "unit" default stamped every vitest/jest repo unit-first.
616
+ // Infer a layer only from evidence that actually distinguishes it. A CALLS
617
+ // edge alone says nothing about the intended test boundary: libraries and
618
+ // ordinary unit subjects call helpers too. External handlers are API targets;
619
+ // they become integration targets only when their reachable path crosses a
620
+ // source-file boundary. Ambiguous cases stay unknown instead of being tuned
621
+ // to a server application's topology.
621
622
  if (graph) {
622
623
  const id = behavior.external_id;
623
624
  const isEntryHandler = graph.edges.some((e) => e.relationship_type === "IMPLEMENTED_IN" && e.to_external_id === id);
625
+ const fileById = new Map(graph.nodes
626
+ .filter((node) => node.kind === "CodeSymbol")
627
+ .map((node) => [node.external_id, String(node.properties.file ?? "")]));
628
+ const behaviorFile = fileById.get(id) ?? "";
629
+ const crossesFile = (from, to) => {
630
+ const fromFile = fileById.get(from) ?? "";
631
+ const toFile = fileById.get(to) ?? "";
632
+ return fromFile !== "" && toFile !== "" && fromFile !== toFile;
633
+ };
634
+ const directCrossBoundary = graph.edges.some((e) => e.relationship_type === "CALLS" && e.from_external_id === id && crossesFile(e.from_external_id, e.to_external_id));
635
+ const triggeredCrossBoundary = (graph.analysis?.flows?.flows ?? []).some((flow) => {
636
+ if (flow.entry_point.kind !== "Endpoint")
637
+ return false;
638
+ const chain = flow.hops.length > 0 ? [flow.hops[0].from, ...flow.hops.map((hop) => hop.to)] : [];
639
+ if (!chain.includes(id))
640
+ return false;
641
+ const files = new Set(chain.map((symbolId) => fileById.get(symbolId) ?? "").filter(Boolean));
642
+ return files.size > 1;
643
+ });
624
644
  if (isEntryHandler)
625
- return "integration";
626
- const inChain = graph.edges.some((e) => e.relationship_type === "CALLS" && (e.from_external_id === id || e.to_external_id === id)) ||
627
- (graph.analysis?.flows?.flows ?? []).some((f) => f.entry_point.external_id === id || f.hops.some((h) => h.from === id || h.to === id));
628
- if (inChain)
645
+ return directCrossBoundary || triggeredCrossBoundary ? "integration" : "api";
646
+ if (behaviorFile && triggeredCrossBoundary)
629
647
  return "integration";
630
648
  }
631
- return "unit";
649
+ return "unknown";
632
650
  }
633
651
  function tooThin(ctx) {
634
652
  const needed = [];
@@ -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.",
@@ -93,7 +93,7 @@ function gitFirstCommitBatch(root, files) {
93
93
  }
94
94
  return out;
95
95
  }
96
- function isEntryPoint(node) {
96
+ export function isEntryPoint(node) {
97
97
  const file = symbolFile(node);
98
98
  const title = symbolTitle(node);
99
99
  if (API_HANDLER_NAME_RE.test(title) && /(^|\/)api(s)?\//i.test(file))
@@ -139,8 +139,8 @@ function deriveRouteWeight(node) {
139
139
  function deriveDataSensitivity(node) {
140
140
  const text = `${node.external_id} ${symbolFile(node)} ${symbolTitle(node)}`.toLowerCase();
141
141
  const tiers = [
142
- [/payment|stripe|capture|refund|charge|billing/, 10],
143
- [/auth|token|session|password|credential|jwt|oauth/, 9],
142
+ [/payment|stripe|refund|charge(?!r)|billing|payout|chargeback/, 10],
143
+ [/auth(?!or\b)|token(?!iz)|session|password|credential|jwt|oauth/, 9],
144
144
  [/order|cart|checkout|invoice|transaction/, 7],
145
145
  [/customer|user|account|profile|pii|gdpr/, 6],
146
146
  [/notification|email|sms|webhook|push/, 3]
@@ -389,7 +389,10 @@ export function rankRiskGaps(graph, opts = {}) {
389
389
  const i = Math.round(iExact);
390
390
  const d = rawScores[idx].d;
391
391
  const detectionTier = detectionFor(s.external_id);
392
- const score = Math.round(pExact * iExact * d * 10) / 10;
392
+ let score = Math.round(pExact * iExact * d * 10) / 10;
393
+ const disconnected = (incoming.get(s.external_id) ?? 0) === 0 && fan_out === 0;
394
+ if (disconnected)
395
+ score = Math.round(score * 0.25 * 10) / 10;
393
396
  const reasons = [
394
397
  `ORS ${score} ≈ P${p} × I${i} × D${d}`,
395
398
  `${incoming_refs} incoming structural reference${incoming_refs === 1 ? "" : "s"} (method-attributed)`,
@@ -400,6 +403,8 @@ export function rankRiskGaps(graph, opts = {}) {
400
403
  reasons.push("near an API/route/handler entry point");
401
404
  if (is_new_code)
402
405
  reasons.push("new code (< 30 days)");
406
+ if (disconnected)
407
+ reasons.push("no callers and no callees — structurally disconnected, score dampened");
403
408
  if (detectionTier === "candidate")
404
409
  reasons.push("lexical candidate test match only — unconfirmed");
405
410
  return {
@@ -424,15 +429,25 @@ export function rankRiskGaps(graph, opts = {}) {
424
429
  };
425
430
  })
426
431
  .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));
427
- // Portfolio diversity: cap how many gaps a single file contributes to the
428
- // surfaced list, then backfill with the remaining highest scores if short.
429
- const maxPerFile = Math.max(1, opts.maxPerFile ?? 3);
432
+ // The default API is the true global ranking because reports call this list
433
+ // "top risks". Callers may explicitly request a diversified portfolio, but
434
+ // that presentation policy must never silently redefine rank.
435
+ if (opts.maxPerFile === undefined)
436
+ return ranked.slice(0, limit);
437
+ const maxPerFile = Math.max(1, opts.maxPerFile);
430
438
  const perFile = new Map();
439
+ // Multi-program repos flood identical titles (76 x main) across files; the
440
+ // per-FILE cap cannot see it. Same diversity principle, second axis.
441
+ const maxPerTitle = 2;
442
+ const perTitle = new Map();
431
443
  const surfaced = [];
432
444
  const overflow = [];
433
445
  for (const gap of ranked) {
434
446
  const used = perFile.get(gap.file) ?? 0;
435
- if (used < maxPerFile) {
447
+ const tKey = (gap.title || "").split("(")[0].trim();
448
+ const tUsed = perTitle.get(tKey) ?? 0;
449
+ if (used < maxPerFile && tUsed < maxPerTitle) {
450
+ perTitle.set(tKey, tUsed + 1);
436
451
  perFile.set(gap.file, used + 1);
437
452
  surfaced.push(gap);
438
453
  }
@@ -1,6 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { buildRtm } from "../rtm.js";
3
- import { rankRiskGaps } from "../score/risk.js";
3
+ import { isEntryPoint, rankRiskGaps } from "../score/risk.js";
4
4
  import { PROOF_BLOCKER_GUIDE } from "../proofDoctor.js";
5
5
  /** Short human phrase per R-1 needs_setup category, for the "blocked because: …" panel copy. */
6
6
  const BLOCK_CATEGORY_LABEL = {
@@ -285,7 +285,9 @@ function flows(graph, rows, risks) {
285
285
  const proven = new Set(rows.filter((r) => r.evidence_tier === "proven").map((r) => r.behavior_id));
286
286
  const riskById = new Map(risks.map((r) => [r.id, r]));
287
287
  return (graph.analysis?.flows?.flows ?? []).map((flow) => {
288
- const trigger = endpointTrigger(nodesById.get(flow.entry_point.external_id));
288
+ const rootNode = nodesById.get(flow.entry_point.external_id);
289
+ const trigger = endpointTrigger(rootNode);
290
+ const root_entry = rootNode ? isEntryPoint(rootNode) : false;
289
291
  const proof = proven.has(flow.entry_point.external_id) || proven.has(flow.hops[0]?.from ?? "") ? "proven" : "none";
290
292
  const steps = [
291
293
  {
@@ -306,6 +308,7 @@ function flows(graph, rows, risks) {
306
308
  return {
307
309
  title: flow.entry_point.title || flow.entry_point.external_id,
308
310
  trigger,
311
+ root_entry,
309
312
  risk: risk ? riskBucket(risk.risk_score, maxRiskScore) : null,
310
313
  proof,
311
314
  services,
@@ -406,6 +409,12 @@ function fmtRefs(n) {
406
409
  return "<1";
407
410
  return String(Math.round(n));
408
411
  }
412
+ function displayTitle(title, file) {
413
+ if (title.includes("."))
414
+ return title;
415
+ const pkg = file && file.includes("/") ? file.split("/").slice(-2, -1)[0] : "";
416
+ return pkg ? `${pkg}.${title}` : title;
417
+ }
409
418
  /** Deterministic 1–2 line behavior context from graph facts only — no LLM.
410
419
  * Sensitivity label mirrors deriveDataSensitivity's tiers. */
411
420
  function riskContext(risk) {
@@ -455,17 +464,37 @@ export function computeReportDelta(prev, cur) {
455
464
  export function reportBaselineOf(cur, ts) {
456
465
  return { ts, summary: cur.summary, riskPaths: cur.risks.map((r) => r.path), generatedTotal: cur.generatedTotal };
457
466
  }
458
- const LANE_OF = {
459
- MUTATION: { id: "graphql", label: "GraphQL" },
460
- QUERY: { id: "graphql", label: "GraphQL" },
461
- SUBSCRIPTION: { id: "graphql", label: "GraphQL" },
462
- GET: { id: "http", label: "HTTP" },
463
- POST: { id: "http", label: "HTTP" },
464
- PUT: { id: "http", label: "HTTP" },
465
- PATCH: { id: "http", label: "HTTP" },
466
- DELETE: { id: "http", label: "HTTP" },
467
- JOB: { id: "job", label: "Jobs" }
468
- };
467
+ const HTTP_TRIGGER_VERBS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD", "ALL"]);
468
+ const GRAPHQL_TRIGGER_VERBS = new Set(["MUTATION", "QUERY", "SUBSCRIPTION"]);
469
+ /** Map every trigger into a stable lane. Known protocol families get friendly
470
+ * labels; unknown framework adapters still receive a deterministic lane rather
471
+ * than being silently deleted from the system map. */
472
+ export function laneForTrigger(trigger) {
473
+ const verb = (trigger.verb || "OTHER").trim().toUpperCase();
474
+ const triggerPath = (trigger.path || "").toLowerCase();
475
+ if (HTTP_TRIGGER_VERBS.has(verb) || triggerPath.startsWith("http:"))
476
+ return { id: "http", label: "HTTP", priority: 20 };
477
+ if (GRAPHQL_TRIGGER_VERBS.has(verb) || triggerPath.startsWith("graphql:"))
478
+ return { id: "graphql", label: "GraphQL", priority: 10 };
479
+ if (["JOB", "QUEUE", "PROCESS", "WORKER"].includes(verb) || triggerPath.startsWith("queue:"))
480
+ return { id: "job", label: "Jobs", priority: 30 };
481
+ if (["SCHEDULE", "CRON", "TIMER"].includes(verb) || triggerPath.startsWith("schedule:") || triggerPath.startsWith("cron:"))
482
+ return { id: "schedule", label: "Scheduled", priority: 40 };
483
+ if (["EVENT", "MESSAGE", "CONSUME", "CONSUMER", "SUBSCRIBE"].includes(verb) || triggerPath.startsWith("event:"))
484
+ return { id: "event", label: "Events", priority: 50 };
485
+ if (["COMMAND", "CLI"].includes(verb) || triggerPath.startsWith("cli:"))
486
+ return { id: "cli", label: "CLI", priority: 60 };
487
+ if (["RPC", "GRPC"].includes(verb) || triggerPath.startsWith("rpc:") || triggerPath.startsWith("grpc:"))
488
+ return { id: "rpc", label: "RPC", priority: 70 };
489
+ const id = verb.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "other";
490
+ const label = verb
491
+ .toLowerCase()
492
+ .split(/[^a-z0-9]+/)
493
+ .filter(Boolean)
494
+ .map((part) => part[0].toUpperCase() + part.slice(1))
495
+ .join(" ") || "Other";
496
+ return { id, label, priority: 100 };
497
+ }
469
498
  function ownerOfSig(sig) {
470
499
  const base = sig.includes("#") ? sig.slice(sig.indexOf("#") + 1) : sig;
471
500
  return base.includes(".") ? base.slice(0, base.indexOf(".")) : base;
@@ -477,7 +506,22 @@ export function buildSystemMapModel(data, maxServices = 12) {
477
506
  // on a full-scale repo. A flow's representative service is its most
478
507
  // DISTINCTIVE owner: lowest global frequency, deepest step on ties, with
479
508
  // near-ubiquitous owners eligible only when a flow touches nothing else.
480
- const triggerFlows = data.flows.filter((f) => f.trigger && LANE_OF[(f.trigger.verb || "").toUpperCase()]);
509
+ const laneForFlow = (flow) => {
510
+ if (flow.trigger)
511
+ return laneForTrigger(flow.trigger);
512
+ // A program root without a framework trigger is still a real product entry
513
+ // point (CLI/main). Internal call-graph roots remain excluded.
514
+ return flow.root_entry ? { id: "entry", label: "Entry points", priority: 80 } : null;
515
+ };
516
+ const triggerFlows = data.flows.filter((flow) => laneForFlow(flow) !== null);
517
+ const laneMeta = new Map();
518
+ for (const flow of triggerFlows) {
519
+ const lane = laneForFlow(flow);
520
+ laneMeta.set(lane.id, lane);
521
+ }
522
+ const laneOrder = [...laneMeta.values()]
523
+ .sort((a, b) => a.priority - b.priority || a.label.localeCompare(b.label) || a.id.localeCompare(b.id))
524
+ .map((lane) => lane.id);
481
525
  const ownerFreq = new Map();
482
526
  for (const f of triggerFlows) {
483
527
  const seen = new Set();
@@ -496,7 +540,7 @@ export function buildSystemMapModel(data, maxServices = 12) {
496
540
  const edgeFlows = new Map();
497
541
  const svcLaneFlows = new Map();
498
542
  for (const f of triggerFlows) {
499
- const lane = LANE_OF[(f.trigger.verb || "").toUpperCase()];
543
+ const lane = laneForFlow(f);
500
544
  const steps = f.steps ?? [];
501
545
  let svc = "";
502
546
  let bestFreq = Infinity;
@@ -538,18 +582,18 @@ export function buildSystemMapModel(data, maxServices = 12) {
538
582
  best = l;
539
583
  bestN = n;
540
584
  }
541
- return ["graphql", "http", "job"].indexOf(best);
585
+ return laneOrder.indexOf(best);
542
586
  };
543
587
  const byTraffic = [...svcFlows.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
544
588
  const chosen = new Set();
545
589
  // 1) Every lane is guaranteed its top 2 services — a lane with 56 flows must
546
590
  // never render edge-less just because its traffic is spread thin.
547
- for (const laneId of ["graphql", "http", "job"]) {
591
+ for (const laneId of laneOrder) {
548
592
  let taken = 0;
549
593
  for (const [svc] of byTraffic) {
550
594
  if (taken >= 2)
551
595
  break;
552
- if (laneRank(svc) === ["graphql", "http", "job"].indexOf(laneId) && !chosen.has(svc)) {
596
+ if (laneRank(svc) === laneOrder.indexOf(laneId) && !chosen.has(svc)) {
553
597
  chosen.add(svc);
554
598
  taken++;
555
599
  }
@@ -606,7 +650,6 @@ export function buildSystemMapModel(data, maxServices = 12) {
606
650
  e.critical = true;
607
651
  riskBySvc.set(svc, e);
608
652
  }
609
- const laneOrder = ["graphql", "http", "job"];
610
653
  // Conservation: edges drawn per lane must sum to the lane's stated count.
611
654
  // Everything below the cut aggregates into one dashed "+N more services"
612
655
  // node per lane — 51 job flows must never silently vanish.
@@ -644,7 +687,7 @@ export function buildSystemMapModel(data, maxServices = 12) {
644
687
  return {
645
688
  lanes: laneOrder
646
689
  .filter((id) => laneFlows.has(id))
647
- .map((id) => ({ id, label: id === "graphql" ? "GraphQL" : id === "http" ? "HTTP" : "Jobs", flows: laneFlows.get(id) ?? 0 })),
690
+ .map((id) => ({ id, label: laneMeta.get(id)?.label ?? id, flows: laneFlows.get(id) ?? 0 })),
648
691
  services: [
649
692
  ...top.map(([label, flows]) => ({
650
693
  id: label,
@@ -703,13 +746,16 @@ function riskTodo(risk, verb, path, generatedTests) {
703
746
  const call = verb !== "BEHAVIOR"
704
747
  ? `issues ${verb} ${path}`
705
748
  : risk.entry_point
706
- ? `invokes ${risk.title} through its entry point`
707
- : `calls ${risk.title} directly`;
708
- const sens = (risk.data_sensitivity ?? 1) >= 9
709
- ? " Include a negative case: invalid or expired credentials must fail closed."
710
- : (risk.data_sensitivity ?? 1) >= 7
711
- ? " Include a failure case: a rejected transaction must leave no partial state."
712
- : "";
749
+ ? `invokes ${displayTitle(risk.title, risk.file)} through its entry point`
750
+ : `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
755
+ ? " Include a negative case: invalid or expired credentials must fail closed."
756
+ : s >= 7
757
+ ? " Include a failure case: a rejected transaction must leave no partial state."
758
+ : "";
713
759
  if (risk.integration_signal === "candidate") {
714
760
  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}`;
715
761
  }
@@ -722,6 +768,23 @@ function riskRows(risks, graph) {
722
768
  for (const r of risks)
723
769
  if (!firstRowForFile.has(r.file))
724
770
  firstRowForFile.set(r.file, r.id);
771
+ // Ambiguity-qualified display: identical titles from DIFFERENT files
772
+ // (multi-program repos: 76 x main) get their top-level dir as a prefix.
773
+ // Purely display; single-file titles render unchanged everywhere else.
774
+ const titleFiles = new Map();
775
+ for (const r of risks) {
776
+ const t = (r.title || "").split("(")[0].trim();
777
+ if (!titleFiles.has(t))
778
+ titleFiles.set(t, new Set());
779
+ titleFiles.get(t).add(r.file);
780
+ }
781
+ const qualify = (risk, path) => {
782
+ const t = (risk.title || "").split("(")[0].trim();
783
+ if ((titleFiles.get(t)?.size ?? 0) <= 1)
784
+ return path;
785
+ const top = (risk.file || "").split("/").filter(Boolean)[0] ?? "";
786
+ return top ? `${top}: ${path}` : path;
787
+ };
725
788
  return risks.map((risk, idx) => {
726
789
  const methodMatch = risk.title.match(/^(GET|POST|PUT|PATCH|DELETE)\s+(.+)$/i);
727
790
  const tags = [];
@@ -736,7 +799,7 @@ function riskRows(risks, graph) {
736
799
  ...(() => {
737
800
  const generatedTests = riskGeneratedTests(graph, risk, riskIds, firstRowForFile.get(risk.file) === risk.id);
738
801
  const verb = methodMatch?.[1]?.toUpperCase() ?? "BEHAVIOR";
739
- const path = methodMatch?.[2] ?? risk.title;
802
+ const path = qualify(risk, methodMatch?.[2] ?? displayTitle(risk.title, risk.file));
740
803
  const generatedCategories = [...new Set([
741
804
  ...generatedTests.map((t) => (t.bucket ? BUCKET_TO_CONCERN[t.bucket] : undefined)),
742
805
  // An integration/api/e2e-layer draft targets integration_flow. This
@@ -770,7 +833,7 @@ export function buildBehaviorReportData(graph, ledger, opts = {}) {
770
833
  const flowIds = flowSymbolIds(graph);
771
834
  const summary = summaryFromRows(rows, flowIds);
772
835
  const repoRoot = opts.repoRoot ?? graph.workspace.root;
773
- const riskGaps = rankRiskGaps(graph, { repoRoot, limit: opts.riskLimit ?? 20 });
836
+ const riskGaps = rankRiskGaps(graph, { repoRoot, limit: opts.riskLimit ?? 20, maxPerFile: 3 });
774
837
  const lists = behaviorLists(rows, flowIds);
775
838
  const risks = riskRows(riskGaps, graph);
776
839
  const sortedBehaviors = [...lists.behaviors].sort((a, b) => tierRank(a) - tierRank(b));
@@ -381,7 +381,8 @@ const D=window.DATA,$=(s)=>document.querySelector(s);
381
381
  var laneY={},svcY={};
382
382
  M.lanes.forEach(function(l,i){laneY[l.id]=padT+i*rowH+(H-2*padT-M.lanes.length*rowH)/2+rowH/2;});
383
383
  M.services.forEach(function(sv,i){svcY[sv.id]=padT+i*rowH+rowH/2;});
384
- var laneColor={graphql:"var(--purple,#a78bfa)",http:"var(--blue)",job:"var(--orange)"};
384
+ var lanePalette=["var(--purple,#a78bfa)","var(--blue)","var(--orange)","var(--green)","var(--red)","#22d3ee","#f472b6","var(--muted)"];
385
+ var laneColor={};M.lanes.forEach(function(l,i){laneColor[l.id]=lanePalette[i%lanePalette.length];});
385
386
  var tierColor=function(t){var tot=t.proven+t.assoc+t.candidate+t.none||1;
386
387
  if(t.proven/tot>=0.5)return "var(--green)";
387
388
  if((t.proven+t.assoc)/tot>=0.5)return "var(--blue)";
@@ -61,20 +61,14 @@ function codeAreaOf(sourceRefOrPath) {
61
61
  return parentDir(parts);
62
62
  }
63
63
  if (language === "python") {
64
- if (["src", "app", "mealie"].includes(parts[0]) && parts.length > 2)
64
+ if (["src", "app"].includes(parts[0]) && parts.length > 2)
65
65
  return parts.slice(1, 3).join("/");
66
66
  return parts.length > 2 ? parts.slice(0, 2).join("/") : parentDir(parts);
67
67
  }
68
68
  if (language === "go") {
69
- if (parts[0] === "server" && parts[1] === "channels" && parts[2])
70
- return `server/${parts[2]}`;
71
69
  return parts.length > 2 ? parts.slice(0, 2).join("/") : parentDir(parts);
72
70
  }
73
71
  if (language === "typescript" || language === "javascript") {
74
- if (parts[0] === "webapp" && parts[1] === "channels" && parts[2] === "src" && parts[3])
75
- return `webapp/${parts[3]}`;
76
- if (parts[0] === "frontend" && parts[1] === "app" && parts[2])
77
- return `frontend/${parts[2]}`;
78
72
  return parts.length > 2 ? parts.slice(0, 2).join("/") : parentDir(parts);
79
73
  }
80
74
  return parts.length > 2 ? parts.slice(0, 2).join("/") : parentDir(parts);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orangepro/orangepro-mcp",
3
- "version": "0.2.7",
3
+ "version": "0.2.9",
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",