@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 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
+ }