mstro-app 0.5.1 → 0.5.5
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/PRIVACY.md +9 -9
- package/README.md +71 -28
- package/bin/commands/config.js +1 -1
- package/bin/mstro.js +55 -4
- package/dist/server/cli/eta-estimator.d.ts +55 -0
- package/dist/server/cli/eta-estimator.d.ts.map +1 -0
- package/dist/server/cli/eta-estimator.js +222 -0
- package/dist/server/cli/eta-estimator.js.map +1 -0
- package/dist/server/cli/headless/stall-assessor.d.ts +50 -0
- package/dist/server/cli/headless/stall-assessor.d.ts.map +1 -1
- package/dist/server/cli/headless/stall-assessor.js +64 -9
- package/dist/server/cli/headless/stall-assessor.js.map +1 -1
- package/dist/server/cli/headless/tool-watchdog.d.ts +21 -0
- package/dist/server/cli/headless/tool-watchdog.d.ts.map +1 -1
- package/dist/server/cli/headless/tool-watchdog.js +19 -12
- package/dist/server/cli/headless/tool-watchdog.js.map +1 -1
- package/dist/server/cli/improvisation-history-store.d.ts.map +1 -1
- package/dist/server/cli/improvisation-history-store.js +5 -1
- package/dist/server/cli/improvisation-history-store.js.map +1 -1
- package/dist/server/cli/improvisation-output-queue.d.ts +5 -1
- package/dist/server/cli/improvisation-output-queue.d.ts.map +1 -1
- package/dist/server/cli/improvisation-output-queue.js +30 -7
- package/dist/server/cli/improvisation-output-queue.js.map +1 -1
- package/dist/server/cli/improvisation-session-manager.d.ts +29 -0
- package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
- package/dist/server/cli/improvisation-session-manager.js +50 -1
- package/dist/server/cli/improvisation-session-manager.js.map +1 -1
- package/dist/server/cli/improvisation-types.d.ts +2 -0
- package/dist/server/cli/improvisation-types.d.ts.map +1 -1
- package/dist/server/cli/improvisation-types.js.map +1 -1
- package/dist/server/engines/EngineEvent.d.ts +126 -0
- package/dist/server/engines/EngineEvent.d.ts.map +1 -0
- package/dist/server/engines/EngineEvent.js +11 -0
- package/dist/server/engines/EngineEvent.js.map +1 -0
- package/dist/server/engines/claude/ClaudeCodeEngine.d.ts +47 -0
- package/dist/server/engines/claude/ClaudeCodeEngine.d.ts.map +1 -0
- package/dist/server/engines/claude/ClaudeCodeEngine.js +338 -0
- package/dist/server/engines/claude/ClaudeCodeEngine.js.map +1 -0
- package/dist/server/engines/factory.d.ts +21 -0
- package/dist/server/engines/factory.d.ts.map +1 -0
- package/dist/server/engines/factory.js +152 -0
- package/dist/server/engines/factory.js.map +1 -0
- package/dist/server/engines/opencode/OpenCodeEngine.d.ts +148 -0
- package/dist/server/engines/opencode/OpenCodeEngine.d.ts.map +1 -0
- package/dist/server/engines/opencode/OpenCodeEngine.js +630 -0
- package/dist/server/engines/opencode/OpenCodeEngine.js.map +1 -0
- package/dist/server/engines/opencode/OpenCodeServerManager.d.ts +172 -0
- package/dist/server/engines/opencode/OpenCodeServerManager.d.ts.map +1 -0
- package/dist/server/engines/opencode/OpenCodeServerManager.js +390 -0
- package/dist/server/engines/opencode/OpenCodeServerManager.js.map +1 -0
- package/dist/server/engines/opencode/model-catalog.d.ts +94 -0
- package/dist/server/engines/opencode/model-catalog.d.ts.map +1 -0
- package/dist/server/engines/opencode/model-catalog.js +141 -0
- package/dist/server/engines/opencode/model-catalog.js.map +1 -0
- package/dist/server/engines/types.d.ts +146 -0
- package/dist/server/engines/types.d.ts.map +1 -0
- package/dist/server/engines/types.js +4 -0
- package/dist/server/engines/types.js.map +1 -0
- package/dist/server/index.js +1 -1
- package/dist/server/index.js.map +1 -1
- package/dist/server/mcp/bouncer-haiku.d.ts +17 -4
- package/dist/server/mcp/bouncer-haiku.d.ts.map +1 -1
- package/dist/server/mcp/bouncer-haiku.js +8 -124
- package/dist/server/mcp/bouncer-haiku.js.map +1 -1
- package/dist/server/mcp/bouncer-integration.d.ts +45 -0
- package/dist/server/mcp/bouncer-integration.d.ts.map +1 -1
- package/dist/server/mcp/bouncer-integration.js +69 -5
- package/dist/server/mcp/bouncer-integration.js.map +1 -1
- package/dist/server/mcp/classifier/BouncerClassifier.d.ts +34 -0
- package/dist/server/mcp/classifier/BouncerClassifier.d.ts.map +1 -0
- package/dist/server/mcp/classifier/BouncerClassifier.js +4 -0
- package/dist/server/mcp/classifier/BouncerClassifier.js.map +1 -0
- package/dist/server/mcp/classifier/ClaudeBouncerClassifier.d.ts +17 -0
- package/dist/server/mcp/classifier/ClaudeBouncerClassifier.d.ts.map +1 -0
- package/dist/server/mcp/classifier/ClaudeBouncerClassifier.js +142 -0
- package/dist/server/mcp/classifier/ClaudeBouncerClassifier.js.map +1 -0
- package/dist/server/mcp/classifier/OpenCodeBouncerClassifier.d.ts +68 -0
- package/dist/server/mcp/classifier/OpenCodeBouncerClassifier.d.ts.map +1 -0
- package/dist/server/mcp/classifier/OpenCodeBouncerClassifier.js +182 -0
- package/dist/server/mcp/classifier/OpenCodeBouncerClassifier.js.map +1 -0
- package/dist/server/mcp/classifier/factory.d.ts +70 -0
- package/dist/server/mcp/classifier/factory.d.ts.map +1 -0
- package/dist/server/mcp/classifier/factory.js +155 -0
- package/dist/server/mcp/classifier/factory.js.map +1 -0
- package/dist/server/services/plan/agent-resolver.d.ts +26 -0
- package/dist/server/services/plan/agent-resolver.d.ts.map +1 -0
- package/dist/server/services/plan/agent-resolver.js +102 -0
- package/dist/server/services/plan/agent-resolver.js.map +1 -0
- package/dist/server/services/plan/composer.d.ts.map +1 -1
- package/dist/server/services/plan/composer.js +59 -11
- package/dist/server/services/plan/composer.js.map +1 -1
- package/dist/server/services/plan/executor.d.ts.map +1 -1
- package/dist/server/services/plan/executor.js +3 -1
- package/dist/server/services/plan/executor.js.map +1 -1
- package/dist/server/services/plan/issue-prompt-builder.d.ts.map +1 -1
- package/dist/server/services/plan/issue-prompt-builder.js +33 -1
- package/dist/server/services/plan/issue-prompt-builder.js.map +1 -1
- package/dist/server/services/plan/parser-core.d.ts.map +1 -1
- package/dist/server/services/plan/parser-core.js +1 -0
- package/dist/server/services/plan/parser-core.js.map +1 -1
- package/dist/server/services/plan/types.d.ts +1 -0
- package/dist/server/services/plan/types.d.ts.map +1 -1
- package/dist/server/services/settings.d.ts +76 -2
- package/dist/server/services/settings.d.ts.map +1 -1
- package/dist/server/services/settings.js +127 -4
- package/dist/server/services/settings.js.map +1 -1
- package/dist/server/services/websocket/git-branch-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/git-branch-handlers.js +19 -6
- package/dist/server/services/websocket/git-branch-handlers.js.map +1 -1
- package/dist/server/services/websocket/handler.d.ts +17 -1
- package/dist/server/services/websocket/handler.d.ts.map +1 -1
- package/dist/server/services/websocket/handler.js +54 -2
- package/dist/server/services/websocket/handler.js.map +1 -1
- package/dist/server/services/websocket/quality-complexity.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-complexity.js +78 -26
- package/dist/server/services/websocket/quality-complexity.js.map +1 -1
- package/dist/server/services/websocket/quality-eta.d.ts +47 -0
- package/dist/server/services/websocket/quality-eta.d.ts.map +1 -0
- package/dist/server/services/websocket/quality-eta.js +110 -0
- package/dist/server/services/websocket/quality-eta.js.map +1 -0
- package/dist/server/services/websocket/quality-grading.d.ts +27 -4
- package/dist/server/services/websocket/quality-grading.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-grading.js +369 -201
- package/dist/server/services/websocket/quality-grading.js.map +1 -1
- package/dist/server/services/websocket/quality-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-handlers.js +145 -7
- package/dist/server/services/websocket/quality-handlers.js.map +1 -1
- package/dist/server/services/websocket/quality-operations.d.ts +34 -0
- package/dist/server/services/websocket/quality-operations.d.ts.map +1 -0
- package/dist/server/services/websocket/quality-operations.js +47 -0
- package/dist/server/services/websocket/quality-operations.js.map +1 -0
- package/dist/server/services/websocket/quality-persistence.d.ts +9 -0
- package/dist/server/services/websocket/quality-persistence.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-persistence.js +10 -0
- package/dist/server/services/websocket/quality-persistence.js.map +1 -1
- package/dist/server/services/websocket/quality-review-agent.d.ts +1 -1
- package/dist/server/services/websocket/quality-review-agent.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-review-agent.js +105 -56
- package/dist/server/services/websocket/quality-review-agent.js.map +1 -1
- package/dist/server/services/websocket/quality-service.d.ts +9 -1
- package/dist/server/services/websocket/quality-service.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-service.js +334 -14
- package/dist/server/services/websocket/quality-service.js.map +1 -1
- package/dist/server/services/websocket/quality-tools.d.ts +21 -0
- package/dist/server/services/websocket/quality-tools.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-tools.js +49 -0
- package/dist/server/services/websocket/quality-tools.js.map +1 -1
- package/dist/server/services/websocket/quality-types.d.ts +35 -2
- package/dist/server/services/websocket/quality-types.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-types.js +1 -1
- package/dist/server/services/websocket/quality-types.js.map +1 -1
- package/dist/server/services/websocket/session-handlers.d.ts +3 -1
- package/dist/server/services/websocket/session-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/session-handlers.js +57 -9
- package/dist/server/services/websocket/session-handlers.js.map +1 -1
- package/dist/server/services/websocket/session-history.js +3 -0
- package/dist/server/services/websocket/session-history.js.map +1 -1
- package/dist/server/services/websocket/session-initialization.d.ts.map +1 -1
- package/dist/server/services/websocket/session-initialization.js +158 -42
- package/dist/server/services/websocket/session-initialization.js.map +1 -1
- package/dist/server/services/websocket/session-registry.d.ts +25 -0
- package/dist/server/services/websocket/session-registry.d.ts.map +1 -1
- package/dist/server/services/websocket/session-registry.js +19 -0
- package/dist/server/services/websocket/session-registry.js.map +1 -1
- package/dist/server/services/websocket/settings-handlers.d.ts +1 -1
- package/dist/server/services/websocket/settings-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/settings-handlers.js +35 -4
- package/dist/server/services/websocket/settings-handlers.js.map +1 -1
- package/dist/server/services/websocket/tab-broadcast.d.ts +7 -2
- package/dist/server/services/websocket/tab-broadcast.d.ts.map +1 -1
- package/dist/server/services/websocket/tab-broadcast.js +10 -2
- package/dist/server/services/websocket/tab-broadcast.js.map +1 -1
- package/dist/server/services/websocket/tab-event-buffer.d.ts +97 -8
- package/dist/server/services/websocket/tab-event-buffer.d.ts.map +1 -1
- package/dist/server/services/websocket/tab-event-buffer.js +138 -12
- package/dist/server/services/websocket/tab-event-buffer.js.map +1 -1
- package/dist/server/services/websocket/tab-event-replay.d.ts +29 -13
- package/dist/server/services/websocket/tab-event-replay.d.ts.map +1 -1
- package/dist/server/services/websocket/tab-event-replay.js +55 -2
- package/dist/server/services/websocket/tab-event-replay.js.map +1 -1
- package/dist/server/services/websocket/tab-handlers.d.ts +9 -1
- package/dist/server/services/websocket/tab-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/tab-handlers.js +47 -2
- package/dist/server/services/websocket/tab-handlers.js.map +1 -1
- package/dist/server/services/websocket/types.d.ts +28 -5
- package/dist/server/services/websocket/types.d.ts.map +1 -1
- package/dist/server/services/websocket/types.js +10 -4
- package/dist/server/services/websocket/types.js.map +1 -1
- package/package.json +5 -3
- package/server/cli/eta-estimator.ts +249 -0
- package/server/cli/headless/stall-assessor.ts +93 -0
- package/server/cli/headless/tool-watchdog.ts +21 -0
- package/server/cli/improvisation-history-store.ts +4 -1
- package/server/cli/improvisation-output-queue.ts +29 -7
- package/server/cli/improvisation-session-manager.ts +54 -1
- package/server/cli/improvisation-types.ts +2 -0
- package/server/engines/EngineEvent.ts +156 -0
- package/server/engines/claude/ClaudeCodeEngine.ts +404 -0
- package/server/engines/factory.ts +176 -0
- package/server/engines/opencode/OpenCodeEngine.ts +786 -0
- package/server/engines/opencode/OpenCodeServerManager.ts +577 -0
- package/server/engines/opencode/model-catalog.ts +217 -0
- package/server/engines/types.ts +173 -0
- package/server/index.ts +1 -1
- package/server/mcp/bouncer-haiku.ts +21 -145
- package/server/mcp/bouncer-integration.ts +107 -5
- package/server/mcp/classifier/BouncerClassifier.ts +40 -0
- package/server/mcp/classifier/ClaudeBouncerClassifier.ts +189 -0
- package/server/mcp/classifier/OpenCodeBouncerClassifier.ts +305 -0
- package/server/mcp/classifier/factory.ts +195 -0
- package/server/services/plan/agent-resolver.ts +115 -0
- package/server/services/plan/agents/code-review.md +38 -8
- package/server/services/plan/composer.ts +63 -11
- package/server/services/plan/executor.ts +3 -1
- package/server/services/plan/issue-prompt-builder.ts +39 -1
- package/server/services/plan/parser-core.ts +1 -0
- package/server/services/plan/types.ts +4 -0
- package/server/services/settings.ts +161 -4
- package/server/services/websocket/git-branch-handlers.ts +20 -6
- package/server/services/websocket/handler.ts +59 -2
- package/server/services/websocket/quality-complexity.ts +80 -26
- package/server/services/websocket/quality-eta.ts +155 -0
- package/server/services/websocket/quality-grading.ts +445 -222
- package/server/services/websocket/quality-handlers.ts +153 -7
- package/server/services/websocket/quality-operations.ts +72 -0
- package/server/services/websocket/quality-persistence.ts +17 -0
- package/server/services/websocket/quality-review-agent.ts +154 -64
- package/server/services/websocket/quality-service.ts +361 -13
- package/server/services/websocket/quality-tools.ts +51 -0
- package/server/services/websocket/quality-types.ts +41 -2
- package/server/services/websocket/session-handlers.ts +64 -10
- package/server/services/websocket/session-history.ts +3 -0
- package/server/services/websocket/session-initialization.ts +189 -46
- package/server/services/websocket/session-registry.ts +37 -0
- package/server/services/websocket/settings-handlers.ts +41 -4
- package/server/services/websocket/tab-broadcast.ts +10 -2
- package/server/services/websocket/tab-event-buffer.ts +143 -11
- package/server/services/websocket/tab-event-replay.ts +70 -3
- package/server/services/websocket/tab-handlers.ts +53 -5
- package/server/services/websocket/types.ts +37 -5
|
@@ -15,10 +15,75 @@
|
|
|
15
15
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
16
16
|
import { homedir } from 'node:os'
|
|
17
17
|
import { join } from 'node:path'
|
|
18
|
+
import type { EngineId } from '../engines/types.js'
|
|
18
19
|
|
|
19
20
|
const MSTRO_DIR = join(homedir(), '.mstro')
|
|
20
21
|
const SETTINGS_FILE = join(MSTRO_DIR, 'settings.json')
|
|
21
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Configuration for the Layer-2 Bouncer classifier (the AI model that runs
|
|
25
|
+
* for every ambiguous tool call). The model MUST be flagged
|
|
26
|
+
* `bouncerEligible` in the engine's model catalogue — frontier models
|
|
27
|
+
* (Opus, GPT-4o, …) are deliberately disallowed because they slow the
|
|
28
|
+
* classifier path and degrade the whole security layer.
|
|
29
|
+
*/
|
|
30
|
+
export interface BouncerClassifierConfig {
|
|
31
|
+
engine: EngineId
|
|
32
|
+
/** Engine-specific model id, e.g. 'haiku', 'sonnet', 'openai/gpt-5-mini'. */
|
|
33
|
+
model: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Canonical list of bouncer-eligible models per engine. Mirrors
|
|
38
|
+
* `web/src/components/views/SettingsView/constants.ts` — keep the two in
|
|
39
|
+
* sync. Only cheap/fast models appear here; if you need to add a model,
|
|
40
|
+
* check p50 latency < ~1s and JSON-mode capability first.
|
|
41
|
+
*/
|
|
42
|
+
export const BOUNCER_ELIGIBLE_MODELS: Record<EngineId, readonly string[]> = {
|
|
43
|
+
'claude-code': ['haiku', 'sonnet'],
|
|
44
|
+
opencode: [
|
|
45
|
+
'openai/gpt-5-mini',
|
|
46
|
+
'openai/gpt-5-nano',
|
|
47
|
+
'google/gemini-2.5-flash',
|
|
48
|
+
'ollama/llama3.1:8b',
|
|
49
|
+
],
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Default classifier — Claude Haiku. Matches the pre-feature-flag behavior. */
|
|
53
|
+
export const DEFAULT_BOUNCER_CLASSIFIER: BouncerClassifierConfig = {
|
|
54
|
+
engine: 'claude-code',
|
|
55
|
+
model: 'haiku',
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Validate a `BouncerClassifierConfig`. Rejects with a thrown `Error` when
|
|
60
|
+
* the model is not flagged `bouncerEligible` under the requested engine —
|
|
61
|
+
* e.g. attempting to use Opus as a classifier, or a frontier OpenCode
|
|
62
|
+
* model. The WebSocket settings handler uses this to reject crafted
|
|
63
|
+
* payloads from the web client.
|
|
64
|
+
*/
|
|
65
|
+
export function validateBouncerClassifier(config: unknown): BouncerClassifierConfig {
|
|
66
|
+
if (config === null || typeof config !== 'object') {
|
|
67
|
+
throw new Error('bouncerClassifier must be an object with { engine, model }')
|
|
68
|
+
}
|
|
69
|
+
const { engine, model } = config as { engine?: unknown; model?: unknown }
|
|
70
|
+
if (engine !== 'claude-code' && engine !== 'opencode') {
|
|
71
|
+
throw new Error(`bouncerClassifier.engine must be 'claude-code' or 'opencode' (got ${String(engine)})`)
|
|
72
|
+
}
|
|
73
|
+
if (typeof model !== 'string' || model.length === 0) {
|
|
74
|
+
throw new Error('bouncerClassifier.model must be a non-empty string')
|
|
75
|
+
}
|
|
76
|
+
const eligible = BOUNCER_ELIGIBLE_MODELS[engine]
|
|
77
|
+
if (!eligible.includes(model)) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
`Model '${model}' is not bouncer-eligible for engine '${engine}'. ` +
|
|
80
|
+
`Eligible models: ${eligible.join(', ')}. ` +
|
|
81
|
+
`Frontier models (Opus, GPT-4o, etc.) are deliberately excluded to keep the classifier fast.`,
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
return { engine, model }
|
|
85
|
+
}
|
|
86
|
+
|
|
22
87
|
export interface MstroSettings {
|
|
23
88
|
/**
|
|
24
89
|
* Claude model to use for main execution.
|
|
@@ -37,11 +102,26 @@ export interface MstroSettings {
|
|
|
37
102
|
effortLevel: string
|
|
38
103
|
/** Per-repo preferred PR base branch, keyed by normalized remote URL */
|
|
39
104
|
prBaseBranches?: Record<string, string>
|
|
105
|
+
/**
|
|
106
|
+
* Feature flag gating all OpenCode code paths (engine factory, classifier
|
|
107
|
+
* factory, and UI). When `false`, the system behaves byte-identically to
|
|
108
|
+
* pre-OpenCode main: no `opencode serve` subprocess, no classifier picker,
|
|
109
|
+
* no EngineSection/EnginePicker in the web UI. Resolution order in
|
|
110
|
+
* `isEngineSwapEnabled()`: env var → stored setting → NODE_ENV default.
|
|
111
|
+
*/
|
|
112
|
+
engineSwap?: boolean
|
|
113
|
+
/**
|
|
114
|
+
* Which engine + model backs the Layer-2 Bouncer classifier. Defaults to
|
|
115
|
+
* `{ engine: 'claude-code', model: 'haiku' }`. Only models flagged
|
|
116
|
+
* `bouncerEligible` are accepted — see {@link validateBouncerClassifier}.
|
|
117
|
+
*/
|
|
118
|
+
bouncerClassifier?: BouncerClassifierConfig
|
|
40
119
|
}
|
|
41
120
|
|
|
42
121
|
const DEFAULT_SETTINGS: MstroSettings = {
|
|
43
122
|
model: 'opus',
|
|
44
|
-
effortLevel: 'auto'
|
|
123
|
+
effortLevel: 'auto',
|
|
124
|
+
bouncerClassifier: { ...DEFAULT_BOUNCER_CLASSIFIER },
|
|
45
125
|
}
|
|
46
126
|
|
|
47
127
|
/**
|
|
@@ -54,7 +134,11 @@ function ensureMstroDir(): void {
|
|
|
54
134
|
}
|
|
55
135
|
|
|
56
136
|
/**
|
|
57
|
-
* Get current settings, merged with defaults for any missing fields
|
|
137
|
+
* Get current settings, merged with defaults for any missing fields. A
|
|
138
|
+
* persisted `bouncerClassifier` that is no longer bouncer-eligible (e.g. a
|
|
139
|
+
* catalogue change removed the model) is dropped in favor of the default
|
|
140
|
+
* and a warning is logged — the Bouncer must never silently run a
|
|
141
|
+
* non-eligible model just because someone edited settings.json by hand.
|
|
58
142
|
*/
|
|
59
143
|
export function getSettings(): MstroSettings {
|
|
60
144
|
if (!existsSync(SETTINGS_FILE)) {
|
|
@@ -64,10 +148,22 @@ export function getSettings(): MstroSettings {
|
|
|
64
148
|
try {
|
|
65
149
|
const content = readFileSync(SETTINGS_FILE, 'utf-8')
|
|
66
150
|
const stored = JSON.parse(content)
|
|
67
|
-
|
|
151
|
+
const merged: MstroSettings = {
|
|
68
152
|
...DEFAULT_SETTINGS,
|
|
69
153
|
...stored,
|
|
70
154
|
}
|
|
155
|
+
if (stored && typeof stored === 'object' && 'bouncerClassifier' in stored) {
|
|
156
|
+
try {
|
|
157
|
+
merged.bouncerClassifier = validateBouncerClassifier(stored.bouncerClassifier)
|
|
158
|
+
} catch (err) {
|
|
159
|
+
console.warn(
|
|
160
|
+
'[settings] Stored bouncerClassifier is not bouncer-eligible, falling back to default:',
|
|
161
|
+
err instanceof Error ? err.message : String(err),
|
|
162
|
+
)
|
|
163
|
+
merged.bouncerClassifier = { ...DEFAULT_BOUNCER_CLASSIFIER }
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return merged
|
|
71
167
|
} catch (err) {
|
|
72
168
|
console.warn('Failed to read settings file, using defaults:', err)
|
|
73
169
|
return { ...DEFAULT_SETTINGS }
|
|
@@ -75,9 +171,18 @@ export function getSettings(): MstroSettings {
|
|
|
75
171
|
}
|
|
76
172
|
|
|
77
173
|
/**
|
|
78
|
-
* Save full settings to disk
|
|
174
|
+
* Save full settings to disk. Rejects if `bouncerClassifier` is present but
|
|
175
|
+
* its model is not flagged `bouncerEligible` — this is the save-time half
|
|
176
|
+
* of the guard; `getSettings` enforces the read-time half. Together they
|
|
177
|
+
* ensure the Bouncer is never configured with a frontier model (Opus,
|
|
178
|
+
* GPT-4o, …) regardless of whether the mutation came from the web UI or a
|
|
179
|
+
* direct edit of settings.json.
|
|
79
180
|
*/
|
|
80
181
|
export function saveSettings(settings: MstroSettings): void {
|
|
182
|
+
if (settings.bouncerClassifier !== undefined) {
|
|
183
|
+
// Throws on non-eligible model — callers must surface the error.
|
|
184
|
+
validateBouncerClassifier(settings.bouncerClassifier)
|
|
185
|
+
}
|
|
81
186
|
ensureMstroDir()
|
|
82
187
|
writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2), {
|
|
83
188
|
mode: 0o600
|
|
@@ -116,6 +221,58 @@ export function setEffortLevel(effortLevel: string): void {
|
|
|
116
221
|
saveSettings(settings)
|
|
117
222
|
}
|
|
118
223
|
|
|
224
|
+
/**
|
|
225
|
+
* Get the current Bouncer classifier configuration. Returns the default
|
|
226
|
+
* `{ engine: 'claude-code', model: 'haiku' }` when nothing is persisted.
|
|
227
|
+
*/
|
|
228
|
+
export function getBouncerClassifier(): BouncerClassifierConfig {
|
|
229
|
+
const settings = getSettings()
|
|
230
|
+
if (settings.bouncerClassifier) {
|
|
231
|
+
try {
|
|
232
|
+
return validateBouncerClassifier(settings.bouncerClassifier)
|
|
233
|
+
} catch {
|
|
234
|
+
// Stored config is no longer eligible (e.g. model removed from the
|
|
235
|
+
// catalogue). Fall back to the safe default rather than crashing.
|
|
236
|
+
return { ...DEFAULT_BOUNCER_CLASSIFIER }
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return { ...DEFAULT_BOUNCER_CLASSIFIER }
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Persist a new Bouncer classifier config. Throws if the model is not
|
|
244
|
+
* flagged `bouncerEligible` under the requested engine — callers should
|
|
245
|
+
* surface the error to the UI so the user sees a clear rejection reason.
|
|
246
|
+
*/
|
|
247
|
+
export function setBouncerClassifier(config: unknown): BouncerClassifierConfig {
|
|
248
|
+
const validated = validateBouncerClassifier(config)
|
|
249
|
+
const settings = getSettings()
|
|
250
|
+
settings.bouncerClassifier = validated
|
|
251
|
+
saveSettings(settings)
|
|
252
|
+
return validated
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Resolve the engineSwap feature flag. Precedence:
|
|
257
|
+
* 1. `MSTRO_ENABLE_ENGINE_SWAP` env var ('true'|'1' → on, 'false'|'0' → off).
|
|
258
|
+
* 2. `engineSwap` field in `~/.mstro/settings.json`.
|
|
259
|
+
* 3. NODE_ENV default — off in production, on otherwise (dev/staging/test).
|
|
260
|
+
*
|
|
261
|
+
* Callers who need a single boolean should use this helper rather than
|
|
262
|
+
* reading the field directly, so the precedence stays in one place.
|
|
263
|
+
*/
|
|
264
|
+
export function isEngineSwapEnabled(): boolean {
|
|
265
|
+
const envFlag = process.env.MSTRO_ENABLE_ENGINE_SWAP
|
|
266
|
+
if (envFlag !== undefined) {
|
|
267
|
+
const normalized = envFlag.trim().toLowerCase()
|
|
268
|
+
if (normalized === 'true' || normalized === '1') return true
|
|
269
|
+
if (normalized === 'false' || normalized === '0') return false
|
|
270
|
+
}
|
|
271
|
+
const stored = getSettings().engineSwap
|
|
272
|
+
if (typeof stored === 'boolean') return stored
|
|
273
|
+
return process.env.NODE_ENV !== 'production'
|
|
274
|
+
}
|
|
275
|
+
|
|
119
276
|
/** Normalize a remote URL into a stable key (e.g. "github.com/owner/repo") */
|
|
120
277
|
function normalizeRemoteUrl(remoteUrl: string): string {
|
|
121
278
|
return remoteUrl
|
|
@@ -63,38 +63,52 @@ async function redirectToWorktreeIfBranchCheckedOut(
|
|
|
63
63
|
|
|
64
64
|
export async function handleGitCheckout(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string, rootWorkingDir: string): Promise<void> {
|
|
65
65
|
try {
|
|
66
|
-
const { branch, create, startPoint } = msg.data || {};
|
|
66
|
+
const { branch, create, startPoint, worktreePath } = msg.data || {};
|
|
67
67
|
if (!branch) {
|
|
68
68
|
ctx.send(ws, { type: 'gitError', tabId, data: { error: 'Branch name is required' } });
|
|
69
69
|
return;
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
// `worktreePath` lets the caller target a specific working directory
|
|
73
|
+
// (typically the main repo) regardless of which tab is active. Used by
|
|
74
|
+
// the "Base branch" dropdown so checkout always lands on main, not on
|
|
75
|
+
// whichever worktree the user happened to be inspecting.
|
|
76
|
+
const targetDir = typeof worktreePath === 'string' && worktreePath.length > 0
|
|
77
|
+
? worktreePath
|
|
78
|
+
: workingDir;
|
|
79
|
+
|
|
72
80
|
// Skip the worktree redirect for `create` — a name collision there is a real user error.
|
|
73
|
-
if (!create && await redirectToWorktreeIfBranchCheckedOut(ctx, ws, tabId, branch,
|
|
81
|
+
if (!create && await redirectToWorktreeIfBranchCheckedOut(ctx, ws, tabId, branch, targetDir, rootWorkingDir)) {
|
|
74
82
|
return;
|
|
75
83
|
}
|
|
76
84
|
|
|
77
|
-
const statusResult = await executeGitCommand(['status', '--porcelain'],
|
|
85
|
+
const statusResult = await executeGitCommand(['status', '--porcelain'], targetDir);
|
|
78
86
|
if (statusResult.stdout.trim()) {
|
|
79
87
|
ctx.send(ws, { type: 'gitError', tabId, data: { error: 'Commit or stash changes before switching branches' } });
|
|
80
88
|
return;
|
|
81
89
|
}
|
|
82
90
|
|
|
83
|
-
const prevResult = await executeGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'],
|
|
91
|
+
const prevResult = await executeGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], targetDir);
|
|
84
92
|
const previous = prevResult.stdout.trim();
|
|
85
93
|
|
|
86
94
|
const args = create
|
|
87
95
|
? ['checkout', '-b', branch, ...(startPoint ? [startPoint] : [])]
|
|
88
96
|
: ['checkout', branch];
|
|
89
97
|
|
|
90
|
-
const result = await executeGitCommand(args,
|
|
98
|
+
const result = await executeGitCommand(args, targetDir);
|
|
91
99
|
if (result.exitCode !== 0) {
|
|
92
100
|
ctx.send(ws, { type: 'gitError', tabId, data: { error: result.stderr || 'Failed to checkout branch' } });
|
|
93
101
|
return;
|
|
94
102
|
}
|
|
95
103
|
|
|
96
104
|
ctx.send(ws, { type: 'gitCheckedOut', tabId, data: { branch, previous } });
|
|
97
|
-
// Re-fetch status
|
|
105
|
+
// Re-fetch status for the *tab's* dir (`workingDir`), not `targetDir`. When
|
|
106
|
+
// the caller targets a different directory via `worktreePath` (e.g. the
|
|
107
|
+
// main repo from a worktree-anchored tab), sending main-repo status keyed
|
|
108
|
+
// to the tab id would clobber the tab's worktree-scoped status display.
|
|
109
|
+
// The web side fires a fresh `gitStatus` + `gitWorktreeList` on the
|
|
110
|
+
// `gitCheckedOut` handler, so the main-repo branch update propagates via
|
|
111
|
+
// the worktree list refresh.
|
|
98
112
|
const { handleGitStatus } = await import('./git-handlers.js');
|
|
99
113
|
handleGitStatus(ctx, ws, tabId, workingDir);
|
|
100
114
|
} catch (error: unknown) {
|
|
@@ -12,6 +12,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
|
12
12
|
import { homedir } from 'node:os';
|
|
13
13
|
import { dirname, join } from 'node:path';
|
|
14
14
|
import type { ImprovisationSessionManager } from '../../cli/improvisation-session-manager.js';
|
|
15
|
+
import type { InstanceRegistry } from '../instances.js';
|
|
15
16
|
import { captureException } from '../sentry.js';
|
|
16
17
|
import { getPTYManager } from '../terminal/pty-manager.js';
|
|
17
18
|
import { AutocompleteService } from './autocomplete.js';
|
|
@@ -30,7 +31,7 @@ import { generateNotificationSummary, handleGetSettings, handleUpdateSettings }
|
|
|
30
31
|
import { handleListSkills } from './skill-handlers.js';
|
|
31
32
|
import { SkillsWatcher } from './skill-watcher.js';
|
|
32
33
|
import { TabEventBufferRegistry } from './tab-event-buffer.js';
|
|
33
|
-
import { handleCreateTab, handleGetActiveTabs, handleMarkTabViewed, handleRemoveTab, handleReorderTabs, handleSyncTabMeta } from './tab-handlers.js';
|
|
34
|
+
import { handleCreateTab, handleGetActiveTabs, handleMarkTabViewed, handleRemoveTab, handleReorderTabs, handleSetTabEngine, handleSyncTabMeta } from './tab-handlers.js';
|
|
34
35
|
import { cleanupTerminalSubscribers, handleTerminalMessage } from './terminal-handlers.js';
|
|
35
36
|
import type { FrecencyData, WebSocketMessage, WebSocketResponse, WSContext } from './types.js';
|
|
36
37
|
|
|
@@ -55,11 +56,14 @@ export class WebSocketImproviseHandler implements HandlerContext {
|
|
|
55
56
|
skillsWatcher: SkillsWatcher | null = null;
|
|
56
57
|
tabEventBuffers: TabEventBufferRegistry = new TabEventBufferRegistry();
|
|
57
58
|
msgIdTracker: MsgIdTracker = new MsgIdTracker();
|
|
59
|
+
private instanceRegistry: InstanceRegistry | null;
|
|
60
|
+
private shutdownInProgress = false;
|
|
58
61
|
|
|
59
|
-
constructor() {
|
|
62
|
+
constructor(instanceRegistry: InstanceRegistry | null = null) {
|
|
60
63
|
this.frecencyPath = join(homedir(), '.mstro', 'autocomplete-frecency.json');
|
|
61
64
|
const frecencyData = this.loadFrecencyData();
|
|
62
65
|
this.autocompleteService = new AutocompleteService(frecencyData);
|
|
66
|
+
this.instanceRegistry = instanceRegistry;
|
|
63
67
|
process.on('exit', () => {
|
|
64
68
|
if (this.frecencySaveTimer) {
|
|
65
69
|
clearTimeout(this.frecencySaveTimer);
|
|
@@ -201,6 +205,9 @@ export class WebSocketImproviseHandler implements HandlerContext {
|
|
|
201
205
|
return handleRemoveTab(this, ws, tabId, workingDir);
|
|
202
206
|
case 'markTabViewed':
|
|
203
207
|
return handleMarkTabViewed(this, ws, tabId, workingDir);
|
|
208
|
+
case 'setTabEngine':
|
|
209
|
+
if (permission === 'view') return;
|
|
210
|
+
return handleSetTabEngine(this, ws, msg, tabId, workingDir);
|
|
204
211
|
case 'getSettings':
|
|
205
212
|
return handleGetSettings(this, ws);
|
|
206
213
|
case 'updateSettings':
|
|
@@ -208,6 +215,8 @@ export class WebSocketImproviseHandler implements HandlerContext {
|
|
|
208
215
|
return handleUpdateSettings(this, ws, msg);
|
|
209
216
|
case 'listSkills':
|
|
210
217
|
return handleListSkills(this, ws, workingDir);
|
|
218
|
+
case 'shutdownInstance':
|
|
219
|
+
return this.handleShutdownInstance(ws, permission);
|
|
211
220
|
}
|
|
212
221
|
|
|
213
222
|
// Dispatch table lookup for domain handlers
|
|
@@ -382,4 +391,52 @@ export class WebSocketImproviseHandler implements HandlerContext {
|
|
|
382
391
|
this.sessions.delete(sessionId);
|
|
383
392
|
}
|
|
384
393
|
|
|
394
|
+
/**
|
|
395
|
+
* Handle a `shutdownInstance` control message from a web client.
|
|
396
|
+
*
|
|
397
|
+
* Authorization: only the orchestra owner may shut down. The relay tags
|
|
398
|
+
* shared (view-only) users with `_permission: 'view'`; absence means the
|
|
399
|
+
* requester is the owner whose CLI this is. View-only requests are
|
|
400
|
+
* rejected with a `forbidden` error rather than silently dropped so the
|
|
401
|
+
* UI can surface "you're not the owner" to non-owners.
|
|
402
|
+
*
|
|
403
|
+
* Idempotency: a shutdown already in progress is acked (broadcast +
|
|
404
|
+
* exit timer were already scheduled) but does not stack a second timer.
|
|
405
|
+
*/
|
|
406
|
+
private handleShutdownInstance(ws: WSContext, permission: 'view' | undefined): void {
|
|
407
|
+
if (permission === 'view') {
|
|
408
|
+
console.log('[WebSocketImproviseHandler] Rejecting shutdownInstance from view-only user');
|
|
409
|
+
this.send(ws, {
|
|
410
|
+
type: 'error',
|
|
411
|
+
data: {
|
|
412
|
+
code: 'forbidden',
|
|
413
|
+
message: 'Only the owner can shut down this instance.'
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (this.shutdownInProgress) {
|
|
420
|
+
console.log('[WebSocketImproviseHandler] shutdownInstance already in progress — ignoring duplicate request');
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
this.shutdownInProgress = true;
|
|
424
|
+
|
|
425
|
+
// The CLI knows the request came from the owner (the relay only forwards
|
|
426
|
+
// owner traffic without a `_permission` tag), but does not receive the
|
|
427
|
+
// owner's userId on the wire. Logged as 'owner' for the audit trail.
|
|
428
|
+
console.log('[WebSocketImproviseHandler] shutdownInstance requested by owner — broadcasting shuttingDown and exiting');
|
|
429
|
+
|
|
430
|
+
this.broadcastToAll({ type: 'shuttingDown', data: { reason: 'user-requested' } });
|
|
431
|
+
|
|
432
|
+
// Mirrors the HTTP /api/shutdown route's 100ms delay so the broadcast
|
|
433
|
+
// has a chance to flush before process.exit tears down the socket.
|
|
434
|
+
setTimeout(() => {
|
|
435
|
+
if (this.instanceRegistry) {
|
|
436
|
+
this.instanceRegistry.unregister();
|
|
437
|
+
}
|
|
438
|
+
process.exit(0);
|
|
439
|
+
}, 100);
|
|
440
|
+
}
|
|
441
|
+
|
|
385
442
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Copyright (c) 2025-present Mstro, Inc. All rights reserved.
|
|
2
2
|
|
|
3
3
|
import { extname, relative } from 'node:path';
|
|
4
|
-
import { chunkFileList, filesByExt, runCommand, type SourceFile } from './quality-tools.js';
|
|
4
|
+
import { chunkFileList, filesByExt, isTestFile, runCommand, type SourceFile } from './quality-tools.js';
|
|
5
5
|
import { biomeDiagToFinding, type Ecosystem, FUNCTION_LENGTH_THRESHOLD, isBiomeComplexityDiagnostic, isEslintComplexityRule, type QualityFinding } from './quality-types.js';
|
|
6
6
|
|
|
7
7
|
const NODE_COMPLEXITY_EXTS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'];
|
|
@@ -16,6 +16,26 @@ interface FunctionInfo {
|
|
|
16
16
|
file: string;
|
|
17
17
|
startLine: number;
|
|
18
18
|
lines: number;
|
|
19
|
+
/** Approximate cyclomatic complexity (count of decision points). */
|
|
20
|
+
branches: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Decision-point keywords that approximate cyclomatic complexity. We count
|
|
25
|
+
* occurrences as a cheap proxy — McCabe's exact metric requires AST parsing,
|
|
26
|
+
* but the keyword count is highly correlated and good enough to distinguish
|
|
27
|
+
* "long but linear" (a flat sequence of statements) from "long and branchy"
|
|
28
|
+
* (deeply nested control flow).
|
|
29
|
+
*
|
|
30
|
+
* The user's task 2 requirement: "a 1000 line file might be just fine, not
|
|
31
|
+
* a violation at all, while another 1000 line file might be a severe mix of
|
|
32
|
+
* concerns" — same applies to functions. A long config-builder with one
|
|
33
|
+
* return statement is fine; a long monster with 40 if-branches is not.
|
|
34
|
+
*/
|
|
35
|
+
const BRANCH_KEYWORDS = /\b(?:if|else if|elif|for|while|case|catch|\?\s*\w|&&|\|\||\?\?)\b/g;
|
|
36
|
+
|
|
37
|
+
function countBranches(body: string): number {
|
|
38
|
+
return (body.match(BRANCH_KEYWORDS) || []).length;
|
|
19
39
|
}
|
|
20
40
|
|
|
21
41
|
const JS_FUNC_PATTERN = /^(\s*)(export\s+)?(async\s+)?function\s+(\w+)|^(\s*)(export\s+)?(const|let|var)\s+(\w+)\s*=\s*(async\s+)?\(|^(\s*)(public|private|protected)?\s*(async\s+)?(\w+)\s*\(/;
|
|
@@ -56,11 +76,15 @@ function extractJsFunctions(file: SourceFile): FunctionInfo[] {
|
|
|
56
76
|
braceDepth += countBraceDeltas(lines[i]);
|
|
57
77
|
|
|
58
78
|
if (currentFunc && braceDepth <= funcStartBraceDepth && i > currentFunc.startLine - 1) {
|
|
79
|
+
const startLine = currentFunc.startLine;
|
|
80
|
+
const endLine = i + 1;
|
|
81
|
+
const body = lines.slice(startLine - 1, endLine).join('\n');
|
|
59
82
|
functions.push({
|
|
60
83
|
name: currentFunc.name,
|
|
61
84
|
file: file.relativePath,
|
|
62
|
-
startLine
|
|
63
|
-
lines:
|
|
85
|
+
startLine,
|
|
86
|
+
lines: endLine - startLine + 1,
|
|
87
|
+
branches: countBranches(body),
|
|
64
88
|
});
|
|
65
89
|
currentFunc = null;
|
|
66
90
|
}
|
|
@@ -75,35 +99,29 @@ function extractPyFunctions(file: SourceFile): FunctionInfo[] {
|
|
|
75
99
|
const defPattern = /^(\s*)(async\s+)?def\s+(\w+)/;
|
|
76
100
|
let currentFunc: { name: string; startLine: number; indent: number } | null = null;
|
|
77
101
|
|
|
102
|
+
const recordFunction = (name: string, startLine: number, endLine: number) => {
|
|
103
|
+
const body = lines.slice(startLine - 1, endLine).join('\n');
|
|
104
|
+
functions.push({
|
|
105
|
+
name,
|
|
106
|
+
file: file.relativePath,
|
|
107
|
+
startLine,
|
|
108
|
+
lines: endLine - startLine + 1,
|
|
109
|
+
branches: countBranches(body),
|
|
110
|
+
});
|
|
111
|
+
};
|
|
112
|
+
|
|
78
113
|
for (let i = 0; i < lines.length; i++) {
|
|
79
114
|
const match = defPattern.exec(lines[i]);
|
|
80
115
|
if (match) {
|
|
81
|
-
if (currentFunc)
|
|
82
|
-
functions.push({
|
|
83
|
-
name: currentFunc.name,
|
|
84
|
-
file: file.relativePath,
|
|
85
|
-
startLine: currentFunc.startLine,
|
|
86
|
-
lines: i - currentFunc.startLine + 1,
|
|
87
|
-
});
|
|
88
|
-
}
|
|
116
|
+
if (currentFunc) recordFunction(currentFunc.name, currentFunc.startLine, i);
|
|
89
117
|
currentFunc = { name: match[3], startLine: i + 1, indent: match[1].length };
|
|
90
118
|
} else if (currentFunc && lines[i].trim() && !lines[i].startsWith(' '.repeat(currentFunc.indent + 1)) && !lines[i].startsWith('\t')) {
|
|
91
|
-
|
|
92
|
-
name: currentFunc.name,
|
|
93
|
-
file: file.relativePath,
|
|
94
|
-
startLine: currentFunc.startLine,
|
|
95
|
-
lines: i - currentFunc.startLine + 1,
|
|
96
|
-
});
|
|
119
|
+
recordFunction(currentFunc.name, currentFunc.startLine, i);
|
|
97
120
|
currentFunc = null;
|
|
98
121
|
}
|
|
99
122
|
}
|
|
100
123
|
if (currentFunc) {
|
|
101
|
-
|
|
102
|
-
name: currentFunc.name,
|
|
103
|
-
file: file.relativePath,
|
|
104
|
-
startLine: currentFunc.startLine,
|
|
105
|
-
lines: lines.length - currentFunc.startLine + 1,
|
|
106
|
-
});
|
|
124
|
+
recordFunction(currentFunc.name, currentFunc.startLine, lines.length);
|
|
107
125
|
}
|
|
108
126
|
|
|
109
127
|
return functions;
|
|
@@ -116,9 +134,37 @@ function extractFunctions(file: SourceFile): FunctionInfo[] {
|
|
|
116
134
|
return [];
|
|
117
135
|
}
|
|
118
136
|
|
|
137
|
+
/**
|
|
138
|
+
* Map a function's branch density (decision points per N lines) to a
|
|
139
|
+
* severity level for the function-length finding. Returns `null` to suppress
|
|
140
|
+
* the finding for a long but linear function — e.g., a config-builder with
|
|
141
|
+
* one return statement and 200 lines of property assignments.
|
|
142
|
+
*
|
|
143
|
+
* Heuristic: McCabe's cyclomatic complexity threshold is ~10. Above that,
|
|
144
|
+
* functions are hard to test. We grade severity by branches-per-50-lines so
|
|
145
|
+
* a 100-line function with 5 branches looks the same as a 50-line function
|
|
146
|
+
* with 5 branches (both ~industry "consider refactoring" zone).
|
|
147
|
+
*
|
|
148
|
+
* Functions absurdly long (>5x threshold) emit a finding regardless of
|
|
149
|
+
* branchiness — a 250-line function is too much to read in one sitting even
|
|
150
|
+
* if it's "linear."
|
|
151
|
+
*/
|
|
152
|
+
function severityFromBranchiness(branches: number, lines: number): QualityFinding['severity'] | null {
|
|
153
|
+
const branchesPer50 = (branches * 50) / Math.max(1, lines);
|
|
154
|
+
const isAbsurd = lines > FUNCTION_LENGTH_THRESHOLD * 5;
|
|
155
|
+
if (branchesPer50 < 3 && !isAbsurd) return null; // Long but linear — not really a violation.
|
|
156
|
+
if (branchesPer50 < 6) return 'low';
|
|
157
|
+
if (branchesPer50 < 10) return 'medium';
|
|
158
|
+
return 'high';
|
|
159
|
+
}
|
|
160
|
+
|
|
119
161
|
export function analyzeFunctionLength(files: SourceFile[]): { score: number; findings: QualityFinding[]; issueCount: number } {
|
|
120
162
|
const allFunctions: FunctionInfo[] = [];
|
|
121
163
|
for (const file of files) {
|
|
164
|
+
// Test files are exempt: a long `it()`/`describe()` body is normal and
|
|
165
|
+
// splitting it produces churn without improving readability. Linting
|
|
166
|
+
// and other quality checks still apply — only structural-length defers.
|
|
167
|
+
if (isTestFile(file.relativePath)) continue;
|
|
122
168
|
allFunctions.push(...extractFunctions(file));
|
|
123
169
|
}
|
|
124
170
|
|
|
@@ -133,13 +179,21 @@ export function analyzeFunctionLength(files: SourceFile[]): { score: number; fin
|
|
|
133
179
|
totalScore += funcScore;
|
|
134
180
|
|
|
135
181
|
if (func.lines > FUNCTION_LENGTH_THRESHOLD) {
|
|
182
|
+
const severity = severityFromBranchiness(func.branches, func.lines);
|
|
183
|
+
if (!severity) continue; // Long but linear — not flagged.
|
|
184
|
+
|
|
136
185
|
findings.push({
|
|
137
|
-
severity
|
|
186
|
+
severity,
|
|
138
187
|
category: 'function-length',
|
|
139
188
|
file: func.file,
|
|
140
189
|
line: func.startLine,
|
|
141
|
-
title: `${func.name}() has ${func.lines} lines
|
|
142
|
-
description:
|
|
190
|
+
title: `${func.name}() has ${func.lines} lines, ~${func.branches} branches`,
|
|
191
|
+
description:
|
|
192
|
+
`Function "${func.name}" exceeds the ${FUNCTION_LENGTH_THRESHOLD}-line threshold by ${func.lines - FUNCTION_LENGTH_THRESHOLD} lines ` +
|
|
193
|
+
`with approximately ${func.branches} decision points (cyclomatic complexity proxy). ` +
|
|
194
|
+
(severity === 'high'
|
|
195
|
+
? 'High branchiness makes this hard to test and review — extract sub-functions or simplify control flow.'
|
|
196
|
+
: 'Long but with manageable branching — consider extracting helpers if the function does multiple things.'),
|
|
143
197
|
});
|
|
144
198
|
}
|
|
145
199
|
}
|