Skip to content

Commit b331190

Browse files
authored
Merge pull request #19 from TrueNine/dev
Merge dev into main
2 parents 3a35d88 + 050e414 commit b331190

33 files changed

+1927
-15
lines changed

.git-hooks/sync-versions.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ if (eslintConfigVersion) {
4444
const packages: readonly PackageEntry[] = [
4545
{ path: 'cli/package.json', name: 'cli' },
4646
{ path: 'gui/package.json', name: 'gui' },
47+
{ path: 'doc/package.json', name: 'doc' },
4748
]
4849

4950
let changed = false
@@ -119,7 +120,7 @@ if (changed) {
119120
console.log('\n📦 Versions synced, auto-staging changes...')
120121
try {
121122
execSync(
122-
'git add cli/package.json gui/package.json gui/src-tauri/Cargo.toml gui/src-tauri/tauri.conf.json cli/public/tnmsc.example.json packages/init-bundle/public/public/tnmsc.example.json',
123+
'git add cli/package.json gui/package.json doc/package.json gui/src-tauri/Cargo.toml gui/src-tauri/tauri.conf.json cli/public/tnmsc.example.json packages/init-bundle/public/public/tnmsc.example.json',
123124
{ stdio: 'inherit' }
124125
)
125126
console.log('✅ Staged modified files')

.github/workflows/ci.yml

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,6 @@ on:
44
pull_request:
55
branches:
66
- main
7-
push:
8-
branches:
9-
- dev
107

118
jobs:
129
build:

.github/workflows/test.yml

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,6 @@ on:
44
pull_request:
55
branches:
66
- main
7-
push:
8-
branches:
9-
- dev
107

118
jobs:
129
test:

cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@truenine/memory-sync-cli",
33
"type": "module",
4-
"version": "2026.10218.12101",
4+
"version": "2026.10218.12326",
55
"description": "TrueNine Memory Synchronization CLI",
66
"author": "TrueNine",
77
"license": "AGPL-3.0-only",

cli/public/tnmsc.example.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"version": "2026.10218.12101",
2+
"version": "2026.10218.12326",
33
"workspaceDir": "~/project",
44
"shadowSourceProject": {
55
"name": "tnmsc-shadow",
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import type {CollectedInputContext, OutputPluginContext, Project, RulePrompt} from '@/types'
2+
import type {RelativePath, RootPath} from '@/types/FileSystemTypes'
3+
import * as fs from 'node:fs'
4+
import * as os from 'node:os'
5+
import * as path from 'node:path'
6+
import * as fc from 'fast-check'
7+
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
8+
import {FilePathKind, NamingCaseKind, PromptKind} from '@/types'
9+
import {ClaudeCodeCLIOutputPlugin} from './ClaudeCodeCLIOutputPlugin'
10+
11+
function createMockRelativePath(pathStr: string, basePath: string): RelativePath {
12+
return {pathKind: FilePathKind.Relative, path: pathStr, basePath, getDirectoryName: () => pathStr, getAbsolutePath: () => path.join(basePath, pathStr)}
13+
}
14+
15+
class TestablePlugin extends ClaudeCodeCLIOutputPlugin {
16+
private mockHomeDir: string | null = null
17+
public setMockHomeDir(dir: string | null): void { this.mockHomeDir = dir }
18+
protected override getHomeDir(): string { return this.mockHomeDir ?? super.getHomeDir() }
19+
public testBuildRuleFileName(rule: RulePrompt): string { return (this as any).buildRuleFileName(rule) }
20+
public testBuildRuleContent(rule: RulePrompt): string { return (this as any).buildRuleContent(rule) }
21+
}
22+
23+
function createMockRulePrompt(opts: {series: string, ruleName: string, globs: readonly string[], scope?: 'global' | 'project', content?: string}): RulePrompt {
24+
const content = opts.content ?? '# Rule body'
25+
return {type: PromptKind.Rule, content, length: content.length, filePathKind: FilePathKind.Relative, dir: createMockRelativePath('.', ''), markdownContents: [], yamlFrontMatter: {description: 'ignored', globs: opts.globs}, series: opts.series, ruleName: opts.ruleName, globs: opts.globs, scope: opts.scope ?? 'global'} as RulePrompt
26+
}
27+
28+
const seriesGen = fc.stringMatching(/^[a-z0-9]{1,5}$/)
29+
const ruleNameGen = fc.stringMatching(/^[a-z][a-z0-9-]{0,14}$/)
30+
const globGen = fc.stringMatching(/^[a-z*/.]{1,30}$/).filter(s => s.length > 0)
31+
const globsGen = fc.array(globGen, {minLength: 1, maxLength: 5})
32+
const contentGen = fc.string({minLength: 1, maxLength: 200}).filter(s => s.trim().length > 0)
33+
34+
describe('claudeCodeCLIOutputPlugin property tests', () => {
35+
let tempDir: string, plugin: TestablePlugin, mockContext: OutputPluginContext
36+
37+
beforeEach(() => {
38+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-prop-'))
39+
plugin = new TestablePlugin()
40+
plugin.setMockHomeDir(tempDir)
41+
mockContext = {
42+
collectedInputContext: {
43+
workspace: {projects: [], directory: createMockRelativePath('.', tempDir)},
44+
globalMemory: {type: PromptKind.GlobalMemory, content: 'mem', filePathKind: FilePathKind.Absolute, dir: createMockRelativePath('.', tempDir), markdownContents: []},
45+
fastCommands: [],
46+
subAgents: [],
47+
skills: []
48+
} as unknown as CollectedInputContext,
49+
logger: {debug: vi.fn(), trace: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn()} as any,
50+
fs,
51+
path,
52+
glob: {} as any
53+
}
54+
}, 30000)
55+
56+
afterEach(() => {
57+
try { fs.rmSync(tempDir, {recursive: true, force: true}) }
58+
catch {}
59+
})
60+
61+
describe('rule file name format', () => {
62+
it('should always produce rule-{series}-{ruleName}.md', async () => {
63+
await fc.assert(fc.asyncProperty(seriesGen, ruleNameGen, async (series, ruleName) => {
64+
const rule = createMockRulePrompt({series, ruleName, globs: []})
65+
const fileName = plugin.testBuildRuleFileName(rule)
66+
expect(fileName).toBe(`rule-${series}-${ruleName}.md`)
67+
expect(fileName).toMatch(/^rule-.[^-\n\r\u2028\u2029]*-.+\.md$/)
68+
}), {numRuns: 100})
69+
})
70+
})
71+
72+
describe('rule content format constraints', () => {
73+
it('should never contain globs field in frontmatter', async () => {
74+
await fc.assert(fc.asyncProperty(seriesGen, ruleNameGen, globsGen, contentGen, async (series, ruleName, globs, content) => {
75+
const rule = createMockRulePrompt({series, ruleName, globs, content})
76+
const output = plugin.testBuildRuleContent(rule)
77+
expect(output).not.toMatch(/^globs:/m)
78+
}), {numRuns: 100})
79+
})
80+
81+
it('should use paths field when globs are present', async () => {
82+
await fc.assert(fc.asyncProperty(seriesGen, ruleNameGen, globsGen, contentGen, async (series, ruleName, globs, content) => {
83+
const rule = createMockRulePrompt({series, ruleName, globs, content})
84+
const output = plugin.testBuildRuleContent(rule)
85+
expect(output).toContain('paths:')
86+
}), {numRuns: 100})
87+
})
88+
89+
it('should wrap frontmatter in --- delimiters when globs exist', async () => {
90+
await fc.assert(fc.asyncProperty(seriesGen, ruleNameGen, globsGen, contentGen, async (series, ruleName, globs, content) => {
91+
const rule = createMockRulePrompt({series, ruleName, globs, content})
92+
const output = plugin.testBuildRuleContent(rule)
93+
const lines = output.split('\n')
94+
expect(lines[0]).toBe('---')
95+
expect(lines.indexOf('---', 1)).toBeGreaterThan(0)
96+
}), {numRuns: 100})
97+
})
98+
99+
it('should have no frontmatter when globs are empty', async () => {
100+
await fc.assert(fc.asyncProperty(seriesGen, ruleNameGen, contentGen, async (series, ruleName, content) => {
101+
const rule = createMockRulePrompt({series, ruleName, globs: [], content})
102+
const output = plugin.testBuildRuleContent(rule)
103+
expect(output).not.toContain('---')
104+
expect(output).toBe(content)
105+
}), {numRuns: 100})
106+
})
107+
108+
it('should preserve rule body content after frontmatter', async () => {
109+
await fc.assert(fc.asyncProperty(seriesGen, ruleNameGen, globsGen, contentGen, async (series, ruleName, globs, content) => {
110+
const rule = createMockRulePrompt({series, ruleName, globs, content})
111+
const output = plugin.testBuildRuleContent(rule)
112+
expect(output).toContain(content)
113+
}), {numRuns: 100})
114+
})
115+
116+
it('should list each glob as a YAML array item under paths', async () => {
117+
await fc.assert(fc.asyncProperty(seriesGen, ruleNameGen, globsGen, contentGen, async (series, ruleName, globs, content) => {
118+
const rule = createMockRulePrompt({series, ruleName, globs, content})
119+
const output = plugin.testBuildRuleContent(rule)
120+
for (const g of globs) expect(output).toMatch(new RegExp(`- "${g.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')}"|- ${g.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')}`))
121+
}), {numRuns: 100})
122+
})
123+
})
124+
125+
describe('write output format verification', () => {
126+
it('should write global rule files with correct format to ~/.claude/rules/', async () => {
127+
await fc.assert(fc.asyncProperty(seriesGen, ruleNameGen, globsGen, contentGen, async (series, ruleName, globs, content) => {
128+
const rule = createMockRulePrompt({series, ruleName, globs, scope: 'global', content})
129+
const ctx = {...mockContext, collectedInputContext: {...mockContext.collectedInputContext, rules: [rule]}} as any
130+
await plugin.writeGlobalOutputs(ctx)
131+
const filePath = path.join(tempDir, '.claude', 'rules', `rule-${series}-${ruleName}.md`)
132+
expect(fs.existsSync(filePath)).toBe(true)
133+
const written = fs.readFileSync(filePath, 'utf8')
134+
expect(written).toContain('paths:')
135+
expect(written).not.toMatch(/^globs:/m)
136+
expect(written).toContain(content)
137+
}), {numRuns: 30})
138+
})
139+
140+
it('should write project rule files to {project}/.claude/rules/', async () => {
141+
await fc.assert(fc.asyncProperty(seriesGen, ruleNameGen, globsGen, contentGen, async (series, ruleName, globs, content) => {
142+
const mockProject: Project = {
143+
name: 'proj',
144+
dirFromWorkspacePath: createMockRelativePath('proj', tempDir),
145+
rootMemoryPrompt: {type: PromptKind.ProjectRootMemory, content: '', filePathKind: FilePathKind.Root, dir: createMockRelativePath('.', tempDir) as unknown as RootPath, markdownContents: [], length: 0, yamlFrontMatter: {namingCase: NamingCaseKind.KebabCase}},
146+
childMemoryPrompts: [],
147+
sourceFiles: []
148+
}
149+
const rule = createMockRulePrompt({series, ruleName, globs, scope: 'project', content})
150+
const ctx = {...mockContext, collectedInputContext: {...mockContext.collectedInputContext, workspace: {...mockContext.collectedInputContext.workspace, projects: [mockProject]}, rules: [rule]}} as any
151+
await plugin.writeProjectOutputs(ctx)
152+
const filePath = path.join(tempDir, 'proj', '.claude', 'rules', `rule-${series}-${ruleName}.md`)
153+
expect(fs.existsSync(filePath)).toBe(true)
154+
const written = fs.readFileSync(filePath, 'utf8')
155+
expect(written).toContain('paths:')
156+
expect(written).toContain(content)
157+
}), {numRuns: 30})
158+
})
159+
})
160+
})

0 commit comments

Comments
 (0)