Skip to content

Commit 932223c

Browse files
committed
fix: make native binding loading lazy to fix MCP test
- Convert eager native binding loading to lazy loading in filters.ts - This allows test setup file to execute before binding is required - Add VITEST and NODE_ENV to MCP test subprocess environment
1 parent df3d097 commit 932223c

File tree

5 files changed

+99
-98
lines changed

5 files changed

+99
-98
lines changed

cli/src/plugins/plugin-core/filters.ts

Lines changed: 36 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,51 @@
1-
import type {
2-
ProjectConfig,
3-
RulePrompt,
4-
SeriName
5-
} from './types'
1+
import type {ProjectConfig, RulePrompt, SeriName} from './types'
62
import * as fs from 'node:fs'
73
import * as path from 'node:path'
84
import {getNativeBinding} from '@/core/native-binding'
95

106
interface SeriesFilterFns {
11-
readonly resolveEffectiveIncludeSeries: (
12-
topLevel?: readonly string[],
13-
typeSpecific?: readonly string[]
14-
) => string[]
15-
readonly matchesSeries: (
16-
seriName: string | readonly string[] | null | undefined,
17-
effectiveIncludeSeries: readonly string[]
18-
) => boolean
7+
readonly resolveEffectiveIncludeSeries: (topLevel?: readonly string[], typeSpecific?: readonly string[]) => string[]
8+
readonly matchesSeries: (seriName: string | readonly string[] | null | undefined, effectiveIncludeSeries: readonly string[]) => boolean
199
readonly resolveSubSeries: (
2010
topLevel?: Readonly<Record<string, readonly string[]>>,
2111
typeSpecific?: Readonly<Record<string, readonly string[]>>
2212
) => Record<string, string[]>
2313
}
2414

