@orangepro/orangepro-mcp 0.2.6 → 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.
package/README.md CHANGED
@@ -32,7 +32,7 @@ The command writes:
32
32
  orangepro_generated/ ← contained generated tests; existing source files are untouched
33
33
  ```
34
34
 
35
- The report opens on a **system map** of your repo — entry lanes (GraphQL/HTTP/Jobs) flowing into the services they reach, sized by traffic, colored by evidence tier, risk-ringed — identical on every run. Each rerun shows a **delta banner**: what changed since last run, or "No changes identical graph, identical ranking." Every one of the ~N behaviors gets a plain-English description; every top risk gets a deterministic context line and a state-aware next step.
35
+ The report opens on a **system map** of your repo — entry lanes (GraphQL/HTTP/Jobs) flowing into the services they reach, sized by traffic, colored by evidence tier, risk-ringed — identical on every run. Each completed rerun shows a **delta banner** against the previous completed run. The report discloses when bounded path enumeration prunes additional branch expansions. Every behavior gets a plain-English description; every top risk gets a deterministic context line and a state-aware next step.
36
36
 
37
37
  Run `opro export` when you want a machine-readable evidence pack.
38
38
  <img width="895" height="960" alt="Screenshot 2026-07-08 at 1 18 01 AM" src="https://github.com/user-attachments/assets/73b8a812-2eab-43a1-8aed-4289545e630b" />
@@ -189,7 +189,7 @@ If the environment can't run tests yet (dependencies not installed, runner uncon
189
189
 
190
190
  ## Test categories
191
191
 
192
- Generation is evidence-gated. A category is produced only when the graph has supporting evidence — never padded with generic filler. These are the local generation buckets. The report additionally shows each risk's **applicable testing categories** (contract, boundary limits, integration flow, state lifecycle, failure recovery, …), derived deterministically from graph facts covered categories from real attached tests render normally; the rest render locked, meaning "warranted here, generated on the platform," never "hidden tests exist." Neither taxonomy changes evidence tiers.
192
+ Generation is evidence-gated. A category is produced only when the graph has supporting evidence — never padded with generic filler. These are the local generation buckets. The report additionally shows each risk's **applicable testing categories** (contract, boundary limits, integration flow, state lifecycle, failure recovery, …), derived deterministically from graph facts. Categories with generated drafts are highlighted as drafts; they are not coverage or proof, and remaining applicable categories stay outlined. Neither taxonomy changes evidence tiers.
193
193
 
194
194
  | Category | What it targets |
195
195
  |----------|-----------------|
@@ -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
@@ -794,7 +794,8 @@ export async function autoProve(root, opts, deps) {
794
794
  dynamicProofRunner: deps.dynamicProofRunner,
795
795
  coverageRunner: deps.coverageRunner,
796
796
  analyze: deps.analyze,
797
- aiProvider: deps.aiProvider
797
+ aiProvider: deps.aiProvider,
798
+ suppressBehaviorReportRefresh: deps.suppressBehaviorReportRefresh
798
799
  };
799
800
  const graph = loadGraph(workspacePaths(root).graphPath);
800
801
  const sourceRoot = resolve(graph.workspace.root);
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",
@@ -3,7 +3,7 @@ import { LOCAL_GRAPH_SCHEMA_VERSION } from "../graph/ontology.js";
3
3
  import { rankRiskGaps } from "../score/risk.js";
4
4
  import { stableId } from "../util/ids.js";
5
5
  const DEFAULT_MAX_DEPTH = 8;
6
- const DEFAULT_MAX_FLOWS_PER_ENTRY = 5;
6
+ const DEFAULT_MAX_FLOWS_PER_ENTRY = 20;
7
7
  const DEFAULT_GLOBAL_CAP = 500;
8
8
  const HIGH_ROUTE_RE = /payment|refund|checkout|cart|order|auth|login|token|customer|user|tax|fulfillment|ship/i;
9
9
  const MUTATION_METHOD_RE = /^(POST|PUT|PATCH|DELETE)\b/i;
@@ -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 = [];
@@ -1327,11 +1327,13 @@ export function opProveLoop(root, opts, deps = defaultDeps()) {
1327
1327
  // Best-effort report refresh (exactly like ai-flows --apply): a render failure
1328
1328
  // must never fail a completed proof — the ledger/cert is already written.
1329
1329
  let behaviorCoveragePath;
1330
- try {
1331
- behaviorCoveragePath = opBehaviorCoverageHtml(root, `${WORKSPACE_DIR}/behavior-coverage.html`).behavior_coverage_path;
1332
- }
1333
- catch {
1334
- // ponytail: swallow — refresh is advisory; DynamicProofResult carries no warnings channel.
1330
+ if (!deps.suppressBehaviorReportRefresh) {
1331
+ try {
1332
+ behaviorCoveragePath = opBehaviorCoverageHtml(root, `${WORKSPACE_DIR}/behavior-coverage.html`).behavior_coverage_path;
1333
+ }
1334
+ catch {
1335
+ // ponytail: swallow — refresh is advisory; DynamicProofResult carries no warnings channel.
1336
+ }
1335
1337
  }
1336
1338
  return { ...proof, ...(behaviorCoveragePath ? { behavior_coverage_path: behaviorCoveragePath } : {}) };
1337
1339
  }
@@ -1405,11 +1407,13 @@ export async function opAiFlows(root, opts = {}, deps = defaultDeps()) {
1405
1407
  // Best-effort: a render failure must never fail apply (graph is saved).
1406
1408
  const warnings = [...result.warnings];
1407
1409
  let behaviorCoveragePath;
1408
- try {
1409
- behaviorCoveragePath = opBehaviorCoverageHtml(root, `${WORKSPACE_DIR}/behavior-coverage.html`).behavior_coverage_path;
1410
- }
1411
- catch (error) {
1412
- warnings.push(`behavior coverage view not written: ${error instanceof Error ? error.message : String(error)}`);
1410
+ if (!deps.suppressBehaviorReportRefresh) {
1411
+ try {
1412
+ behaviorCoveragePath = opBehaviorCoverageHtml(root, `${WORKSPACE_DIR}/behavior-coverage.html`).behavior_coverage_path;
1413
+ }
1414
+ catch (error) {
1415
+ warnings.push(`behavior coverage view not written: ${error instanceof Error ? error.message : String(error)}`);
1416
+ }
1413
1417
  }
1414
1418
  return { ...result, warnings, ...(behaviorCoveragePath ? { behavior_coverage_path: behaviorCoveragePath } : {}) };
1415
1419
  }
@@ -1471,11 +1475,7 @@ function writeStartStaticSnapshot(root, baseRef, warnings) {
1471
1475
  let behaviorCoveragePath;
1472
1476
  try {
1473
1477
  reportProgress("artifacts: writing static behavior view (proof still running)", { current: 4, total: 8 });
1474
- behaviorCoveragePath = opBehaviorCoverageHtml(root, `${WORKSPACE_DIR}/behavior-coverage.html`, {
1475
- attempted: 0,
1476
- proven: 0,
1477
- needsSetup: []
1478
- }).behavior_coverage_path;
1478
+ behaviorCoveragePath = opBehaviorCoverageHtml(root, `${WORKSPACE_DIR}/behavior-coverage.html`, { attempted: 0, proven: 0, needsSetup: [] }, { persistBaseline: false }).behavior_coverage_path;
1479
1479
  }
1480
1480
  catch (error) {
1481
1481
  warnings.push(`static behavior view not written: ${error instanceof Error ? error.message : String(error)}`);
@@ -1493,7 +1493,9 @@ export async function opStart(root, opts = {}, deps = defaultDeps()) {
1493
1493
  const providerOpts = startProviderOverride(root, opts);
1494
1494
  const scanRoot = opts.source ? resolve(opts.source) : resolve(root);
1495
1495
  const providerEnv = loadProviderEnv([root, scanRoot], deps.env);
1496
- const providerDeps = { ...deps, env: providerEnv };
1496
+ // `start` writes advisory/intermediate views before the final report. Keep
1497
+ // those helpers from advancing report-baseline.json mid-run.
1498
+ const providerDeps = { ...deps, env: providerEnv, suppressBehaviorReportRefresh: true };
1497
1499
  const scope = summarizeCorpusScope(scanRoot);
1498
1500
  reportProgress(`start: preflight found ${scope.files.toLocaleString()} source/doc file(s)`, { current: 1, total: 8 });
1499
1501
  reportProgress("start: running deterministic analysis", { current: 2, total: 8 });
@@ -1995,7 +1997,7 @@ export function opGraphHtml(root, outputPath = "orangepro-graph.html") {
1995
1997
  return { graph_html_path: htmlPath };
1996
1998
  }
1997
1999
  /** Write the self-contained offline behavior-coverage view (deterministic, metadata only). */
1998
- export function opBehaviorCoverageHtml(root, outputPath = "orangepro-behavior-coverage.html", dynamicProof) {
2000
+ export function opBehaviorCoverageHtml(root, outputPath = "orangepro-behavior-coverage.html", dynamicProof, options = {}) {
1999
2001
  const graph = loadGraph(workspacePaths(root).graphPath);
2000
2002
  // Standalone regens have no this-run outcome: fall back to the persisted
2001
2003
  // proof-attempts sidecar ONLY when it anchors to the current graph+commit
@@ -2017,11 +2019,13 @@ export function opBehaviorCoverageHtml(root, outputPath = "orangepro-behavior-co
2017
2019
  const html = renderBehaviorReport(data);
2018
2020
  const htmlPath = resolve(root, outputPath);
2019
2021
  writeFileSync(htmlPath, html, "utf8");
2020
- try {
2021
- writeFileSync(baselinePath, JSON.stringify(reportBaselineOf(data, new Date().toISOString())), "utf8");
2022
- }
2023
- catch {
2024
- // baseline write is advisory
2022
+ if (options.persistBaseline !== false) {
2023
+ try {
2024
+ writeFileSync(baselinePath, JSON.stringify(reportBaselineOf(data, new Date().toISOString())), "utf8");
2025
+ }
2026
+ catch {
2027
+ // baseline write is advisory
2028
+ }
2025
2029
  }
2026
2030
  return { behavior_coverage_path: htmlPath };
2027
2031
  }
@@ -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,17 +790,17 @@ 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;
740
- const coveredCategories = [...new Set([
793
+ const path = qualify(risk, methodMatch?.[2] ?? risk.title);
794
+ const generatedCategories = [...new Set([
741
795
  ...generatedTests.map((t) => (t.bucket ? BUCKET_TO_CONCERN[t.bucket] : undefined)),
742
- // An integration/api/e2e-layer test covers the integration_flow
743
- // category by construction the chip and the pill must agree.
796
+ // An integration/api/e2e-layer draft targets integration_flow. This
797
+ // remains generation metadata until dynamic proof closes.
744
798
  ...generatedTests.map((t) => (t.concern === "integration" || t.concern === "api" || t.concern === "e2e" ? "integration_flow" : undefined))
745
799
  ].filter((c) => Boolean(c)))];
746
800
  return {
747
801
  generatedTests,
748
802
  applicableCategories: riskApplicableConcerns(risk, verb),
749
- coveredCategories,
803
+ generatedCategories,
750
804
  verb,
751
805
  path,
752
806
  todo: riskTodo(risk, verb, path, generatedTests)
@@ -230,9 +230,8 @@ nav.tabs{display:flex;gap:2px;margin:18px 0 0;border-bottom:1px solid var(--bd)}
230
230
  .cat-strip-lbl{font-size:9.5px;color:var(--muted);font-weight:600;letter-spacing:.04em;text-transform:uppercase;margin-bottom:6px}
231
231
  .cat-pills{display:flex;flex-wrap:wrap;gap:5px}
232
232
  .cat-pill{font-size:10px;padding:3px 8px;border-radius:4px;font-weight:600;letter-spacing:.01em;display:inline-flex;align-items:center;gap:4px}
233
- .cat-pill.cp-shown{background:var(--gbg);border:1px solid var(--gbd);color:var(--green)}
234
- .cat-pill.cp-locked{background:transparent;border:1px dashed var(--bd2);color:var(--faint)}
235
- .cat-pill.cp-locked::after{content:"\\1F512";font-size:8px}
233
+ .cat-pill.cp-drafted{background:var(--obg);border:1px solid var(--obd);color:var(--orange)}
234
+ .cat-pill.cp-open{background:transparent;border:1px dashed var(--bd2);color:var(--faint)}
236
235
  /* PAYWALL CTA */
237
236
  .paywall{background:linear-gradient(135deg,rgba(240,136,62,.08),rgba(240,136,62,.02));border:1px dashed var(--obd);border-radius:9px;padding:14px 16px;margin-top:14px;text-align:center}
238
237
  .paywall-num{font-size:18px;font-weight:750;color:var(--orange);font-variant-numeric:tabular-nums}
@@ -382,7 +381,8 @@ const D=window.DATA,$=(s)=>document.querySelector(s);
382
381
  var laneY={},svcY={};
383
382
  M.lanes.forEach(function(l,i){laneY[l.id]=padT+i*rowH+(H-2*padT-M.lanes.length*rowH)/2+rowH/2;});
384
383
  M.services.forEach(function(sv,i){svcY[sv.id]=padT+i*rowH+rowH/2;});
385
- 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];});
386
386
  var tierColor=function(t){var tot=t.proven+t.assoc+t.candidate+t.none||1;
387
387
  if(t.proven/tot>=0.5)return "var(--green)";
388
388
  if((t.proven+t.assoc)/tot>=0.5)return "var(--blue)";
@@ -443,7 +443,7 @@ const D=window.DATA,$=(s)=>document.querySelector(s);
443
443
  bc.innerHTML='';
444
444
  var backBtn=document.createElement("button");
445
445
  backBtn.className="sm-crumb-btn";
446
- backBtn.textContent="\u2190 Back to system map";
446
+ backBtn.textContent=" Back to system map";
447
447
  backBtn.addEventListener("click",function(){
448
448
  if(search){search.value="";search.dispatchEvent(new Event("input"));}
449
449
  bc.remove();
@@ -453,7 +453,7 @@ const D=window.DATA,$=(s)=>document.querySelector(s);
453
453
  });
454
454
  var clearBtn=document.createElement("button");
455
455
  clearBtn.className="sm-crumb-btn";
456
- clearBtn.textContent="\u2715 Clear filter \u201c"+svc+"\u201d";
456
+ clearBtn.textContent=" Clear filter "+svc+"";
457
457
  clearBtn.addEventListener("click",function(){
458
458
  if(search){search.value="";search.dispatchEvent(new Event("input"));}
459
459
  bc.remove();
@@ -523,7 +523,7 @@ function describeBehavior(b){
523
523
  :b.tier==="assoc"?"A real test imports and calls it, but no mutation proof exists yet."
524
524
  :b.tier==="candidate"?"Only a lexically similar test file was found — an unconfirmed lead, not evidence."
525
525
  :"No test signal of any kind was found for it.";
526
- const s1="\u201c"+h.words+"\u201d"+(h.owner?" on "+h.owner:"")+" — a behavior in "+(mod||"this repo")+".";
526
+ const s1=""+h.words+""+(h.owner?" on "+h.owner:"")+" — a behavior in "+(mod||"this repo")+".";
527
527
  const s2=reach.length?"A user or system reaches it via "+reach.join(" and ")+(reach.length>=2?" (and possibly more)":"")+".":(b.reachable?"It appears inside at least one statically traced call chain.":"No entry-anchored call chain reaches it in the static flow set.");
528
528
  return s1+" "+s2+" "+tierTxt;
529
529
  }
@@ -549,14 +549,14 @@ function renderBeh(){
549
549
  const rows=D.behaviors.filter(b=>(!activeGrp||b.group===activeGrp)&&(!activeTier||tierOf(b).cls===activeTier)&&(!searchQ||(b.sig+" "+b.file).toLowerCase().includes(searchQ)));
550
550
  if(rows.length===0){
551
551
  const parts=[];
552
- if(searchQ)parts.push('search \u201c'+searchQ+'\u201d');
552
+ if(searchQ)parts.push('search '+searchQ+'');
553
553
  if(activeTier)parts.push('the selected evidence tier');
554
- if(activeGrp)parts.push('package \u201c'+activeGrp+'\u201d');
554
+ if(activeGrp)parts.push('package '+activeGrp+'');
555
555
  const why=parts.length?parts.join(' + '):'the current filters';
556
556
  const box=el("div","beh-empty",
557
- '<p>No behaviors match '+esc(why)+'. Filters combine \u2014 clearing one usually brings results back.</p>');
557
+ '<p>No behaviors match '+esc(why)+'. Filters combine clearing one usually brings results back.</p>');
558
558
  if(searchQ){
559
- const b1=el("button","sm-crumb-btn","\u2715 Clear search");
559
+ const b1=el("button","sm-crumb-btn"," Clear search");
560
560
  b1.onclick=function(){searchEl.value="";searchQ="";renderBeh();};
561
561
  box.append(b1);
562
562
  }
@@ -697,15 +697,16 @@ function riskCardHtml(r){
697
697
  // category strip: show which concern categories apply to this flow
698
698
  let catHtml='';
699
699
  if(r.applicableCategories&&r.applicableCategories.length){
700
- const shownConcerns=new Set(r.coveredCategories||[]);
700
+ const draftedConcerns=new Set(r.generatedCategories||[]);
701
701
  const pills=r.applicableCategories.map(c=>{
702
- const shown=shownConcerns.has(c);
702
+ const drafted=draftedConcerns.has(c);
703
703
  const label=c.replace(/_/g,' ');
704
- return \`<span class="cat-pill \${shown?'cp-shown':'cp-locked'}">\${esc(label)}</span>\`;
704
+ const hint=drafted?'Generated draft attached; not coverage or proof':'Applicable category without a generated draft';
705
+ return \`<span class="cat-pill \${drafted?'cp-drafted':'cp-open'}" title="\${hint}">\${esc(label)}</span>\`;
705
706
  }).join('');
706
- const shownN=r.applicableCategories.filter(c=>shownConcerns.has(c)).length;
707
+ const shownN=r.applicableCategories.filter(c=>draftedConcerns.has(c)).length;
707
708
  const totalN=r.applicableCategories.length;
708
- catHtml=\`<div class="cat-strip"><div class="cat-strip-lbl">Applicable testing categories for this flow (\${shownN} of \${totalN} covered)</div><div class="cat-pills">\${pills}</div></div>\`;
709
+ catHtml=\`<div class="cat-strip"><div class="cat-strip-lbl">Generated drafts: \${shownN} of \${totalN} applicable testing categories</div><div class="cat-pills">\${pills}</div></div>\`;
709
710
  }
710
711
  let testsHtml='';
711
712
  if(r.generatedTests&&r.generatedTests.length){
@@ -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.6",
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",