dsh-plugin-git-commit-push 0.0.0-stage → 1.0.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/LICENSE +21 -0
- package/README.en.md +473 -0
- package/README.md +381 -2
- package/SKILL.md +79 -0
- package/capture-git-format.mjs +93 -0
- package/cordis.patch.yml +35 -0
- package/e2e-check.mjs +26 -0
- package/git-commit-push.config.json +17 -0
- package/icon.svg +1 -0
- package/index.js +908 -0
- package/lib/analyze.js +517 -0
- package/lib/config.js +300 -0
- package/lib/git.js +562 -0
- package/lib/profile-edit.mjs +118 -0
- package/lib/schema.js +111 -0
- package/lib/skill.js +95 -0
- package/lib/survey.js +225 -0
- package/lib/test-fixture.mjs +64 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +81 -4
- package/self-test-git.mjs +442 -0
- package/self-test.mjs +913 -0
- package/setup.ps1 +226 -0
- package/setup.sh +190 -0
package/self-test.mjs
ADDED
|
@@ -0,0 +1,913 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-test for the parts of dsh-plugin-git-commit-push that do not need a live DSH
|
|
3
|
+
* host or a real repository: the deterministic message generator, the change
|
|
4
|
+
* classifier, and the two porcelain parsers.
|
|
5
|
+
*
|
|
6
|
+
* The git parsers are fed byte-exact `-z` output captured from real git, so a
|
|
7
|
+
* parser regression shows up here rather than as a wrong commit.
|
|
8
|
+
*
|
|
9
|
+
* The last two sections check the packaging contract instead of the code: the
|
|
10
|
+
* `dsh.bundle.patch` declaration and the mount row it points at, the display
|
|
11
|
+
* metadata the Plugins page reads, whether the tarball npm would publish
|
|
12
|
+
* actually carries every module the entry point imports, and the settings and
|
|
13
|
+
* skill contracts an npm install depends on.
|
|
14
|
+
*
|
|
15
|
+
* Run it with node (any platform):
|
|
16
|
+
*
|
|
17
|
+
* node self-test.mjs
|
|
18
|
+
*
|
|
19
|
+
* On Windows with the bundled runtime:
|
|
20
|
+
* & "$env:USERPROFILE\.dsh\dsh-runtimes\dsh-primary-runtime\dependencies\node\bin\node.exe" self-test.mjs
|
|
21
|
+
*/
|
|
22
|
+
import assert from 'node:assert/strict'
|
|
23
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
24
|
+
import { tmpdir } from 'node:os'
|
|
25
|
+
import { dirname, join, resolve } from 'node:path'
|
|
26
|
+
import { fileURLToPath } from 'node:url'
|
|
27
|
+
|
|
28
|
+
import { git, parseStatusZ, tagNameError } from './lib/git.js'
|
|
29
|
+
import { buildMessage, inferType, scopeOf, totalsOf, typeOfPath, declaredSymbols, removedDeclarationCount, renderCard } from './lib/analyze.js'
|
|
30
|
+
import { normalizeEntries } from './lib/survey.js'
|
|
31
|
+
import { loadSettingsReport, DEFAULTS, FIELDS as CONFIG_FIELDS, IDENTITY_FIELDS, resolveSettings, uiOverrides } from './lib/config.js'
|
|
32
|
+
import { buildConfigSchema, loadSchemaLibrary, schemaBuildProblem, CONFIG } from './lib/schema.js'
|
|
33
|
+
import { parseSkillFile, skillDefinition, SKILL_NAME, SKILL_PATH } from './lib/skill.js'
|
|
34
|
+
import { apply, applyCard, inject, COMMAND_NAME, parseCommitCommand, run, TOOL_DEFINITION, toolDefinitionProblems } from './index.js'
|
|
35
|
+
|
|
36
|
+
let passed = 0
|
|
37
|
+
const failures = []
|
|
38
|
+
|
|
39
|
+
/** Run one named check, collecting rather than throwing on the first failure. */
|
|
40
|
+
function check(label, fn) {
|
|
41
|
+
try {
|
|
42
|
+
fn()
|
|
43
|
+
passed += 1
|
|
44
|
+
console.log(` ok ${label}`)
|
|
45
|
+
} catch (error) {
|
|
46
|
+
failures.push({ label, error })
|
|
47
|
+
console.log(` FAIL ${label}\n ${error.message}`)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function checkAsync(label, fn) {
|
|
52
|
+
try {
|
|
53
|
+
await fn()
|
|
54
|
+
passed += 1
|
|
55
|
+
console.log(` ok ${label}`)
|
|
56
|
+
} catch (error) {
|
|
57
|
+
failures.push({ label, error })
|
|
58
|
+
console.log(` FAIL ${label}\n ${error.message}`)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function statMap(entries) {
|
|
63
|
+
return new Map(Object.entries(entries))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
console.log('\nporcelain -z parsing')
|
|
67
|
+
|
|
68
|
+
check('status: plain modify', () => {
|
|
69
|
+
const parsed = parseStatusZ(' M src/a.js\u0000')
|
|
70
|
+
assert.deepEqual(parsed, [{ xy: ' M', path: 'src/a.js' }])
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
check('status: untracked', () => {
|
|
74
|
+
const parsed = parseStatusZ('?? src/new.js\u0000')
|
|
75
|
+
assert.deepEqual(parsed, [{ xy: '??', path: 'src/new.js' }])
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
check('status: rename consumes the origin field', () => {
|
|
79
|
+
// Real git emits `R newpath\0oldpath\0` for a staged rename.
|
|
80
|
+
const parsed = parseStatusZ('R src/new.js\u0000src/old.js\u0000 M src/b.js\u0000')
|
|
81
|
+
assert.deepEqual(parsed, [
|
|
82
|
+
{ xy: 'R ', path: 'src/new.js', origPath: 'src/old.js' },
|
|
83
|
+
{ xy: ' M', path: 'src/b.js' },
|
|
84
|
+
])
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
check('status: multiple records and non-ascii paths', () => {
|
|
88
|
+
const parsed = parseStatusZ(' M 中文/文件.js\u0000A src/x.js\u0000')
|
|
89
|
+
assert.deepEqual(parsed.map(entry => entry.path), ['中文/文件.js', 'src/x.js'])
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
check('normalizeEntries keeps status/staged/unstaged', () => {
|
|
93
|
+
const entries = normalizeEntries(parseStatusZ('?? a.js\u0000M b.js\u0000 M c.js\u0000D d.js\u0000'))
|
|
94
|
+
assert.deepEqual(entries.map(entry => entry.status), ['?', 'M', 'M', 'D'])
|
|
95
|
+
assert.equal(entries[1].staged, true)
|
|
96
|
+
assert.equal(entries[2].staged, false)
|
|
97
|
+
assert.equal(entries[2].unstaged, true)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
console.log('\nchange classification')
|
|
101
|
+
|
|
102
|
+
check('feat for new source', () => assert.equal(typeOfPath('src/a.ts', 'A'), 'feat'))
|
|
103
|
+
check('docs for markdown', () => assert.equal(typeOfPath('README.md', 'M'), 'docs'))
|
|
104
|
+
check('docs for a docs directory', () => assert.equal(typeOfPath('docs/guide/x.txt', 'M'), 'docs'))
|
|
105
|
+
check('test for a test path', () => assert.equal(typeOfPath('src/a.test.ts', 'M'), 'test'))
|
|
106
|
+
check('test for a __tests__ directory', () => assert.equal(typeOfPath('pkg/__tests__/a.ts', 'M'), 'test'))
|
|
107
|
+
check('style for css', () => assert.equal(typeOfPath('src/a.css', 'M'), 'style'))
|
|
108
|
+
check('build for a lockfile', () => assert.equal(typeOfPath('pnpm-lock.yaml', 'M'), 'build'))
|
|
109
|
+
check('build for package.json', () => assert.equal(typeOfPath('package.json', 'M'), 'build'))
|
|
110
|
+
check('ci for a workflow', () => assert.equal(typeOfPath('.github/workflows/ci.yml', 'M'), 'ci'))
|
|
111
|
+
check('scope skips the src wrapper', () => assert.equal(scopeOf(['src/foo/a.ts', 'src/foo/b.ts']), 'foo'))
|
|
112
|
+
check('scope is undefined for scattered root files', () => assert.equal(scopeOf(['a.ts', 'b.ts']), undefined))
|
|
113
|
+
check('scope for a single nested file', () => assert.equal(scopeOf(['src/foo/a.ts']), 'foo'))
|
|
114
|
+
|
|
115
|
+
console.log('\ncommit type inference')
|
|
116
|
+
|
|
117
|
+
check('new source files are a feature', () => {
|
|
118
|
+
assert.equal(inferType([{ status: 'A', path: 'src/a.ts' }]), 'feat')
|
|
119
|
+
})
|
|
120
|
+
check('new docs are docs, not feat', () => {
|
|
121
|
+
assert.equal(inferType([{ status: 'A', path: 'docs/x.md' }]), 'docs')
|
|
122
|
+
})
|
|
123
|
+
check('all deletions are a refactor', () => {
|
|
124
|
+
assert.equal(inferType([{ status: 'D', path: 'src/a.ts' }, { status: 'D', path: 'src/b.ts' }]), 'refactor')
|
|
125
|
+
})
|
|
126
|
+
check('mixed edits never claim fix', () => {
|
|
127
|
+
const type = inferType([{ status: 'M', path: 'src/a.ts' }, { status: 'M', path: 'src/b.ts' }])
|
|
128
|
+
assert.notEqual(type, 'fix')
|
|
129
|
+
})
|
|
130
|
+
check('inferType always returns a string', () => {
|
|
131
|
+
for (const entries of [[], [{ status: 'M', path: 'x.js' }], [{ status: '?', path: '.' }]]) {
|
|
132
|
+
assert.equal(typeof inferType(entries), 'string')
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
console.log('\nmessage generation')
|
|
137
|
+
|
|
138
|
+
check('subject carries type and scope', () => {
|
|
139
|
+
const built = buildMessage({
|
|
140
|
+
entries: [{ status: 'M', path: 'src/foo/a.ts' }, { status: 'M', path: 'src/foo/b.ts' }],
|
|
141
|
+
stats: statMap({ 'src/foo/a.ts': { added: 3, deleted: 1, binary: false }, 'src/foo/b.ts': { added: 2, deleted: 0, binary: false } }),
|
|
142
|
+
unifiedDiff: '',
|
|
143
|
+
})
|
|
144
|
+
assert.match(built.message.split('\n')[0], /^feat\(foo\): /)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
check('a declared symbol names the subject', () => {
|
|
148
|
+
const built = buildMessage({
|
|
149
|
+
entries: [{ status: 'M', path: 'src/a.ts' }],
|
|
150
|
+
stats: statMap({ 'src/a.ts': { added: 5, deleted: 0, binary: false } }),
|
|
151
|
+
unifiedDiff: '+export function parseThing(input) {\n',
|
|
152
|
+
})
|
|
153
|
+
assert.match(built.message, /parseThing/)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
check('buildMessage survives empty and single input', () => {
|
|
157
|
+
assert.doesNotThrow(() => buildMessage({ entries: [], stats: new Map(), unifiedDiff: '' }))
|
|
158
|
+
assert.doesNotThrow(() => buildMessage({ entries: [{ status: 'M', path: 'a.js' }], stats: new Map(), unifiedDiff: '' }))
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
check('english mode produces an english subject', () => {
|
|
162
|
+
const built = buildMessage({
|
|
163
|
+
entries: [{ status: 'M', path: 'src/a.ts' }],
|
|
164
|
+
stats: statMap({ 'src/a.ts': { added: 1, deleted: 1, binary: false } }),
|
|
165
|
+
unifiedDiff: '+export function thing() {}\n',
|
|
166
|
+
language: 'en',
|
|
167
|
+
})
|
|
168
|
+
assert.match(built.message, /update thing/)
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
check('english mode localizes the body too, not just the subject', () => {
|
|
172
|
+
// A commit whose subject is English and whose notes are Chinese reads like an
|
|
173
|
+
// accident; the language setting covers the whole message.
|
|
174
|
+
const entries = [{ status: 'M', path: 'src/a.ts' }, { status: 'M', path: 'src/b.ts' }]
|
|
175
|
+
const stats = statMap({ 'src/a.ts': { added: 2, deleted: 1, binary: false }, 'src/b.ts': { added: 1, deleted: 1, binary: false } })
|
|
176
|
+
const english = buildMessage({ entries, stats, unifiedDiff: '+export function thing() {}\n', language: 'en' })
|
|
177
|
+
assert.equal(/[\u4e00-\u9fa5]/u.test(english.message), false, english.message)
|
|
178
|
+
assert.match(english.message, /- [^\n]*· src\/a\.ts/)
|
|
179
|
+
assert.match(english.message, /- [^\n]*· src\/b\.ts/)
|
|
180
|
+
|
|
181
|
+
const chinese = buildMessage({ entries, stats, unifiedDiff: '+export function thing() {}\n' })
|
|
182
|
+
const bodyLines = chinese.message.split('\n').filter(line => line.startsWith('- '))
|
|
183
|
+
assert.equal(bodyLines.length, 2)
|
|
184
|
+
for (const line of bodyLines) assert.match(line, /[\u4e00-\u9fa5]/u, line)
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
console.log('\none Conventional-Commits note per file')
|
|
188
|
+
|
|
189
|
+
const noteStats = statMap({
|
|
190
|
+
'src/api/retry.ts': { added: 8, deleted: 3, binary: false },
|
|
191
|
+
'docs/guide.md': { added: 4, deleted: 0, binary: false },
|
|
192
|
+
'src/api/retry.test.ts': { added: 12, deleted: 1, binary: false },
|
|
193
|
+
})
|
|
194
|
+
const noteEntries = [
|
|
195
|
+
{ status: 'M', path: 'src/api/retry.ts' },
|
|
196
|
+
{ status: 'M', path: 'docs/guide.md' },
|
|
197
|
+
{ status: 'A', path: 'src/api/retry.test.ts' },
|
|
198
|
+
]
|
|
199
|
+
const noteDiff = [
|
|
200
|
+
'diff --git a/src/api/retry.ts b/src/api/retry.ts',
|
|
201
|
+
'@@ -1 +1 @@',
|
|
202
|
+
'-function decideRetry(n) { return n < 3 }',
|
|
203
|
+
'+export function decideRetry(n) { return n < 5 }',
|
|
204
|
+
'diff --git a/docs/guide.md b/docs/guide.md',
|
|
205
|
+
'@@ -1 +1 @@',
|
|
206
|
+
'+Retries now give up after five attempts.',
|
|
207
|
+
].join('\n')
|
|
208
|
+
|
|
209
|
+
check('every file in the body gets its own typed note', () => {
|
|
210
|
+
const { message, notes } = buildMessage({ entries: noteEntries, stats: noteStats, unifiedDiff: noteDiff })
|
|
211
|
+
// Each note is derived from THAT file: the API file is a feat named after the
|
|
212
|
+
// symbol its diff declares, the markdown file is docs, the new test file is
|
|
213
|
+
// a test. One shared sentence could not say all three.
|
|
214
|
+
assert.deepEqual(notes.map(item => item.path), noteEntries.map(entry => entry.path))
|
|
215
|
+
assert.match(message, /- feat\(api\): 更新 decideRetry · src\/api\/retry\.ts/)
|
|
216
|
+
assert.match(message, /- docs: 更新文档 guide · docs\/guide\.md/)
|
|
217
|
+
assert.match(message, /- test\(api\): 新增 retry\.test\.ts · src\/api\/retry\.test\.ts/)
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
check('the body follows the subject after a blank line', () => {
|
|
221
|
+
const { message } = buildMessage({ entries: noteEntries, stats: noteStats, unifiedDiff: noteDiff })
|
|
222
|
+
const [subject, blank, first] = message.split('\n')
|
|
223
|
+
assert.match(subject, /^(feat|fix|docs|style|refactor|perf|test|build|ci|chore)(\(|:)/)
|
|
224
|
+
assert.equal(blank, '', 'a body must be separated from the subject by a blank line')
|
|
225
|
+
assert.match(first, /^- /)
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
check('a single file keeps the subject only, with no body at all', () => {
|
|
229
|
+
const { message, notes } = buildMessage({
|
|
230
|
+
entries: [noteEntries[0]],
|
|
231
|
+
stats: noteStats,
|
|
232
|
+
unifiedDiff: noteDiff,
|
|
233
|
+
})
|
|
234
|
+
assert.equal(message.includes('\n'), false)
|
|
235
|
+
assert.deepEqual(notes, [], 'a one-file commit has nothing to itemize')
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
check('a scope that only repeats the type is dropped', () => {
|
|
239
|
+
// `docs(docs): 更新文档 docs` is what the naive composition produces, and it
|
|
240
|
+
// reads like a bug.
|
|
241
|
+
const { message } = buildMessage({ entries: [noteEntries[1]], stats: noteStats, unifiedDiff: noteDiff })
|
|
242
|
+
assert.match(message, /^docs: /)
|
|
243
|
+
assert.equal(message.includes('docs(docs)'), false)
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
check('the body is capped, with an explicit remainder line', () => {
|
|
247
|
+
const { message, notes } = buildMessage({
|
|
248
|
+
entries: noteEntries,
|
|
249
|
+
stats: noteStats,
|
|
250
|
+
unifiedDiff: noteDiff,
|
|
251
|
+
maxFiles: 2,
|
|
252
|
+
})
|
|
253
|
+
assert.equal(notes.length, 2)
|
|
254
|
+
assert.match(message, /- …另有 1 个文件/)
|
|
255
|
+
assert.equal(message.includes('retry.test.ts'), false, 'the capped file must not leak into the body')
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
check('declaredSymbols ignores diff headers and comments', () => {
|
|
259
|
+
const symbols = declaredSymbols('+++ b/src/a.ts\n+// export function fake()\n+export function real()\n')
|
|
260
|
+
assert.deepEqual(symbols, ['real'])
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
check('removedDeclarationCount counts only declarations', () => {
|
|
264
|
+
assert.equal(removedDeclarationCount('- const x = 1\n+ const x = 2\n'), 1)
|
|
265
|
+
assert.equal(removedDeclarationCount('- const x = 1\n- const y = 2\n'), 2)
|
|
266
|
+
assert.equal(removedDeclarationCount('--- a/x\n+++ b/x\n'), 0)
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
check('totalsOf sums both sides', () => {
|
|
270
|
+
const totals = totalsOf(statMap({ a: { added: 2, deleted: 3, binary: false }, b: { added: 1, deleted: 4, binary: false } }))
|
|
271
|
+
assert.deepEqual(totals, { added: 3, deleted: 7 })
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
console.log('\ncard rendering')
|
|
275
|
+
|
|
276
|
+
check('the preview card leads with an unmissable verdict', () => {
|
|
277
|
+
const card = renderCard({
|
|
278
|
+
branch: 'main',
|
|
279
|
+
entries: noteEntries,
|
|
280
|
+
stats: noteStats,
|
|
281
|
+
recentSubjects: [],
|
|
282
|
+
version: undefined,
|
|
283
|
+
breaking: false,
|
|
284
|
+
draft: buildMessage({ entries: noteEntries, stats: noteStats, unifiedDiff: noteDiff }),
|
|
285
|
+
maxFiles: 12,
|
|
286
|
+
hasUpstream: true,
|
|
287
|
+
})
|
|
288
|
+
assert.match(card.split('\n')[0], /^🔎 \*\*改动预览(未提交)\*\*/)
|
|
289
|
+
// Each file line ends in the note that file would get in the commit body.
|
|
290
|
+
assert.match(card, /src\/api\/retry\.ts.*→ feat\(api\): 更新 decideRetry/)
|
|
291
|
+
assert.match(card, /docs\/guide\.md.*→ docs: 更新文档 guide/)
|
|
292
|
+
assert.match(card, /拟定标题:/)
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
check('card renders without a diff or a version', () => {
|
|
296
|
+
const card = renderCard({
|
|
297
|
+
branch: 'main',
|
|
298
|
+
entries: [{ status: 'M', path: 'src/a.ts' }, { status: 'A', path: 'src/b.ts' }],
|
|
299
|
+
stats: statMap({ 'src/a.ts': { added: 1, deleted: 2, binary: false }, 'src/b.ts': { added: 9, deleted: 0, binary: false } }),
|
|
300
|
+
recentSubjects: ['feat: x'],
|
|
301
|
+
version: undefined,
|
|
302
|
+
breaking: false,
|
|
303
|
+
draft: { message: 'feat(a): x' },
|
|
304
|
+
maxFiles: 1,
|
|
305
|
+
hasUpstream: true,
|
|
306
|
+
})
|
|
307
|
+
assert.match(card, /main/)
|
|
308
|
+
assert.match(card, /另有 1 个文件/)
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
check('card reports tag evidence and deletion count', () => {
|
|
312
|
+
const card = renderCard({
|
|
313
|
+
branch: 'dev',
|
|
314
|
+
entries: [{ status: 'D', path: 'src/old.ts' }],
|
|
315
|
+
stats: new Map(),
|
|
316
|
+
recentSubjects: [],
|
|
317
|
+
version: { file: 'package.json', from: '1.0.0', to: '1.1.0' },
|
|
318
|
+
breaking: true,
|
|
319
|
+
draft: { message: 'refactor: 移除 old' },
|
|
320
|
+
maxFiles: 5,
|
|
321
|
+
hasUpstream: false,
|
|
322
|
+
})
|
|
323
|
+
assert.match(card, /1\.0\.0 → 1\.1\.0/)
|
|
324
|
+
assert.match(card, /破坏性变更/)
|
|
325
|
+
assert.match(card, /无 upstream/)
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
console.log('\nthe commit card states the outcome')
|
|
329
|
+
|
|
330
|
+
const commitCard = (extra) => applyCard({
|
|
331
|
+
branch: 'main',
|
|
332
|
+
hash: 'a1b2c3d',
|
|
333
|
+
subject: 'feat(api): 更新 retry',
|
|
334
|
+
tagCreated: undefined,
|
|
335
|
+
pushed: false,
|
|
336
|
+
pushedTag: undefined,
|
|
337
|
+
note: undefined,
|
|
338
|
+
fileCount: 2,
|
|
339
|
+
totals: { added: 12, deleted: 4 },
|
|
340
|
+
notes: [
|
|
341
|
+
{ path: 'src/api/retry.ts', note: 'feat(api): 更新 decideRetry' },
|
|
342
|
+
{ path: 'docs/guide.md', note: 'docs: 更新文档 guide' },
|
|
343
|
+
],
|
|
344
|
+
autoPush: false,
|
|
345
|
+
maxFiles: 12,
|
|
346
|
+
...extra,
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
check('a pushed commit says so in the first line', () => {
|
|
350
|
+
const card = commitCard({ pushed: true, autoPush: true })
|
|
351
|
+
assert.match(card.split('\n')[0], /^✅ \*\*Git 提交并推送成功\*\*/)
|
|
352
|
+
assert.match(card, /推送:已推送/)
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
check('a commit that was not pushed says so without looking like a failure', () => {
|
|
356
|
+
const card = commitCard({ pushed: false, autoPush: false })
|
|
357
|
+
assert.match(card.split('\n')[0], /^✅ \*\*Git 提交成功(未推送)\*\*/)
|
|
358
|
+
assert.match(card, /推送:未推送/)
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
check('a failed push is a warning, not a silent success', () => {
|
|
362
|
+
const card = commitCard({ pushed: false, autoPush: true, note: '推送失败(rejected)' })
|
|
363
|
+
assert.match(card.split('\n')[0], /^⚠️ \*\*已提交,但推送失败\*\*/)
|
|
364
|
+
assert.match(card, /推送:失败/)
|
|
365
|
+
assert.match(card, /说明:推送失败/)
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
check('the commit card lists the per-file notes it wrote', () => {
|
|
369
|
+
const card = commitCard({ pushed: true, autoPush: true })
|
|
370
|
+
assert.match(card, /- feat\(api\): 更新 decideRetry · src\/api\/retry\.ts/)
|
|
371
|
+
assert.match(card, /- docs: 更新文档 guide · docs\/guide\.md/)
|
|
372
|
+
assert.match(card, /提交 2 个文件 · \+12 \/ -4/)
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
check('a one-file commit card has no redundant note list', () => {
|
|
376
|
+
const card = commitCard({ notes: [{ path: 'src/api/retry.ts', note: 'feat(api): 更新 decideRetry' }], fileCount: 1 })
|
|
377
|
+
assert.equal(card.includes(' - '), false)
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
console.log('\nargument parsing (/git-commit-push)')
|
|
381
|
+
|
|
382
|
+
check('bare /git-commit-push is auto', () => {
|
|
383
|
+
assert.deepEqual(parseCommitCommand(''), { action: 'auto' })
|
|
384
|
+
})
|
|
385
|
+
check('--prepare is preview only', () => {
|
|
386
|
+
assert.equal(parseCommitCommand('--prepare').action, 'prepare')
|
|
387
|
+
})
|
|
388
|
+
check('a bare message means apply', () => {
|
|
389
|
+
const request = parseCommitCommand('修复登录超时')
|
|
390
|
+
assert.equal(request.action, 'apply')
|
|
391
|
+
assert.equal(request.message, '修复登录超时')
|
|
392
|
+
})
|
|
393
|
+
check('--no-push is honored', () => {
|
|
394
|
+
assert.equal(parseCommitCommand('--no-push').push, false)
|
|
395
|
+
})
|
|
396
|
+
check('--tag=NAME is parsed', () => {
|
|
397
|
+
assert.equal(parseCommitCommand('--tag=v1.2.3').tag, 'v1.2.3')
|
|
398
|
+
})
|
|
399
|
+
check('--en selects english', () => {
|
|
400
|
+
assert.equal(parseCommitCommand('--en').language, 'en')
|
|
401
|
+
})
|
|
402
|
+
check('a message plus --prepare stays a preview', () => {
|
|
403
|
+
const request = parseCommitCommand('--prepare 想提交的内容')
|
|
404
|
+
assert.equal(request.action, 'prepare')
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
console.log('\nrunner boundaries (no git needed)')
|
|
408
|
+
|
|
409
|
+
check('the tool definition is registration-ready', () => {
|
|
410
|
+
assert.deepEqual(toolDefinitionProblems(), [])
|
|
411
|
+
assert.equal(TOOL_DEFINITION.parameters.type, 'object')
|
|
412
|
+
assert.equal(Array.isArray(TOOL_DEFINITION.output.schema.required), true)
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
await checkAsync('a non-repository directory is refused, not guessed', async () => {
|
|
416
|
+
const result = await run(
|
|
417
|
+
{ get: () => undefined },
|
|
418
|
+
{ action: 'prepare', cwd: process.env.SystemRoot ?? '/nonexistent-dir-xyz' },
|
|
419
|
+
undefined,
|
|
420
|
+
)
|
|
421
|
+
assert.equal(result.ok, false)
|
|
422
|
+
assert.match(String(result.card), /未初始化 Git|不是 Git 仓库|找不到 git/)
|
|
423
|
+
assert.equal(typeof result.card, 'string')
|
|
424
|
+
assert.equal(typeof result.pushed, 'boolean')
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
await checkAsync('tagNameError rejects unsafe names without touching a repo', async () => {
|
|
428
|
+
assert.match(String(await tagNameError(process.cwd(), '-badtag')), /start/)
|
|
429
|
+
assert.match(String(await tagNameError(process.cwd(), 'has space')), /whitespace/)
|
|
430
|
+
assert.match(String(await tagNameError(process.cwd(), '')), /empty/)
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
await checkAsync('git() surfaces a clean GitError when git cannot answer', async () => {
|
|
434
|
+
// `rev-parse` inside a path that does not exist must reject with our own
|
|
435
|
+
// GitError (never a raw spawn exception, never a hang, never a bare string).
|
|
436
|
+
await assert.rejects(
|
|
437
|
+
() => git(process.platform === 'win32' ? 'C:\\nonexistent-dsh-test-dir' : '/nonexistent-dsh-test-dir', ['rev-parse'], { timeoutMs: 5_000 }),
|
|
438
|
+
(error) => error instanceof Error && error.name === 'GitError' && typeof error.code === 'string',
|
|
439
|
+
)
|
|
440
|
+
})
|
|
441
|
+
|
|
442
|
+
console.log('\nbundle declaration (what the Plugins page reads)')
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* The package directory, so these checks read the REAL manifest, patch and
|
|
446
|
+
* locale files instead of a copy of what the author believes they say.
|
|
447
|
+
*/
|
|
448
|
+
const PACKAGE_DIR = dirname(fileURLToPath(import.meta.url))
|
|
449
|
+
const manifest = JSON.parse(readFileSync(join(PACKAGE_DIR, 'package.json'), 'utf8'))
|
|
450
|
+
const bundlePatch = manifest.dsh?.bundle?.patch
|
|
451
|
+
|
|
452
|
+
/** Strip one layer of YAML quoting. */
|
|
453
|
+
function unquote(value) {
|
|
454
|
+
const trimmed = value.trim()
|
|
455
|
+
if (/^'.*'$/.test(trimmed) || /^".*"$/.test(trimmed)) return trimmed.slice(1, -1)
|
|
456
|
+
return trimmed
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* The mount rows a bundle patch inserts.
|
|
461
|
+
*
|
|
462
|
+
* Deliberately a hand-written reader for exactly the dialect this patch uses:
|
|
463
|
+
* the plugin ships with NO dependencies (see README, "设计取舍"), so the test
|
|
464
|
+
* cannot reach for a YAML library without making one a runtime dependency. The
|
|
465
|
+
* reader is strict — an unknown line throws, and the check reports it — so a
|
|
466
|
+
* patch that grows a construct it does not understand fails loudly rather than
|
|
467
|
+
* being silently under-read.
|
|
468
|
+
*/
|
|
469
|
+
function insertRows(text) {
|
|
470
|
+
const rows = []
|
|
471
|
+
let current
|
|
472
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
473
|
+
const line = raw.trim()
|
|
474
|
+
if (line === '' || line.startsWith('#')) continue
|
|
475
|
+
if (line === '- insert:') { current = undefined; continue }
|
|
476
|
+
const id = /^-\s*id:\s*(.+)$/.exec(line)
|
|
477
|
+
if (id !== null) { current = { id: unquote(id[1]) }; rows.push(current); continue }
|
|
478
|
+
const name = /^name:\s*(.+)$/.exec(line)
|
|
479
|
+
if (name !== null) {
|
|
480
|
+
assert.notEqual(current, undefined, `"${line}" appears before any row id`)
|
|
481
|
+
current.name = unquote(name[1])
|
|
482
|
+
continue
|
|
483
|
+
}
|
|
484
|
+
throw new Error(`unrecognized line in the bundle patch: ${JSON.stringify(raw)}`)
|
|
485
|
+
}
|
|
486
|
+
return rows
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
check('package.json declares a bundle patch', () => {
|
|
490
|
+
// This one field is the difference between "a dependency" and "a bundle":
|
|
491
|
+
// the launcher applies the patch for every profile that selects the package,
|
|
492
|
+
// and the Plugins page refuses every action without it ("not-bundle").
|
|
493
|
+
assert.equal(bundlePatch, './cordis.patch.yml')
|
|
494
|
+
})
|
|
495
|
+
|
|
496
|
+
check('the declared bundle patch exists and is not empty', () => {
|
|
497
|
+
const files = typeof bundlePatch === 'string' ? [bundlePatch] : bundlePatch
|
|
498
|
+
assert.equal(Array.isArray(files) && files.length > 0, true, 'dsh.bundle.patch must be a file or a list of files')
|
|
499
|
+
for (const file of files) {
|
|
500
|
+
assert.equal(typeof file, 'string')
|
|
501
|
+
const text = readFileSync(join(PACKAGE_DIR, file), 'utf8')
|
|
502
|
+
assert.equal(text.trim() === '', false, `${file} is empty`)
|
|
503
|
+
}
|
|
504
|
+
})
|
|
505
|
+
|
|
506
|
+
check('the bundle patch inserts exactly one mount row', () => {
|
|
507
|
+
const rows = insertRows(readFileSync(join(PACKAGE_DIR, bundlePatch), 'utf8'))
|
|
508
|
+
assert.equal(rows.length, 1, 'a second insert of the same id would mount the plugin twice')
|
|
509
|
+
})
|
|
510
|
+
|
|
511
|
+
check('the mount row names this package under a stable id', () => {
|
|
512
|
+
const [row] = insertRows(readFileSync(join(PACKAGE_DIR, bundlePatch), 'utf8'))
|
|
513
|
+
// The id is the Loader entry identity: the Plugins page's per-component
|
|
514
|
+
// switch and any profile override find the row by it, so it must not drift.
|
|
515
|
+
assert.equal(row.id, 'git-commit-push')
|
|
516
|
+
assert.equal(row.name, manifest.name)
|
|
517
|
+
})
|
|
518
|
+
|
|
519
|
+
check('neither installer writes a mount row of its own', () => {
|
|
520
|
+
for (const script of ['setup.ps1', 'setup.sh']) {
|
|
521
|
+
const text = readFileSync(join(PACKAGE_DIR, script), 'utf8')
|
|
522
|
+
assert.equal(
|
|
523
|
+
text.includes('- insert:'),
|
|
524
|
+
false,
|
|
525
|
+
`${script} writes a mount row; the bundle patch owns the only mount`,
|
|
526
|
+
)
|
|
527
|
+
assert.match(text, /legacy mount row/, `${script} must strip the legacy row an earlier revision wrote`)
|
|
528
|
+
}
|
|
529
|
+
})
|
|
530
|
+
|
|
531
|
+
check('the Plugins page can resolve the manifest and locale metadata', () => {
|
|
532
|
+
// readPluginMeta resolves `<specifier>/package.json` and `<specifier>/locale/*`
|
|
533
|
+
// through the package's own `exports` map: without these subpaths both lookups
|
|
534
|
+
// are ERR_PACKAGE_PATH_NOT_EXPORTED and the page falls back to the bare
|
|
535
|
+
// specifier as the title.
|
|
536
|
+
assert.equal(manifest.exports['./package.json'], './package.json')
|
|
537
|
+
assert.equal(manifest.exports['./locale/*'], './locale/*')
|
|
538
|
+
for (const language of ['en', 'zh']) {
|
|
539
|
+
const file = join(PACKAGE_DIR, 'locale', `${language}.json`)
|
|
540
|
+
assert.equal(existsSync(file), true, `locale/${language}.json is missing`)
|
|
541
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8'))
|
|
542
|
+
for (const field of ['title', 'description']) {
|
|
543
|
+
const value = parsed.meta?.[field]
|
|
544
|
+
assert.equal(typeof value === 'string' && value.trim() !== '', true, `locale/${language}.json: meta.${field}`)
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
})
|
|
548
|
+
|
|
549
|
+
console.log('\npublish readiness (npm)')
|
|
550
|
+
|
|
551
|
+
/** `files` entries are either exact files or directory prefixes. */
|
|
552
|
+
function coveredByFiles(relativePath) {
|
|
553
|
+
return manifest.files.some((entry) => {
|
|
554
|
+
const clean = entry.replace(/^\.\//, '').replace(/\/+$/, '')
|
|
555
|
+
return clean === relativePath || relativePath.startsWith(`${clean}/`)
|
|
556
|
+
})
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** Relative module specifiers a file imports (statically or dynamically). */
|
|
560
|
+
function relativeImports(file) {
|
|
561
|
+
const text = readFileSync(file, 'utf8')
|
|
562
|
+
const specifiers = []
|
|
563
|
+
for (const pattern of [
|
|
564
|
+
/\bfrom\s+['"](\.[^'"]*)['"]/gu,
|
|
565
|
+
/\bimport\s*\(\s*['"](\.[^'"]*)['"]\s*\)/gu,
|
|
566
|
+
/\bimport\s+['"](\.[^'"]*)['"]/gu,
|
|
567
|
+
]) {
|
|
568
|
+
for (const match of text.matchAll(pattern)) specifiers.push(match[1])
|
|
569
|
+
}
|
|
570
|
+
return specifiers
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Every module reachable from the entry point through relative imports.
|
|
575
|
+
*
|
|
576
|
+
* This is the check that catches the classic npm-publishing accident: a module
|
|
577
|
+
* the entry point needs is missing from the tarball because `files` forgot it,
|
|
578
|
+
* and the package installs cleanly and then fails to load.
|
|
579
|
+
*/
|
|
580
|
+
function moduleGraph(entry) {
|
|
581
|
+
const seen = new Set()
|
|
582
|
+
const queue = [entry]
|
|
583
|
+
while (queue.length > 0) {
|
|
584
|
+
const file = queue.pop()
|
|
585
|
+
if (seen.has(file)) continue
|
|
586
|
+
seen.add(file)
|
|
587
|
+
for (const specifier of relativeImports(file)) queue.push(resolve(dirname(file), specifier))
|
|
588
|
+
}
|
|
589
|
+
return seen
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
check('the manifest carries what npm and the Plugins page need', () => {
|
|
593
|
+
assert.equal(manifest.private, undefined, 'private: true makes the package unpublishable')
|
|
594
|
+
assert.equal(manifest.name, 'dsh-plugin-git-commit-push')
|
|
595
|
+
assert.match(manifest.version, /^\d+\.\d+\.\d+$/)
|
|
596
|
+
assert.equal(typeof manifest.description === 'string' && manifest.description.length > 20, true)
|
|
597
|
+
assert.equal(manifest.license, 'MIT')
|
|
598
|
+
assert.match(String(manifest.author), /<[^@\s]+@[^@\s]+>/, 'author should carry a contact address')
|
|
599
|
+
assert.match(manifest.repository.url, /github\.com\/ygzhang-lab\/dsh-plugin-git-commit-push/)
|
|
600
|
+
assert.equal(manifest.publishConfig.registry, 'https://registry.npmjs.org/')
|
|
601
|
+
assert.equal(manifest.publishConfig.access, 'public')
|
|
602
|
+
assert.equal(Array.isArray(manifest.keywords) && manifest.keywords.length >= 5, true)
|
|
603
|
+
// npm and pnpm strip publish-only scripts from the packed manifest, and this
|
|
604
|
+
// test ships inside the tarball: an installed copy legitimately lacks it.
|
|
605
|
+
const prepublish = manifest.scripts?.prepublishOnly
|
|
606
|
+
assert.equal(
|
|
607
|
+
prepublish === undefined || /self-test\.mjs/u.test(prepublish),
|
|
608
|
+
true,
|
|
609
|
+
'prepublishOnly must run the self-tests when the packer keeps it',
|
|
610
|
+
)
|
|
611
|
+
assert.match(manifest.scripts.test, /self-test\.mjs/u)
|
|
612
|
+
})
|
|
613
|
+
|
|
614
|
+
check('the dsh manifest follows the documented convention', () => {
|
|
615
|
+
// `dsh.manifestVersion` is the manifest-format identifier (independent of the
|
|
616
|
+
// npm version); `engines.dsh` is where an author declares compatible hosts.
|
|
617
|
+
assert.equal(manifest.dsh.manifestVersion, 1)
|
|
618
|
+
assert.equal(typeof manifest.engines.dsh, 'string')
|
|
619
|
+
assert.notEqual(manifest.engines.dsh.trim(), '')
|
|
620
|
+
assert.equal(manifest.engines.git, undefined, 'engines.git is not a field any installer reads')
|
|
621
|
+
})
|
|
622
|
+
|
|
623
|
+
check('no DSH peer is ever installed by the consumer', () => {
|
|
624
|
+
// The peer exists so DSH's own compatibility gate
|
|
625
|
+
// (`evaluatePluginCompatibility`) can compare the host version. The plugin
|
|
626
|
+
// imports nothing from it, so it must be optional: otherwise pnpm would try
|
|
627
|
+
// to fetch a host package into the user's profile.
|
|
628
|
+
const peers = Object.keys(manifest.peerDependencies ?? {})
|
|
629
|
+
assert.equal(peers.length > 0, true, 'the DSH compatibility gate needs a dsh- peer')
|
|
630
|
+
for (const peer of peers) {
|
|
631
|
+
assert.match(peer, /^@deepseek-ai\/dsh(-|$)/, `${peer} is not a DSH package; drop it or make it a real dependency`)
|
|
632
|
+
assert.equal(manifest.peerDependenciesMeta?.[peer]?.optional, true, `${peer} must be an optional peer`)
|
|
633
|
+
}
|
|
634
|
+
})
|
|
635
|
+
|
|
636
|
+
check('the schema library is a dependency, never a peer', () => {
|
|
637
|
+
// A profile sets `autoInstallPeers: false`, so a peer would never be
|
|
638
|
+
// installed: the settings form would silently disappear for every user.
|
|
639
|
+
const range = manifest.dependencies?.['@deepseek-ai/schemastery']
|
|
640
|
+
assert.equal(typeof range, 'string', '@deepseek-ai/schemastery must be a dependency')
|
|
641
|
+
assert.match(range, /^\^?\d+\.\d+\.\d+/)
|
|
642
|
+
assert.equal(manifest.peerDependencies?.['@deepseek-ai/schemastery'], undefined)
|
|
643
|
+
})
|
|
644
|
+
|
|
645
|
+
check('the icon satisfies the registry rules', () => {
|
|
646
|
+
const icon = manifest.icon
|
|
647
|
+
assert.equal(typeof icon, 'string')
|
|
648
|
+
assert.equal(/^[A-Za-z][A-Za-z\d+.-]*:/u.test(icon), false, 'icon must be a relative path')
|
|
649
|
+
assert.match(icon, /\.(svg|png|jpe?g|webp)$/u)
|
|
650
|
+
const file = resolve(PACKAGE_DIR, icon)
|
|
651
|
+
assert.equal(file.startsWith(PACKAGE_DIR), true, 'icon must stay inside the package')
|
|
652
|
+
const stat = statSync(file)
|
|
653
|
+
assert.equal(stat.isFile(), true)
|
|
654
|
+
assert.equal(stat.size <= 256 * 1024, true, 'icon exceeds the 256 KiB limit')
|
|
655
|
+
})
|
|
656
|
+
|
|
657
|
+
check('every file list entry exists', () => {
|
|
658
|
+
for (const entry of manifest.files) {
|
|
659
|
+
assert.equal(existsSync(join(PACKAGE_DIR, entry)), true, `files lists "${entry}", which does not exist`)
|
|
660
|
+
}
|
|
661
|
+
})
|
|
662
|
+
|
|
663
|
+
check('the tarball carries every module the entry point imports', () => {
|
|
664
|
+
for (const file of moduleGraph(join(PACKAGE_DIR, 'index.js'))) {
|
|
665
|
+
const relative = file.slice(PACKAGE_DIR.length + 1).replace(/\\/gu, '/')
|
|
666
|
+
assert.equal(coveredByFiles(relative), true, `files does not publish ${relative}`)
|
|
667
|
+
}
|
|
668
|
+
})
|
|
669
|
+
|
|
670
|
+
check('the tarball carries the files the plugin reads at runtime', () => {
|
|
671
|
+
for (const required of [
|
|
672
|
+
'SKILL.md',
|
|
673
|
+
'cordis.patch.yml',
|
|
674
|
+
'git-commit-push.config.json',
|
|
675
|
+
'icon.svg',
|
|
676
|
+
'locale/en.json',
|
|
677
|
+
'locale/zh.json',
|
|
678
|
+
'README.md',
|
|
679
|
+
'README.en.md',
|
|
680
|
+
]) {
|
|
681
|
+
assert.equal(existsSync(join(PACKAGE_DIR, required)), true, `${required} is missing`)
|
|
682
|
+
assert.equal(coveredByFiles(required), true, `files does not publish ${required}`)
|
|
683
|
+
}
|
|
684
|
+
})
|
|
685
|
+
|
|
686
|
+
check('no author-machine path leaks into the published files', () => {
|
|
687
|
+
// A published README or installer that hard-codes the author's checkout is a
|
|
688
|
+
// broken instruction for everyone else.
|
|
689
|
+
const leaked = []
|
|
690
|
+
for (const entry of manifest.files) {
|
|
691
|
+
const file = join(PACKAGE_DIR, entry)
|
|
692
|
+
if (!statSync(file).isFile() || !/\.(js|mjs|json|md|ps1|sh|ya?ml)$/u.test(file)) continue
|
|
693
|
+
const text = readFileSync(file, 'utf8')
|
|
694
|
+
for (const needle of ['C:\\Users\\admin', 'D:\\Program Files', 'local-plugins\\dsh-plugin-git-commit-push']) {
|
|
695
|
+
if (text.includes(needle)) leaked.push(`${entry}: ${needle}`)
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
assert.deepEqual(leaked, [])
|
|
699
|
+
})
|
|
700
|
+
|
|
701
|
+
console.log('\nsettings files (npm installs live under node_modules)')
|
|
702
|
+
|
|
703
|
+
const withTemporaryDshHome = async (run) => {
|
|
704
|
+
const previous = process.env.DSH_HOME
|
|
705
|
+
const home = mkdtempSync(join(tmpdir(), 'dsh-config-test-'))
|
|
706
|
+
process.env.DSH_HOME = home
|
|
707
|
+
try {
|
|
708
|
+
await run(home)
|
|
709
|
+
} finally {
|
|
710
|
+
if (previous === undefined) delete process.env.DSH_HOME
|
|
711
|
+
else process.env.DSH_HOME = previous
|
|
712
|
+
rmSync(home, { recursive: true, force: true })
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
await checkAsync('a user config next to the DSH home wins over the shipped template', async () => {
|
|
717
|
+
await withTemporaryDshHome(async (home) => {
|
|
718
|
+
const file = join(home, 'git-commit-push.config.json')
|
|
719
|
+
writeFileSync(file, JSON.stringify({ tagPrefix: 'release-', tagOnFileCount: 3 }))
|
|
720
|
+
const report = await loadSettingsReport()
|
|
721
|
+
assert.equal(report.source, file)
|
|
722
|
+
assert.equal(report.problem, undefined)
|
|
723
|
+
assert.equal(report.settings.tagPrefix, 'release-')
|
|
724
|
+
assert.equal(report.settings.tagOnFileCount, 3)
|
|
725
|
+
assert.equal(report.settings.autoPush, DEFAULTS.autoPush, 'omitted keys keep the defaults')
|
|
726
|
+
})
|
|
727
|
+
})
|
|
728
|
+
|
|
729
|
+
await checkAsync('with no user config the shipped template is the source', async () => {
|
|
730
|
+
await withTemporaryDshHome(async () => {
|
|
731
|
+
const report = await loadSettingsReport()
|
|
732
|
+
assert.match(report.source, /git-commit-push\.config\.json$/)
|
|
733
|
+
assert.equal(report.source.startsWith(PACKAGE_DIR), true)
|
|
734
|
+
assert.equal(report.problem, undefined)
|
|
735
|
+
})
|
|
736
|
+
})
|
|
737
|
+
|
|
738
|
+
await checkAsync('a malformed user config is reported, not silently ignored', async () => {
|
|
739
|
+
await withTemporaryDshHome(async (home) => {
|
|
740
|
+
writeFileSync(join(home, 'git-commit-push.config.json'), '{ "autoPush": tru }')
|
|
741
|
+
const report = await loadSettingsReport()
|
|
742
|
+
assert.equal(report.source, join(home, 'git-commit-push.config.json'))
|
|
743
|
+
assert.match(report.problem, /不是合法的 JSON 配置/)
|
|
744
|
+
assert.equal(report.settings.autoPush, DEFAULTS.autoPush)
|
|
745
|
+
})
|
|
746
|
+
})
|
|
747
|
+
|
|
748
|
+
console.log('\nthe embedded skill')
|
|
749
|
+
|
|
750
|
+
check('the shipped SKILL.md parses into a valid runtime skill', () => {
|
|
751
|
+
const skill = skillDefinition()
|
|
752
|
+
assert.notEqual(skill, undefined, 'SKILL.md is missing or unusable')
|
|
753
|
+
// The registry's own rules (see `validateRuntimeSkill` in @deepseek-ai/dsh-skill).
|
|
754
|
+
assert.match(skill.name, /^[a-z0-9]+(?:-[a-z0-9]+)*$/)
|
|
755
|
+
assert.equal(typeof skill.description, 'string')
|
|
756
|
+
assert.equal(skill.description.length > 0, true)
|
|
757
|
+
assert.equal(typeof skill.content, 'string')
|
|
758
|
+
assert.equal(skill.content.length > 0, true)
|
|
759
|
+
assert.equal(skill.content.startsWith('---'), false, 'the frontmatter must not reach the model as instructions')
|
|
760
|
+
assert.deepEqual(skill.invocation, { modelInvocable: true, userInvocable: false })
|
|
761
|
+
assert.equal(skill.source, manifest.name)
|
|
762
|
+
assert.equal(skill.provider, undefined, 'the registry fills in the runtime provider label')
|
|
763
|
+
})
|
|
764
|
+
|
|
765
|
+
check('the skill name and description come from SKILL.md, not from a copy', () => {
|
|
766
|
+
const { fields } = parseSkillFile(readFileSync(SKILL_PATH, 'utf8'))
|
|
767
|
+
const skill = skillDefinition()
|
|
768
|
+
assert.equal(skill.name, fields.name)
|
|
769
|
+
assert.equal(skill.description, fields.description)
|
|
770
|
+
// The skill is named after the plugin: one name for the tool surface, the
|
|
771
|
+
// command, the skill and the npm package, so nothing has to be looked up
|
|
772
|
+
// under two different names.
|
|
773
|
+
assert.equal(fields.name, 'git-commit-push')
|
|
774
|
+
assert.equal(fields.name, manifest.name.replace(/^dsh-plugin-/u, ''))
|
|
775
|
+
})
|
|
776
|
+
|
|
777
|
+
check('apply registers the tool, the command and the skill', () => {
|
|
778
|
+
const registered = { tools: [], commands: [], skills: [] }
|
|
779
|
+
const services = {
|
|
780
|
+
commands: { register: (command) => registered.commands.push(command) },
|
|
781
|
+
skills: { register: (skill) => registered.skills.push(skill) },
|
|
782
|
+
}
|
|
783
|
+
apply({
|
|
784
|
+
tools: { register: (definition) => registered.tools.push(definition) },
|
|
785
|
+
// `inject` mirrors the Cordis scoped context: the callback runs only when
|
|
786
|
+
// the service is present.
|
|
787
|
+
inject: (names, callback) => {
|
|
788
|
+
for (const service of names) callback({ get: () => services[service] })
|
|
789
|
+
},
|
|
790
|
+
logger: { warn: () => {} },
|
|
791
|
+
})
|
|
792
|
+
assert.deepEqual(registered.tools.map(tool => tool.name), ['git_commit_push'])
|
|
793
|
+
assert.deepEqual(registered.commands.map(command => command.name), ['git-commit-push'])
|
|
794
|
+
assert.deepEqual(registered.skills.map(skill => skill.name), ['git-commit-push'])
|
|
795
|
+
})
|
|
796
|
+
|
|
797
|
+
check('one name covers the command, the skill, the bundle row and the package', () => {
|
|
798
|
+
// The user-visible name of this plugin should not need looking up twice: the
|
|
799
|
+
// slash command, the skill, the Loader row id and the npm package (minus its
|
|
800
|
+
// scope prefix) are the same string.
|
|
801
|
+
const expected = manifest.name.replace(/^dsh-plugin-/u, '')
|
|
802
|
+
assert.equal(expected, 'git-commit-push')
|
|
803
|
+
assert.equal(COMMAND_NAME, expected, 'the slash command name')
|
|
804
|
+
assert.equal(SKILL_NAME, expected, 'the skill name')
|
|
805
|
+
const rows = insertRows(readFileSync(join(PACKAGE_DIR, bundlePatch), 'utf8'))
|
|
806
|
+
assert.equal(rows[0].id, expected, 'the Loader row id')
|
|
807
|
+
})
|
|
808
|
+
|
|
809
|
+
check('the optional services stay optional in the static inject list', () => {
|
|
810
|
+
// Putting skills/commands in `inject` would block the whole plugin — and
|
|
811
|
+
// therefore the tool — on a host that has no such surface.
|
|
812
|
+
assert.deepEqual(inject, ['tools'])
|
|
813
|
+
})
|
|
814
|
+
|
|
815
|
+
check('apply survives a host with no command and no skill surface', () => {
|
|
816
|
+
let registered = 0
|
|
817
|
+
apply({
|
|
818
|
+
tools: { register: () => { registered += 1 } },
|
|
819
|
+
inject: () => {},
|
|
820
|
+
logger: { warn: () => {} },
|
|
821
|
+
})
|
|
822
|
+
assert.equal(registered, 1)
|
|
823
|
+
})
|
|
824
|
+
|
|
825
|
+
console.log('\nthe settings form (Config schema)')
|
|
826
|
+
|
|
827
|
+
check('the field table matches the built-in defaults', () => {
|
|
828
|
+
// One table drives the schema, the merge and this test: a field added to one
|
|
829
|
+
// and forgotten in the other is how a form ends up writing values no code reads.
|
|
830
|
+
for (const field of CONFIG_FIELDS) {
|
|
831
|
+
assert.equal(Object.hasOwn(DEFAULTS, field.key), true, `${field.key} is missing from DEFAULTS`)
|
|
832
|
+
assert.equal(typeof DEFAULTS[field.key], field.kind, `${field.key} default type`)
|
|
833
|
+
assert.equal(typeof field.label === 'string' && field.label.trim() !== '', true, `${field.key} needs a form label`)
|
|
834
|
+
}
|
|
835
|
+
for (const field of IDENTITY_FIELDS) {
|
|
836
|
+
assert.equal(Object.hasOwn(DEFAULTS.pinnedIdentity, field.key), true, `pinnedIdentity.${field.key}`)
|
|
837
|
+
assert.equal(typeof field.label === 'string' && field.label.trim() !== '', true, `pinnedIdentity.${field.key} needs a label`)
|
|
838
|
+
}
|
|
839
|
+
const listed = new Set(CONFIG_FIELDS.map(field => field.key))
|
|
840
|
+
for (const key of Object.keys(DEFAULTS)) {
|
|
841
|
+
if (key === 'pinnedIdentity') continue
|
|
842
|
+
assert.equal(listed.has(key), true, `${key} has a default but no form field`)
|
|
843
|
+
}
|
|
844
|
+
})
|
|
845
|
+
|
|
846
|
+
check('the shipped template names every field', () => {
|
|
847
|
+
const template = JSON.parse(readFileSync(join(PACKAGE_DIR, 'git-commit-push.config.json'), 'utf8'))
|
|
848
|
+
for (const field of CONFIG_FIELDS) assert.equal(Object.hasOwn(template, field.key), true, `template is missing ${field.key}`)
|
|
849
|
+
for (const field of IDENTITY_FIELDS) {
|
|
850
|
+
assert.equal(Object.hasOwn(template.pinnedIdentity ?? {}, field.key), true, `template is missing pinnedIdentity.${field.key}`)
|
|
851
|
+
}
|
|
852
|
+
})
|
|
853
|
+
|
|
854
|
+
check('the schema is published, or degrades without taking the plugin down', () => {
|
|
855
|
+
// @deepseek-ai/schemastery is a real dependency, but a `link:` install can
|
|
856
|
+
// only reach it through the launcher's runtime resolution. Either outcome is
|
|
857
|
+
// supported: a form, or no form with the tool still registered.
|
|
858
|
+
const library = loadSchemaLibrary()
|
|
859
|
+
if (library === undefined) {
|
|
860
|
+
assert.equal(CONFIG, undefined, 'without the library there must be no schema')
|
|
861
|
+
assert.equal(buildConfigSchema(undefined), undefined)
|
|
862
|
+
let registered = 0
|
|
863
|
+
apply({ tools: { register: () => { registered += 1 } }, inject: () => {}, logger: { warn: () => {} } })
|
|
864
|
+
assert.equal(registered, 1, 'the tool must still register')
|
|
865
|
+
} else {
|
|
866
|
+
assert.equal(typeof CONFIG, 'function', `the schema must be built when the library resolves (${schemaBuildProblem() ?? 'no recorded problem'})`)
|
|
867
|
+
assert.equal(typeof CONFIG.dict, 'object')
|
|
868
|
+
for (const field of CONFIG_FIELDS) {
|
|
869
|
+
assert.equal(CONFIG.dict[field.key]?.meta?.volatile, true, `${field.key} must be volatile to apply without a remount`)
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
})
|
|
873
|
+
|
|
874
|
+
check('the row config outranks the settings file', () => {
|
|
875
|
+
const file = { ...DEFAULTS, autoPush: true, tagPrefix: 'file-', maxFilesShown: 7 }
|
|
876
|
+
const merged = resolveSettings({ ui: { autoPush: false, tagPrefix: 'ui-' }, file })
|
|
877
|
+
assert.equal(merged.autoPush, false, 'the form wins')
|
|
878
|
+
assert.equal(merged.tagPrefix, 'ui-', 'the form wins for the same field')
|
|
879
|
+
assert.equal(merged.maxFilesShown, 7, 'the file still supplies fields the form did not set')
|
|
880
|
+
})
|
|
881
|
+
|
|
882
|
+
check('pinnedIdentity merges per key, not wholesale', () => {
|
|
883
|
+
const file = { ...DEFAULTS, pinnedIdentity: { name: 'From File', email: 'file@example.com' } }
|
|
884
|
+
const merged = resolveSettings({ ui: { pinnedIdentity: { name: 'From Form' } }, file })
|
|
885
|
+
assert.equal(merged.pinnedIdentity.name, 'From Form')
|
|
886
|
+
assert.equal(merged.pinnedIdentity.email, 'file@example.com', 'an untouched identity key must survive')
|
|
887
|
+
})
|
|
888
|
+
|
|
889
|
+
check('the raw row config is read as the UI layer', () => {
|
|
890
|
+
const ctx = { fiber: { entry: { options: { config: { autoPush: false, tagPrefix: 'form-' } } } } }
|
|
891
|
+
assert.deepEqual(uiOverrides(ctx, undefined), { autoPush: false, tagPrefix: 'form-' })
|
|
892
|
+
})
|
|
893
|
+
|
|
894
|
+
check('without the raw config, only values that differ from the defaults count', () => {
|
|
895
|
+
// A parsed config always carries defaults; treating those as "the user chose
|
|
896
|
+
// them" would make the form silently beat the settings file.
|
|
897
|
+
const parsed = { autoPush: false, autoAdd: DEFAULTS.autoAdd, pinnedIdentity: { name: '', email: 'me@example.com' } }
|
|
898
|
+
assert.deepEqual(uiOverrides({ get: () => undefined }, parsed), {
|
|
899
|
+
autoPush: false,
|
|
900
|
+
pinnedIdentity: { email: 'me@example.com' },
|
|
901
|
+
})
|
|
902
|
+
})
|
|
903
|
+
|
|
904
|
+
check('no context and no config means no UI layer at all', () => {
|
|
905
|
+
assert.deepEqual(uiOverrides({ get: () => undefined }, undefined), {})
|
|
906
|
+
assert.deepEqual(uiOverrides(undefined, undefined), {})
|
|
907
|
+
})
|
|
908
|
+
|
|
909
|
+
console.log(`\n${passed} passed, ${failures.length} failed\n`)
|
|
910
|
+
if (failures.length > 0) {
|
|
911
|
+
for (const failure of failures) console.log(`FAILED: ${failure.label}\n${failure.error.stack}\n`)
|
|
912
|
+
process.exitCode = 1
|
|
913
|
+
}
|