25-
function requireSeriesFilterFns(): SeriesFilterFns {
15+
let seriesFilterFnsCache: SeriesFilterFns | undefined
16+
17+
function getSeriesFilterFns(): SeriesFilterFns {
18+
if (seriesFilterFnsCache != null) return seriesFilterFnsCache
19+
2620
const candidate = getNativeBinding<SeriesFilterFns>()
2721
if (candidate == null) {
2822
throw new TypeError('Native series-filter binding is required. Build or install the Rust NAPI package before running tnmsc.')
2923
}
30-
if (typeof candidate.matchesSeries !== 'function'
24+
if (
25+
typeof candidate.matchesSeries !== 'function'
3126
|| typeof candidate.resolveEffectiveIncludeSeries !== 'function'
32-
|| typeof candidate.resolveSubSeries !== 'function') {
27+
|| typeof candidate.resolveSubSeries !== 'function'
28+
) {
3329
throw new TypeError('Native series-filter binding is incomplete. Rebuild the Rust NAPI package before running tnmsc.')
3430
}
31+
seriesFilterFnsCache = candidate
3532
return candidate
3633
}
3734

38-
const {
39-
resolveEffectiveIncludeSeries,
40-
matchesSeries,
41-
resolveSubSeries
42-
}: SeriesFilterFns = requireSeriesFilterFns()
35+
function resolveEffectiveIncludeSeries(topLevel?: readonly string[], typeSpecific?: readonly string[]): string[] {
36+
return getSeriesFilterFns().resolveEffectiveIncludeSeries(topLevel, typeSpecific)
37+
}
38+
39+
function matchesSeries(seriName: string | readonly string[] | null | undefined, effectiveIncludeSeries: readonly string[]): boolean {
40+
return getSeriesFilterFns().matchesSeries(seriName, effectiveIncludeSeries)
41+
}
42+
43+
function resolveSubSeries(
44+
topLevel?: Readonly<Record<string, readonly string[]>>,
45+
typeSpecific?: Readonly<Record<string, readonly string[]>>
46+
): Record<string, string[]> {
47+
return getSeriesFilterFns().resolveSubSeries(topLevel, typeSpecific)
48+
}
4349

4450
/**
4551
* Interface for items that can be filtered by series name
@@ -58,10 +64,7 @@ export function filterByProjectConfig<T extends SeriesFilterable>(
5864
projectConfig: ProjectConfig | undefined,
5965
configPath: FilterConfigPath
6066
): readonly T[] {
61-
const effectiveSeries = resolveEffectiveIncludeSeries(
62-
projectConfig?.includeSeries,
63-
projectConfig?.[configPath]?.includeSeries
64-
)
67+
const effectiveSeries = resolveEffectiveIncludeSeries(projectConfig?.includeSeries, projectConfig?.[configPath]?.includeSeries)
6568
return items.filter(item => matchesSeries(item.seriName, effectiveSeries))
6669
}
6770

@@ -77,10 +80,7 @@ function smartConcatGlob(prefix: string, glob: string): string {
7780
return `${prefix}/${glob}`
7881
}
7982

80-
function extractPrefixAndBaseGlob(
81-
glob: string,
82-
prefixes: readonly string[]
83-
): {prefix: string | null, baseGlob: string} {
83+
function extractPrefixAndBaseGlob(glob: string, prefixes: readonly string[]): {prefix: string | null, baseGlob: string} {
8484
for (const prefix of prefixes) {
8585
const normalizedPrefix = prefix.replaceAll(/\/+$/g, '')
8686
const patterns = [
@@ -95,10 +95,7 @@ function extractPrefixAndBaseGlob(
9595
return {prefix: null, baseGlob: glob}
9696
}
9797

98-
export function applySubSeriesGlobPrefix(
99-
rules: readonly RulePrompt[],
100-
projectConfig: ProjectConfig | undefined
101-
): readonly RulePrompt[] {
98+
export function applySubSeriesGlobPrefix(rules: readonly RulePrompt[], projectConfig: ProjectConfig | undefined): readonly RulePrompt[] {
10299
const subSeries = resolveSubSeries(projectConfig?.subSeries, projectConfig?.rules?.subSeries)
103100
if (Object.keys(subSeries).length === 0) return rules
104101

@@ -115,9 +112,7 @@ export function applySubSeriesGlobPrefix(
115112

116113
const matchedPrefixes: string[] = []
117114
for (const [subdir, seriNames] of Object.entries(normalizedSubSeries)) {
118-
const matched = Array.isArray(rule.seriName)
119-
? rule.seriName.some(name => seriNames.includes(name))
120-
: seriNames.includes(rule.seriName)
115+
const matched = Array.isArray(rule.seriName) ? rule.seriName.some(name => seriNames.includes(name)) : seriNames.includes(rule.seriName)
121116
if (matched) matchedPrefixes.push(subdir)
122117
}
123118

@@ -168,9 +163,7 @@ export function resolveGitInfoDir(projectDir: string): string | null {
168163
const gitdir = path.resolve(projectDir, match[1])
169164
return path.join(gitdir, 'info')
170165
}
171-
}
172-
catch {
173-
} // ignore read errors
166+
} catch {} // ignore read errors
174167
}
175168

176169
return null
@@ -193,8 +186,7 @@ export function findAllGitRepos(rootDir: string, maxDepth = 5): string[] {
193186
const raw = fs.readdirSync(dir, {withFileTypes: true})
194187
if (!Array.isArray(raw)) return
195188
entries = raw
196-
}
197-
catch {
189+
} catch {
198190
return
199191
}
200192

@@ -229,8 +221,7 @@ export function findGitModuleInfoDirs(dotGitDir: string): string[] {
229221
const raw = fs.readdirSync(dir, {withFileTypes: true})
230222
if (!Array.isArray(raw)) return
231223
entries = raw
232-
}
233-
catch {
224+
} catch {
234225
return
235226
}
236227

@@ -245,8 +236,7 @@ export function findGitModuleInfoDirs(dotGitDir: string): string[] {
245236
const raw = fs.readdirSync(path.join(dir, 'modules'), {withFileTypes: true})
246237
if (!Array.isArray(raw)) return
247238
subEntries = raw
248-
}
249-
catch {
239+
} catch {
250240
return
251241
}
252242
for (const sub of subEntries) {
@@ -259,8 +249,7 @@ export function findGitModuleInfoDirs(dotGitDir: string): string[] {
259249
const raw = fs.readdirSync(modulesDir, {withFileTypes: true})
260250
if (!Array.isArray(raw)) return results
261251
topEntries = raw
262-
}
263-
catch {
252+
} catch {
264253
return results
265254
}
266255

mcp/src/server.test.ts

Lines changed: 15 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const tempDirs: string[] = []
1212
const serverMainPath = fileURLToPath(new URL('./main.ts', import.meta.url))
1313
const tsxPackageJsonPath = fileURLToPath(new URL('../node_modules/tsx/package.json', import.meta.url))
1414
const tsxCliPath = path.join(path.dirname(tsxPackageJsonPath), 'dist', 'cli.mjs')
15+
const cliNativeBindingSetupPath = fileURLToPath(new URL('../test/setup-native-binding.ts', import.meta.url))
1516

1617
function createTempDir(prefix: string): string {
1718
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix))
@@ -20,8 +21,7 @@ function createTempDir(prefix: string): string {
2021
}
2122

2223
function createTransportEnv(homeDir: string): Record<string, string> {
23-
const envEntries = Object.entries(process.env)
24-
.filter((entry): entry is [string, string] => typeof entry[1] === 'string')
24+
const envEntries = Object.entries(process.env).filter((entry): entry is [string, string] => typeof entry[1] === 'string')
2525

2626
return {
2727
...Object.fromEntries(envEntries),
@@ -30,7 +30,9 @@ function createTransportEnv(homeDir: string): Record<string, string> {
3030
XDG_CACHE_HOME: path.join(homeDir, '.cache'),
3131
XDG_CONFIG_HOME: path.join(homeDir, '.config'),
3232
XDG_DATA_HOME: path.join(homeDir, '.local', 'share'),
33-
XDG_STATE_HOME: path.join(homeDir, '.local', 'state')
33+
XDG_STATE_HOME: path.join(homeDir, '.local', 'state'),
34+
VITEST: 'true',
35+
NODE_ENV: 'test'
3436
}
3537
}
3638

@@ -40,17 +42,13 @@ interface TextContentBlock {
4042
}
4143

4244
function getTextBlock(result: unknown): string {
43-
if (
44-
typeof result !== 'object'
45-
|| result == null
46-
|| !('content' in result)
47-
|| !Array.isArray(result.content)
48-
) {
45+
if (typeof result !== 'object' || result == null || !('content' in result) || !Array.isArray(result.content)) {
4946
throw new Error('Expected content blocks in MCP result')
5047
}
5148

52-
const textBlock = result.content
53-
.find((block): block is TextContentBlock => typeof block === 'object' && block != null && 'type' in block && (block as {type?: unknown}).type === 'text')
49+
const textBlock = result.content.find(
50+
(block): block is TextContentBlock => typeof block === 'object' && block != null && 'type' in block && (block as {type?: unknown}).type === 'text'
51+
)
5452
if (textBlock?.text == null) throw new Error('Expected a text content block in MCP result')
5553
return textBlock.text
5654
}
@@ -73,28 +71,21 @@ describe('memory-sync MCP stdio server', () => {
7371
})
7472
const transport = new StdioClientTransport({
7573
command: process.execPath,
76-
args: [tsxCliPath, serverMainPath],
74+
args: [tsxCliPath, '--import', cliNativeBindingSetupPath, serverMainPath],
7775
cwd: workspaceDir,
7876
env: createTransportEnv(homeDir),
7977
stderr: 'pipe'
8078
})
8179
let stderrOutput = ''
8280

83-
transport.stderr?.on('data', (chunk: Buffer | string) => stderrOutput += String(chunk))
81+
transport.stderr?.on('data', (chunk: Buffer | string) => (stderrOutput += String(chunk)))
8482

8583
try {
8684
await client.connect(transport)
8785

8886
const tools = await client.listTools()
89-
expect(tools.tools.map(tool => tool.name).sort()).toEqual([
90-
'apply_prompt_translation',
91-
'get_prompt',
92-
'list_prompts',
93-
'upsert_prompt_src'
94-
])
95-
expect(
96-
tools.tools.find(tool => tool.name === 'apply_prompt_translation')?.inputSchema.properties
97-
).toMatchObject({
87+
expect(tools.tools.map(tool => tool.name).sort()).toEqual(['apply_prompt_translation', 'get_prompt', 'list_prompts', 'upsert_prompt_src'])
88+
expect(tools.tools.find(tool => tool.name === 'apply_prompt_translation')?.inputSchema.properties).toMatchObject({
9889
promptId: expect.any(Object),
9990
enContent: expect.any(Object),
10091
distContent: expect.any(Object)
@@ -241,14 +232,12 @@ describe('memory-sync MCP stdio server', () => {
241232
})
242233
const filteredPrompts = parseToolResult<{prompts: {promptId: string}[]}>(filteredListResult)
243234
expect(filteredPrompts.prompts.map(prompt => prompt.promptId)).toEqual(['command:demo/build'])
244-
}
245-
catch (error) {
235+
} catch (error) {
246236
if (stderrOutput.trim().length === 0) throw error
247237

248238
const errorMessage = error instanceof Error ? error.message : String(error)
249239
throw new Error(`${errorMessage}\nMCP stderr:\n${stderrOutput.trim()}`)
250-
}
251-
finally {
240+
} finally {
252241
await client.close()
253242
}
254243
})

mcp/test/setup-native-binding.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
function resolveEffectiveIncludeSeries(topLevel?: readonly string[], typeSpecific?: readonly string[]): string[] {
2+
if (topLevel == null && typeSpecific == null) return []
3+
return [...new Set([...topLevel ?? [], ...typeSpecific ?? []])]
4+
}
5+
6+
function matchesSeries(seriName: string | readonly string[] | null | undefined, effectiveIncludeSeries: readonly string[]): boolean {
7+
if (seriName == null) return true
8+
if (effectiveIncludeSeries.length === 0) return true
9+
if (typeof seriName === 'string') return effectiveIncludeSeries.includes(seriName)
10+
return seriName.some(name => effectiveIncludeSeries.includes(name))
11+
}
12+
13+
function resolveSubSeries(
14+
topLevel?: Readonly<Record<string, readonly string[]>>,
15+
typeSpecific?: Readonly<Record<string, readonly string[]>>
16+
): Record<string, string[]> {
17+
if (topLevel == null && typeSpecific == null) return {}
18+
19+
const merged: Record<string, string[]> = {}
20+
for (const [key, values] of Object.entries(topLevel ?? {})) merged[key] = [...values]
21+
22+
for (const [key, values] of Object.entries(typeSpecific ?? {})) {
23+
const existingValues = merged[key] ?? []
24+
merged[key] = Object.hasOwn(merged, key) ? [...new Set([...existingValues, ...values])] : [...values]
25+
}
26+
27+
return merged
28+
}
29+
30+
const testGlobals = globalThis as typeof globalThis & {
31+
__TNMSC_TEST_NATIVE_BINDING__?: {
32+
resolveEffectiveIncludeSeries: typeof resolveEffectiveIncludeSeries
33+
matchesSeries: typeof matchesSeries
34+
resolveSubSeries: typeof resolveSubSeries
35+
}
36+
}
37+
38+
testGlobals.__TNMSC_TEST_NATIVE_BINDING__ = {
39+
resolveEffectiveIncludeSeries,
40+
matchesSeries,
41+
resolveSubSeries
42+
}

mcp/tsconfig.json

Lines changed: 5 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,22 +5,16 @@
55
"incremental": true,
66
"composite": false,
77
"target": "ESNext",
8-
"lib": [
9-
"ESNext"
10-
],
8+
"lib": ["ESNext"],
119
"moduleDetection": "force",
1210
"useDefineForClassFields": true,
1311
"module": "ESNext",
1412
"moduleResolution": "Bundler",
1513
"paths": {
16-
"@/*": [
17-
"./src/*"
18-
]
14+
"@/*": ["./src/*"]
1915
},
2016
"resolveJsonModule": true,
21-
"types": [
22-
"node"
23-
],
17+
"types": ["node"],
2418
"allowImportingTsExtensions": true,
2519
"strict": true,
2620
"strictBindCallApply": true,
@@ -57,14 +51,6 @@
5751
"verbatimModuleSyntax": true,
5852
"skipLibCheck": true
5953
},
60-
"include": [
61-
"src/**/*",
62-
"eslint.config.ts",
63-
"tsdown.config.ts",
64-
"vitest.config.ts"
65-
],
66-
"exclude": [
67-
"../node_modules",
68-
"dist"
69-
]
54+
"include": ["src/**/*", "test/**/*.ts", "eslint.config.ts", "tsdown.config.ts", "vitest.config.ts"],
55+
"exclude": ["../node_modules", "dist"]
7056
}

mcp/tsconfig.lib.json

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,5 @@
33
"compilerOptions": {
44
"noEmit": true
55
},
6-
"include": [
7-
"src/**/*",
8-
"eslint.config.ts",
9-
"tsdown.config.ts",
10-
"vitest.config.ts"
11-
]
6+
"include": ["src/**/*", "test/**/*.ts", "eslint.config.ts", "tsdown.config.ts", "vitest.config.ts"]
127
}

0 commit comments

Comments
 (0)