@orangepro/orangepro-mcp 0.2.7 → 0.2.8

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.
@@ -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,
@@ -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 = [];
@@ -2376,6 +2376,119 @@ export function confirmPair(ctx, testAbsPath, implAbsPath, behaviorName) {
2376
2376
  reason: "behavior not evaluated"
2377
2377
  });
2378
2378
  }
2379
+ // Workspace/alias resolution is useful for candidate linkage in TS monorepos,
2380
+ // but resolution and a call reference are not assertion evidence. Keep this
2381
+ // resolver separate from the hard-proof confirmer below.
2382
+ import { readFileSync as _rf, existsSync as _ex, readdirSync as _rd } from "node:fs";
2383
+ import { dirname as _dn, join as _jn, resolve as _rs } from "node:path";
2384
+ const EXT_TRIES = ["", ".ts", ".tsx", ".js", ".jsx", "/index.ts", "/index.tsx", "/index.js"];
2385
+ const BUILT_DIRS = /\/(lib|dist|build|out)(\/|$)/;
2386
+ function tryFile(base) {
2387
+ for (const e of EXT_TRIES)
2388
+ if (_ex(base + e))
2389
+ return base + e;
2390
+ return null;
2391
+ }
2392
+ function readJsonSafe(p) {
2393
+ try {
2394
+ return JSON.parse(_rf(p, "utf8"));
2395
+ }
2396
+ catch {
2397
+ return null;
2398
+ }
2399
+ }
2400
+ /** Nearest tsconfig paths/baseUrl for a file, walking extends chains (depth-capped). */
2401
+ function tsconfigPathsFor(fileAbs, stopDir) {
2402
+ let dir = _dn(fileAbs);
2403
+ for (let i = 0; i < 12 && dir.startsWith(stopDir); i++) {
2404
+ const tc = _jn(dir, "tsconfig.json");
2405
+ if (_ex(tc)) {
2406
+ let merged = {};
2407
+ let cur = tc;
2408
+ for (let d = 0; d < 6 && cur; d++) {
2409
+ const j = readJsonSafe(cur);
2410
+ if (!j)
2411
+ break;
2412
+ const co = (j.compilerOptions ?? {});
2413
+ merged = { baseUrl: merged.baseUrl ?? (co.baseUrl ? _rs(_dn(cur), co.baseUrl) : undefined), paths: { ...co.paths, ...merged.paths } };
2414
+ 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;
2415
+ }
2416
+ return { baseUrl: merged.baseUrl ?? _dn(tc), paths: merged.paths ?? {} };
2417
+ }
2418
+ const parent = _dn(dir);
2419
+ if (parent === dir)
2420
+ break;
2421
+ dir = parent;
2422
+ }
2423
+ return null;
2424
+ }
2425
+ /** Workspace member name → package dir, from the root package.json workspaces globs. */
2426
+ function workspacePackages(repoRoot) {
2427
+ const out = new Map();
2428
+ const rootPkg = readJsonSafe(_jn(repoRoot, "package.json"));
2429
+ const ws = rootPkg?.workspaces;
2430
+ const globs = Array.isArray(ws) ? ws : Array.isArray(ws?.packages) ? ws.packages : [];
2431
+ const dirs = [];
2432
+ for (const g of globs) {
2433
+ if (g.endsWith("/*")) {
2434
+ const base = _jn(repoRoot, g.slice(0, -2));
2435
+ try {
2436
+ for (const d of _rd(base))
2437
+ dirs.push(_jn(base, d));
2438
+ }
2439
+ catch { /* absent */ }
2440
+ }
2441
+ else
2442
+ dirs.push(_jn(repoRoot, g));
2443
+ }
2444
+ for (const d of dirs) {
2445
+ const pj = readJsonSafe(_jn(d, "package.json"));
2446
+ const name = pj?.name;
2447
+ if (typeof name === "string")
2448
+ out.set(name, d);
2449
+ }
2450
+ return out;
2451
+ }
2452
+ /** Resolve one import specifier from a test file to an in-repo file, or null. */
2453
+ export function resolveSpecifier(spec, fromFile, repoRoot, wsPkgs) {
2454
+ if (spec.startsWith("."))
2455
+ return tryFile(_rs(_dn(fromFile), spec));
2456
+ const tc = tsconfigPathsFor(fromFile, repoRoot);
2457
+ if (tc) {
2458
+ for (const [pat, targets] of Object.entries(tc.paths)) {
2459
+ const star = pat.indexOf("*");
2460
+ const matches = star >= 0 ? spec.startsWith(pat.slice(0, star)) && spec.endsWith(pat.slice(star + 1)) : spec === pat;
2461
+ if (!matches)
2462
+ continue;
2463
+ const wild = star >= 0 ? spec.slice(pat.slice(0, star).length, spec.length - pat.slice(star + 1).length) : "";
2464
+ for (const t of targets) {
2465
+ const hit = tryFile(_rs(tc.baseUrl, t.replace("*", wild)));
2466
+ if (hit)
2467
+ return hit;
2468
+ }
2469
+ }
2470
+ if (!spec.startsWith("@") && !spec.includes(":")) {
2471
+ const viaBase = tryFile(_rs(tc.baseUrl, spec));
2472
+ if (viaBase)
2473
+ return viaBase;
2474
+ }
2475
+ }
2476
+ // workspace package name (longest-prefix match), with built→src subpath mapping
2477
+ for (const [name, dir] of wsPkgs) {
2478
+ if (spec !== name && !spec.startsWith(name + "/"))
2479
+ continue;
2480
+ const sub = spec === name ? "" : spec.slice(name.length + 1);
2481
+ const candidates = sub
2482
+ ? [sub, sub.replace(BUILT_DIRS, "/src/"), "src/" + sub.replace(/^(lib|dist|build|out)\//, "")]
2483
+ : ["src/index", "index"];
2484
+ for (const c of candidates) {
2485
+ const hit = tryFile(_jn(dir, c));
2486
+ if (hit)
2487
+ return hit;
2488
+ }
2489
+ }
2490
+ return null;
2491
+ }
2379
2492
  /**
2380
2493
  * Run the confirmer over every candidate (test -> impl) pair (the resolver-derived
2381
2494
  * 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 = [];
@@ -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))
@@ -424,9 +424,12 @@ export function rankRiskGaps(graph, opts = {}) {
424
424
  };
425
425
  })
426
426
  .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);
427
+ // The default API is the true global ranking because reports call this list
428
+ // "top risks". Callers may explicitly request a diversified portfolio, but
429
+ // that presentation policy must never silently redefine rank.
430
+ if (opts.maxPerFile === undefined)
431
+ return ranked.slice(0, limit);
432
+ const maxPerFile = Math.max(1, opts.maxPerFile);
430
433
  const perFile = new Map();
431
434
  const surfaced = [];
432
435
  const overflow = [];
@@ -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,
@@ -455,17 +458,37 @@ export function computeReportDelta(prev, cur) {
455
458
  export function reportBaselineOf(cur, ts) {
456
459
  return { ts, summary: cur.summary, riskPaths: cur.risks.map((r) => r.path), generatedTotal: cur.generatedTotal };
457
460
  }
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
- };
461
+ const HTTP_TRIGGER_VERBS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD", "ALL"]);
462
+ const GRAPHQL_TRIGGER_VERBS = new Set(["MUTATION", "QUERY", "SUBSCRIPTION"]);
463
+ /** Map every trigger into a stable lane. Known protocol families get friendly
464
+ * labels; unknown framework adapters still receive a deterministic lane rather
465
+ * than being silently deleted from the system map. */
466
+ export function laneForTrigger(trigger) {
467
+ const verb = (trigger.verb || "OTHER").trim().toUpperCase();
468
+ const triggerPath = (trigger.path || "").toLowerCase();
469
+ if (HTTP_TRIGGER_VERBS.has(verb) || triggerPath.startsWith("http:"))
470
+ return { id: "http", label: "HTTP", priority: 20 };
471
+ if (GRAPHQL_TRIGGER_VERBS.has(verb) || triggerPath.startsWith("graphql:"))
472
+ return { id: "graphql", label: "GraphQL", priority: 10 };
473
+ if (["JOB", "QUEUE", "PROCESS", "WORKER"].includes(verb) || triggerPath.startsWith("queue:"))
474
+ return { id: "job", label: "Jobs", priority: 30 };
475
+ if (["SCHEDULE", "CRON", "TIMER"].includes(verb) || triggerPath.startsWith("schedule:") || triggerPath.startsWith("cron:"))
476
+ return { id: "schedule", label: "Scheduled", priority: 40 };
477
+ if (["EVENT", "MESSAGE", "CONSUME", "CONSUMER", "SUBSCRIBE"].includes(verb) || triggerPath.startsWith("event:"))
478
+ return { id: "event", label: "Events", priority: 50 };
479
+ if (["COMMAND", "CLI"].includes(verb) || triggerPath.startsWith("cli:"))
480
+ return { id: "cli", label: "CLI", priority: 60 };
481
+ if (["RPC", "GRPC"].includes(verb) || triggerPath.startsWith("rpc:") || triggerPath.startsWith("grpc:"))
482
+ return { id: "rpc", label: "RPC", priority: 70 };
483
+ const id = verb.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "other";
484
+ const label = verb
485
+ .toLowerCase()
486
+ .split(/[^a-z0-9]+/)
487
+ .filter(Boolean)
488
+ .map((part) => part[0].toUpperCase() + part.slice(1))
489
+ .join(" ") || "Other";
490
+ return { id, label, priority: 100 };
491
+ }
469
492
  function ownerOfSig(sig) {
470
493
  const base = sig.includes("#") ? sig.slice(sig.indexOf("#") + 1) : sig;
471
494
  return base.includes(".") ? base.slice(0, base.indexOf(".")) : base;
@@ -477,7 +500,22 @@ export function buildSystemMapModel(data, maxServices = 12) {
477
500
  // on a full-scale repo. A flow's representative service is its most
478
501
  // DISTINCTIVE owner: lowest global frequency, deepest step on ties, with
479
502
  // 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()]);
503
+ const laneForFlow = (flow) => {
504
+ if (flow.trigger)
505
+ return laneForTrigger(flow.trigger);
506
+ // A program root without a framework trigger is still a real product entry
507
+ // point (CLI/main). Internal call-graph roots remain excluded.
508
+ return flow.root_entry ? { id: "entry", label: "Entry points", priority: 80 } : null;
509
+ };
510
+ const triggerFlows = data.flows.filter((flow) => laneForFlow(flow) !== null);
511
+ const laneMeta = new Map();
512
+ for (const flow of triggerFlows) {
513
+ const lane = laneForFlow(flow);
514
+ laneMeta.set(lane.id, lane);
515
+ }
516
+ const laneOrder = [...laneMeta.values()]
517
+ .sort((a, b) => a.priority - b.priority || a.label.localeCompare(b.label) || a.id.localeCompare(b.id))
518
+ .map((lane) => lane.id);
481
519
  const ownerFreq = new Map();
482
520
  for (const f of triggerFlows) {
483
521
  const seen = new Set();
@@ -496,7 +534,7 @@ export function buildSystemMapModel(data, maxServices = 12) {
496
534
  const edgeFlows = new Map();
497
535
  const svcLaneFlows = new Map();
498
536
  for (const f of triggerFlows) {
499
- const lane = LANE_OF[(f.trigger.verb || "").toUpperCase()];
537
+ const lane = laneForFlow(f);
500
538
  const steps = f.steps ?? [];
501
539
  let svc = "";
502
540
  let bestFreq = Infinity;
@@ -538,18 +576,18 @@ export function buildSystemMapModel(data, maxServices = 12) {
538
576
  best = l;
539
577
  bestN = n;
540
578
  }
541
- return ["graphql", "http", "job"].indexOf(best);
579
+ return laneOrder.indexOf(best);
542
580
  };
543
581
  const byTraffic = [...svcFlows.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
544
582
  const chosen = new Set();
545
583
  // 1) Every lane is guaranteed its top 2 services — a lane with 56 flows must
546
584
  // never render edge-less just because its traffic is spread thin.
547
- for (const laneId of ["graphql", "http", "job"]) {
585
+ for (const laneId of laneOrder) {
548
586
  let taken = 0;
549
587
  for (const [svc] of byTraffic) {
550
588
  if (taken >= 2)
551
589
  break;
552
- if (laneRank(svc) === ["graphql", "http", "job"].indexOf(laneId) && !chosen.has(svc)) {
590
+ if (laneRank(svc) === laneOrder.indexOf(laneId) && !chosen.has(svc)) {
553
591
  chosen.add(svc);
554
592
  taken++;
555
593
  }
@@ -606,7 +644,6 @@ export function buildSystemMapModel(data, maxServices = 12) {
606
644
  e.critical = true;
607
645
  riskBySvc.set(svc, e);
608
646
  }
609
- const laneOrder = ["graphql", "http", "job"];
610
647
  // Conservation: edges drawn per lane must sum to the lane's stated count.
611
648
  // Everything below the cut aggregates into one dashed "+N more services"
612
649
  // node per lane — 51 job flows must never silently vanish.
@@ -644,7 +681,7 @@ export function buildSystemMapModel(data, maxServices = 12) {
644
681
  return {
645
682
  lanes: laneOrder
646
683
  .filter((id) => laneFlows.has(id))
647
- .map((id) => ({ id, label: id === "graphql" ? "GraphQL" : id === "http" ? "HTTP" : "Jobs", flows: laneFlows.get(id) ?? 0 })),
684
+ .map((id) => ({ id, label: laneMeta.get(id)?.label ?? id, flows: laneFlows.get(id) ?? 0 })),
648
685
  services: [
649
686
  ...top.map(([label, flows]) => ({
650
687
  id: label,
@@ -722,6 +759,23 @@ function riskRows(risks, graph) {
722
759
  for (const r of risks)
723
760
  if (!firstRowForFile.has(r.file))
724
761
  firstRowForFile.set(r.file, r.id);
762
+ // Ambiguity-qualified display: identical titles from DIFFERENT files
763
+ // (multi-program repos: 76 x main) get their top-level dir as a prefix.
764
+ // Purely display; single-file titles render unchanged everywhere else.
765
+ const titleFiles = new Map();
766
+ for (const r of risks) {
767
+ const t = (r.title || "").split("(")[0].trim();
768
+ if (!titleFiles.has(t))
769
+ titleFiles.set(t, new Set());
770
+ titleFiles.get(t).add(r.file);
771
+ }
772
+ const qualify = (risk, path) => {
773
+ const t = (risk.title || "").split("(")[0].trim();
774
+ if ((titleFiles.get(t)?.size ?? 0) <= 1)
775
+ return path;
776
+ const top = (risk.file || "").split("/").filter(Boolean)[0] ?? "";
777
+ return top ? `${top}: ${path}` : path;
778
+ };
725
779
  return risks.map((risk, idx) => {
726
780
  const methodMatch = risk.title.match(/^(GET|POST|PUT|PATCH|DELETE)\s+(.+)$/i);
727
781
  const tags = [];
@@ -736,7 +790,7 @@ function riskRows(risks, graph) {
736
790
  ...(() => {
737
791
  const generatedTests = riskGeneratedTests(graph, risk, riskIds, firstRowForFile.get(risk.file) === risk.id);
738
792
  const verb = methodMatch?.[1]?.toUpperCase() ?? "BEHAVIOR";
739
- const path = methodMatch?.[2] ?? risk.title;
793
+ const path = qualify(risk, methodMatch?.[2] ?? risk.title);
740
794
  const generatedCategories = [...new Set([
741
795
  ...generatedTests.map((t) => (t.bucket ? BUCKET_TO_CONCERN[t.bucket] : undefined)),
742
796
  // An integration/api/e2e-layer draft targets integration_flow. This
@@ -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.8",
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",