@vibe-forge/definition-core 0.9.1
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/AGENTS.md +35 -0
- package/LICENSE +21 -0
- package/__tests__/definition-core.spec.ts +112 -0
- package/package.json +34 -0
- package/src/index.ts +156 -0
package/AGENTS.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Definition Core 包说明
|
|
2
|
+
|
|
3
|
+
`@vibe-forge/definition-core` 承载 definition 领域共享语义 helper。
|
|
4
|
+
|
|
5
|
+
## 什么时候先看这里
|
|
6
|
+
|
|
7
|
+
- definition 的名称、标识、摘要推导不一致
|
|
8
|
+
- remote rule reference 在不同包里的投影不一致
|
|
9
|
+
- `definition-loader` 和 `workspace-assets` 之间需要共享 definition 领域 helper
|
|
10
|
+
|
|
11
|
+
## 入口
|
|
12
|
+
|
|
13
|
+
- `src/index.ts`
|
|
14
|
+
- definition 名称、标识、摘要、always 语义 helper
|
|
15
|
+
- remote rule reference 到 definition 的投影 helper
|
|
16
|
+
- `__tests__/definition-core.spec.ts`
|
|
17
|
+
|
|
18
|
+
## 当前边界
|
|
19
|
+
|
|
20
|
+
- 本包负责:
|
|
21
|
+
- definition 名称解析
|
|
22
|
+
- spec / entity / skill 标识解析
|
|
23
|
+
- definition 摘要描述解析
|
|
24
|
+
- rule always 语义兼容
|
|
25
|
+
- remote rule reference 的 definition 投影
|
|
26
|
+
- 本包不负责:
|
|
27
|
+
- 文档发现与读取
|
|
28
|
+
- prompt 文本拼装
|
|
29
|
+
- workspace asset 选择
|
|
30
|
+
- task 生命周期编排
|
|
31
|
+
|
|
32
|
+
## 维护约定
|
|
33
|
+
|
|
34
|
+
- 只放 definition 领域共享语义,不放通用路径工具,也不放 prompt builder。
|
|
35
|
+
- 依赖保持轻量,优先只依赖 `@vibe-forge/types`。
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025-present Vibe-Forge.ai
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createRemoteRuleDefinition,
|
|
5
|
+
isAlwaysRule,
|
|
6
|
+
isLocalRuleReference,
|
|
7
|
+
isPathLikeReference,
|
|
8
|
+
isRemoteRuleReference,
|
|
9
|
+
parseScopedReference,
|
|
10
|
+
resolveDefinitionName,
|
|
11
|
+
resolveDocumentDescription,
|
|
12
|
+
resolveDocumentName,
|
|
13
|
+
resolveEntityIdentifier,
|
|
14
|
+
resolveSkillIdentifier,
|
|
15
|
+
resolveSpecIdentifier
|
|
16
|
+
} from '#~/index.js'
|
|
17
|
+
|
|
18
|
+
describe('definition core helpers', () => {
|
|
19
|
+
it('prefers explicit document names and handles index files', () => {
|
|
20
|
+
expect(resolveDocumentName('/tmp/specs/index.md', ' explicit ')).toBe('explicit')
|
|
21
|
+
expect(resolveDocumentName('/tmp/skills/example/SKILL.md', undefined, ['skill.md'])).toBe('example')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('resolves spec, entity, and skill identifiers from conventional files', () => {
|
|
25
|
+
expect(resolveSpecIdentifier('/tmp/specs/my-spec/index.md')).toBe('my-spec')
|
|
26
|
+
expect(resolveEntityIdentifier('/tmp/project/.ai/entities/reviewer/README.md')).toBe('reviewer')
|
|
27
|
+
expect(resolveSkillIdentifier('/tmp/project/.ai/skills/research/SKILL.md')).toBe('research')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('resolves definition names from explicit names, resolved names, and file paths', () => {
|
|
31
|
+
expect(resolveDefinitionName({
|
|
32
|
+
path: '/tmp/project/.ai/rules/base.md',
|
|
33
|
+
body: '',
|
|
34
|
+
attributes: {}
|
|
35
|
+
})).toBe('base')
|
|
36
|
+
|
|
37
|
+
expect(resolveDefinitionName({
|
|
38
|
+
path: '/tmp/project/.ai/specs/release/index.md',
|
|
39
|
+
body: '',
|
|
40
|
+
attributes: {},
|
|
41
|
+
resolvedName: 'demo/release'
|
|
42
|
+
}, ['index.md'])).toBe('demo/release')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('resolves descriptions from explicit descriptions or the first non-empty body line', () => {
|
|
46
|
+
expect(resolveDocumentDescription('first line\nsecond line', ' explicit ', 'fallback')).toBe('explicit')
|
|
47
|
+
expect(resolveDocumentDescription('\nfirst line\nsecond line', undefined, 'fallback')).toBe('first line')
|
|
48
|
+
expect(resolveDocumentDescription('\n\n', undefined, 'fallback')).toBe('fallback')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('supports rule always compatibility across always and alwaysApply', () => {
|
|
52
|
+
expect(isAlwaysRule({ always: true })).toBe(true)
|
|
53
|
+
expect(isAlwaysRule({ alwaysApply: true })).toBe(true)
|
|
54
|
+
expect(isAlwaysRule({ always: false, alwaysApply: true })).toBe(false)
|
|
55
|
+
expect(isAlwaysRule({})).toBe(false)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('identifies local and remote rule references consistently', () => {
|
|
59
|
+
expect(isLocalRuleReference({ path: './rules/base.md' })).toBe(true)
|
|
60
|
+
expect(isLocalRuleReference({ type: 'local', path: './rules/base.md' })).toBe(true)
|
|
61
|
+
expect(isLocalRuleReference({ type: 'remote', tags: ['business'] })).toBe(false)
|
|
62
|
+
|
|
63
|
+
expect(isRemoteRuleReference({ type: 'remote', tags: ['business'] })).toBe(true)
|
|
64
|
+
expect(isRemoteRuleReference({ path: './rules/base.md' })).toBe(false)
|
|
65
|
+
expect(isRemoteRuleReference('rules/base.md')).toBe(false)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('parses scoped references while excluding path-like values', () => {
|
|
69
|
+
expect(parseScopedReference('demo/release')).toEqual({ scope: 'demo', name: 'release' })
|
|
70
|
+
expect(parseScopedReference('./rules/base.md')).toBeUndefined()
|
|
71
|
+
expect(parseScopedReference('/abs/path/rule.md')).toBeUndefined()
|
|
72
|
+
expect(parseScopedReference('release.md')).toBeUndefined()
|
|
73
|
+
expect(parseScopedReference('demo/server.yaml', { pathSuffixes: ['.md', '.json', '.yaml', '.yml'] }))
|
|
74
|
+
.toBeUndefined()
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('detects path-like references with optional glob support', () => {
|
|
78
|
+
expect(isPathLikeReference('./rules/base.md')).toBe(true)
|
|
79
|
+
expect(isPathLikeReference('/abs/path/rule.md')).toBe(true)
|
|
80
|
+
expect(isPathLikeReference('rules/*.md', { allowGlob: true })).toBe(true)
|
|
81
|
+
expect(isPathLikeReference('demo/release')).toBe(false)
|
|
82
|
+
expect(isPathLikeReference('demo/server.yaml', { pathSuffixes: ['.md', '.json', '.yaml', '.yml'] })).toBe(true)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('builds remote rule definitions with normalized descriptions and tags', () => {
|
|
86
|
+
expect(createRemoteRuleDefinition({
|
|
87
|
+
type: 'remote',
|
|
88
|
+
tags: [' business ', '', 'api-develop']
|
|
89
|
+
}, 1)).toEqual({
|
|
90
|
+
path: 'remote-rule-2.md',
|
|
91
|
+
body: [
|
|
92
|
+
'Remote knowledge base tags: business, api-develop',
|
|
93
|
+
'Knowledge base tags: business, api-develop',
|
|
94
|
+
'This rule comes from a remote knowledge base reference and does not correspond to a local file.'
|
|
95
|
+
].join('\n'),
|
|
96
|
+
attributes: {
|
|
97
|
+
name: 'remote:business,api-develop',
|
|
98
|
+
description: 'Remote knowledge base tags: business, api-develop'
|
|
99
|
+
}
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('prefers explicit remote rule descriptions when present', () => {
|
|
104
|
+
expect(
|
|
105
|
+
createRemoteRuleDefinition({
|
|
106
|
+
type: 'remote',
|
|
107
|
+
tags: ['business'],
|
|
108
|
+
desc: 'Look up remote business knowledge when needed'
|
|
109
|
+
}, 0).attributes.description
|
|
110
|
+
).toBe('Look up remote business knowledge when needed')
|
|
111
|
+
})
|
|
112
|
+
})
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@vibe-forge/definition-core",
|
|
3
|
+
"version": "0.9.1",
|
|
4
|
+
"description": "Shared definition-domain helpers for Vibe Forge",
|
|
5
|
+
"imports": {
|
|
6
|
+
"#~/*.js": {
|
|
7
|
+
"__vibe-forge__": {
|
|
8
|
+
"default": "./src/*.ts"
|
|
9
|
+
},
|
|
10
|
+
"default": {
|
|
11
|
+
"import": "./dist/*.mjs",
|
|
12
|
+
"require": "./dist/*.js"
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"__vibe-forge__": {
|
|
19
|
+
"default": "./src/index.ts"
|
|
20
|
+
},
|
|
21
|
+
"default": {
|
|
22
|
+
"import": "./dist/index.mjs",
|
|
23
|
+
"require": "./dist/index.js"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"./package.json": "./package.json"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@vibe-forge/types": "^0.9.0"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"test": "pnpm -C ../.. exec vitest run --workspace vitest.workspace.ts --project bundler packages/definition-core/__tests__"
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { basename, dirname } from 'node:path'
|
|
2
|
+
|
|
3
|
+
import type { Definition, LocalRuleReference, RemoteRuleReference, Rule, RuleReference } from '@vibe-forge/types'
|
|
4
|
+
|
|
5
|
+
const stripExtension = (fileName: string) => fileName.replace(/\.[^/.]+$/, '')
|
|
6
|
+
|
|
7
|
+
const hasAnySuffix = (value: string, suffixes: string[]) => suffixes.some(suffix => value.endsWith(suffix))
|
|
8
|
+
|
|
9
|
+
export const parseScopedReference = (
|
|
10
|
+
value: string,
|
|
11
|
+
options: {
|
|
12
|
+
pathSuffixes?: string[]
|
|
13
|
+
} = {}
|
|
14
|
+
) => {
|
|
15
|
+
const pathSuffixes = options.pathSuffixes ?? ['.md', '.json']
|
|
16
|
+
|
|
17
|
+
if (
|
|
18
|
+
value.startsWith('./') ||
|
|
19
|
+
value.startsWith('../') ||
|
|
20
|
+
value.startsWith('/') ||
|
|
21
|
+
hasAnySuffix(value, pathSuffixes)
|
|
22
|
+
) {
|
|
23
|
+
return undefined
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const separatorIndex = value.indexOf('/')
|
|
27
|
+
if (separatorIndex <= 0) return undefined
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
scope: value.slice(0, separatorIndex),
|
|
31
|
+
name: value.slice(separatorIndex + 1)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const isPathLikeReference = (
|
|
36
|
+
value: string,
|
|
37
|
+
options: {
|
|
38
|
+
pathSuffixes?: string[]
|
|
39
|
+
allowGlob?: boolean
|
|
40
|
+
} = {}
|
|
41
|
+
) => {
|
|
42
|
+
const pathSuffixes = options.pathSuffixes ?? ['.md', '.json']
|
|
43
|
+
const allowGlob = options.allowGlob ?? false
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
value.startsWith('./') ||
|
|
47
|
+
value.startsWith('../') ||
|
|
48
|
+
value.startsWith('/') ||
|
|
49
|
+
(allowGlob && value.includes('*')) ||
|
|
50
|
+
hasAnySuffix(value, pathSuffixes)
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const resolveDocumentName = (
|
|
55
|
+
path: string,
|
|
56
|
+
explicitName?: string,
|
|
57
|
+
indexFileNames: string[] = []
|
|
58
|
+
) => {
|
|
59
|
+
const trimmedName = explicitName?.trim()
|
|
60
|
+
if (trimmedName) return trimmedName
|
|
61
|
+
|
|
62
|
+
const fileName = basename(path).toLowerCase()
|
|
63
|
+
if (indexFileNames.includes(fileName)) {
|
|
64
|
+
return basename(dirname(path))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return stripExtension(basename(path))
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const resolveSpecIdentifier = (path: string, explicitName?: string) => (
|
|
71
|
+
resolveDocumentName(path, explicitName, ['index.md'])
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
export const resolveEntityIdentifier = (path: string, explicitName?: string) => (
|
|
75
|
+
resolveDocumentName(path, explicitName, ['readme.md', 'index.json'])
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
export const resolveSkillIdentifier = (path: string, explicitName?: string) => (
|
|
79
|
+
resolveDocumentName(path, explicitName, ['skill.md'])
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
const getFirstNonEmptyLine = (text: string) =>
|
|
83
|
+
text
|
|
84
|
+
.split('\n')
|
|
85
|
+
.map(line => line.trim())
|
|
86
|
+
.find(Boolean)
|
|
87
|
+
|
|
88
|
+
export const resolveDocumentDescription = (
|
|
89
|
+
body: string,
|
|
90
|
+
explicitDescription?: string,
|
|
91
|
+
fallbackName?: string
|
|
92
|
+
) => {
|
|
93
|
+
const trimmedDescription = explicitDescription?.trim()
|
|
94
|
+
return trimmedDescription || getFirstNonEmptyLine(body) || fallbackName || ''
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export const isAlwaysRule = (attributes: Pick<Rule, 'always' | 'alwaysApply'>) => (
|
|
98
|
+
attributes.always ?? attributes.alwaysApply ?? false
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
export const resolveDefinitionName = <T extends { name?: string }>(
|
|
102
|
+
definition: Definition<T>,
|
|
103
|
+
indexFileNames: string[] = []
|
|
104
|
+
) => definition.resolvedName?.trim() || resolveDocumentName(definition.path, definition.attributes.name, indexFileNames)
|
|
105
|
+
|
|
106
|
+
const toNonEmptyStringArray = (values: unknown): string[] => {
|
|
107
|
+
if (!Array.isArray(values)) return []
|
|
108
|
+
|
|
109
|
+
return values
|
|
110
|
+
.filter((value): value is string => typeof value === 'string')
|
|
111
|
+
.map(value => value.trim())
|
|
112
|
+
.filter(Boolean)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export const isLocalRuleReference = (value: RuleReference): value is LocalRuleReference => {
|
|
116
|
+
return (
|
|
117
|
+
value != null &&
|
|
118
|
+
typeof value === 'object' &&
|
|
119
|
+
typeof (value as { path?: unknown }).path === 'string' &&
|
|
120
|
+
((value as { type?: unknown }).type == null || (value as { type?: unknown }).type === 'local')
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export const isRemoteRuleReference = (value: RuleReference): value is RemoteRuleReference => {
|
|
125
|
+
return (
|
|
126
|
+
value != null &&
|
|
127
|
+
typeof value === 'object' &&
|
|
128
|
+
value.type === 'remote'
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export const createRemoteRuleDefinition = (
|
|
133
|
+
rule: RemoteRuleReference,
|
|
134
|
+
index: number
|
|
135
|
+
): Definition<Rule> => {
|
|
136
|
+
const tags = toNonEmptyStringArray(rule.tags)
|
|
137
|
+
const description = rule.desc?.trim() || (
|
|
138
|
+
tags.length > 0
|
|
139
|
+
? `Remote knowledge base tags: ${tags.join(', ')}`
|
|
140
|
+
: 'Remote knowledge base rule reference'
|
|
141
|
+
)
|
|
142
|
+
const bodyParts = [
|
|
143
|
+
description,
|
|
144
|
+
tags.length > 0 ? `Knowledge base tags: ${tags.join(', ')}` : undefined,
|
|
145
|
+
'This rule comes from a remote knowledge base reference and does not correspond to a local file.'
|
|
146
|
+
].filter((value): value is string => Boolean(value))
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
path: `remote-rule-${index + 1}.md`,
|
|
150
|
+
body: bodyParts.join('\n'),
|
|
151
|
+
attributes: {
|
|
152
|
+
name: tags.length > 0 ? `remote:${tags.join(',')}` : `remote-rule-${index + 1}`,
|
|
153
|
+
description
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|