tissues 0.6.0 → 0.6.2
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/README.md +78 -1
- package/package.json +4 -2
- package/src/cli.js +12 -1
- package/src/commands/ai.js +149 -173
- package/src/commands/config.js +143 -69
- package/src/commands/create.js +16 -9
- package/src/commands/create.test.js +381 -0
- package/src/commands/enhancements.js +282 -0
- package/src/commands/flush.test.js +299 -0
- package/src/commands/list.js +3 -2
- package/src/commands/providers.js +347 -0
- package/src/commands/providers.test.js +28 -0
- package/src/commands/storage.js +167 -0
- package/src/commands/sync.js +225 -0
- package/src/daemon/sync.js +189 -0
- package/src/lib/ai/adapters/claude-cli.js +55 -0
- package/src/lib/ai/adapters/cli-adapters.test.js +133 -0
- package/src/lib/ai/adapters/codex-cli.js +77 -0
- package/src/lib/ai/adapters/command.js +23 -13
- package/src/lib/ai/adapters/gemini-cli.js +55 -0
- package/src/lib/ai/adapters/openclaw.js +91 -0
- package/src/lib/ai/agent-actions.js +271 -0
- package/src/lib/ai/agent.js +323 -0
- package/src/lib/ai/body-template.js +15 -0
- package/src/lib/ai/discovery.js +89 -0
- package/src/lib/ai/discovery.test.js +74 -0
- package/src/lib/ai/enhance.js +48 -11
- package/src/lib/ai/enhancement-adapter.js +109 -0
- package/src/lib/ai/enhancement-adapter.test.js +188 -0
- package/src/lib/ai/index.js +2 -2
- package/src/lib/ai/pipeline.js +20 -2
- package/src/lib/ai/pipeline.test.js +257 -0
- package/src/lib/ai/prompt.test.js +30 -0
- package/src/lib/ai/router.js +118 -7
- package/src/lib/ai/router.test.js +481 -0
- package/src/lib/ai/steps.js +23 -3
- package/src/lib/ai/steps.test.js +335 -0
- package/src/lib/attribution.test.js +64 -0
- package/src/lib/cache.js +408 -0
- package/src/lib/db.js +42 -0
- package/src/lib/dedup.js +44 -48
- package/src/lib/dedup.test.js +227 -0
- package/src/lib/defaults.js +37 -1
- package/src/lib/defaults.test.js +217 -0
- package/src/lib/drafts-perf.test.js +203 -0
- package/src/lib/drafts.test.js +300 -0
- package/src/lib/enhancements.js +436 -0
- package/src/lib/enhancements.test.js +294 -0
- package/src/lib/gh.js +76 -10
- package/src/lib/safety.test.js +217 -0
- package/src/lib/storage.js +298 -0
- package/src/lib/templates.test.js +207 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { test, describe } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { normalizeText, computeFingerprint, computeIdempotencyKey, jaccardSimilarity, scoreDedupConfidence } from './dedup.js'
|
|
4
|
+
|
|
5
|
+
describe('normalizeText', () => {
|
|
6
|
+
test('lowercases input', () => {
|
|
7
|
+
assert.equal(normalizeText('Hello World'), 'hello world')
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
test('replaces non-alphanumeric characters with spaces', () => {
|
|
11
|
+
assert.equal(normalizeText('foo-bar_baz!qux'), 'foo bar baz qux')
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
test('collapses multiple spaces into one', () => {
|
|
15
|
+
assert.equal(normalizeText('foo bar baz'), 'foo bar baz')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test('trims leading and trailing whitespace', () => {
|
|
19
|
+
assert.equal(normalizeText(' hello world '), 'hello world')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test('handles empty string', () => {
|
|
23
|
+
assert.equal(normalizeText(''), '')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('handles only special characters', () => {
|
|
27
|
+
assert.equal(normalizeText('!!!---...'), '')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test('preserves digits', () => {
|
|
31
|
+
assert.equal(normalizeText('fix bug 42 in v2'), 'fix bug 42 in v2')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('normalizes punctuation-heavy title', () => {
|
|
35
|
+
assert.equal(normalizeText('[BUG]: Fix null-pointer exception!'), 'bug fix null pointer exception')
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
describe('computeFingerprint', () => {
|
|
40
|
+
test('returns a 64-character hex string', () => {
|
|
41
|
+
const fp = computeFingerprint('Title', 'Body text')
|
|
42
|
+
assert.equal(typeof fp, 'string')
|
|
43
|
+
assert.equal(fp.length, 64)
|
|
44
|
+
assert.match(fp, /^[0-9a-f]{64}$/)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('same inputs produce same fingerprint', () => {
|
|
48
|
+
const fp1 = computeFingerprint('Fix login bug', 'The login page crashes when...')
|
|
49
|
+
const fp2 = computeFingerprint('Fix login bug', 'The login page crashes when...')
|
|
50
|
+
assert.equal(fp1, fp2)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('different titles produce different fingerprints', () => {
|
|
54
|
+
const fp1 = computeFingerprint('Fix login bug', 'Same body')
|
|
55
|
+
const fp2 = computeFingerprint('Fix signup bug', 'Same body')
|
|
56
|
+
assert.notEqual(fp1, fp2)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('different bodies produce different fingerprints', () => {
|
|
60
|
+
const fp1 = computeFingerprint('Same title', 'Body A')
|
|
61
|
+
const fp2 = computeFingerprint('Same title', 'Body B')
|
|
62
|
+
assert.notEqual(fp1, fp2)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('only uses first 500 chars of body', () => {
|
|
66
|
+
const body500 = 'a'.repeat(500)
|
|
67
|
+
const body501 = 'a'.repeat(500) + 'z'
|
|
68
|
+
const fp3 = computeFingerprint('T', body500)
|
|
69
|
+
const fp4 = computeFingerprint('T', body501)
|
|
70
|
+
assert.equal(fp3, fp4)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('handles null/undefined body gracefully', () => {
|
|
74
|
+
const fp1 = computeFingerprint('Title', null)
|
|
75
|
+
const fp2 = computeFingerprint('Title', undefined)
|
|
76
|
+
const fp3 = computeFingerprint('Title', '')
|
|
77
|
+
assert.equal(fp1, fp3)
|
|
78
|
+
assert.equal(fp2, fp3)
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
describe('computeIdempotencyKey', () => {
|
|
83
|
+
test('returns a 64-character hex string', () => {
|
|
84
|
+
const key = computeIdempotencyKey({ agent: 'bot', trigger: 'cli', issueType: 'bug', repo: 'owner/repo' })
|
|
85
|
+
assert.equal(typeof key, 'string')
|
|
86
|
+
assert.equal(key.length, 64)
|
|
87
|
+
assert.match(key, /^[0-9a-f]{64}$/)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('same context produces same key', () => {
|
|
91
|
+
const ctx = { agent: 'bot', trigger: 'api', issueType: 'feature', repo: 'org/proj' }
|
|
92
|
+
assert.equal(computeIdempotencyKey(ctx), computeIdempotencyKey(ctx))
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('different agent produces different key', () => {
|
|
96
|
+
const ctx = { agent: 'bot-a', trigger: 'api', issueType: 'bug', repo: 'org/repo' }
|
|
97
|
+
const ctx2 = { ...ctx, agent: 'bot-b' }
|
|
98
|
+
assert.notEqual(computeIdempotencyKey(ctx), computeIdempotencyKey(ctx2))
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test('different repo produces different key', () => {
|
|
102
|
+
const ctx = { agent: 'bot', trigger: 'api', issueType: 'bug', repo: 'org/repo-a' }
|
|
103
|
+
const ctx2 = { ...ctx, repo: 'org/repo-b' }
|
|
104
|
+
assert.notEqual(computeIdempotencyKey(ctx), computeIdempotencyKey(ctx2))
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
describe('jaccardSimilarity', () => {
|
|
109
|
+
test('identical strings return 1.0', () => {
|
|
110
|
+
assert.equal(jaccardSimilarity('fix login crash', 'fix login crash'), 1)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test('both empty strings return 1.0', () => {
|
|
114
|
+
assert.equal(jaccardSimilarity('', ''), 1)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test('both only stop words return 1.0', () => {
|
|
118
|
+
// After stop-word removal, both are empty token sets
|
|
119
|
+
assert.equal(jaccardSimilarity('the and or', 'is are was'), 1)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
test('one empty string returns 0', () => {
|
|
123
|
+
assert.equal(jaccardSimilarity('', 'fix login'), 0)
|
|
124
|
+
assert.equal(jaccardSimilarity('fix login', ''), 0)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
test('one all-stop-words vs meaningful tokens returns 0', () => {
|
|
128
|
+
assert.equal(jaccardSimilarity('the and or', 'fix login crash'), 0)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
test('zero word overlap returns 0', () => {
|
|
132
|
+
assert.equal(jaccardSimilarity('stop agents hallucinating', 'sadfdasfa'), 0)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
test('partial overlap computes correct ratio', () => {
|
|
136
|
+
// tokens: {fix,login,crash} vs {fix,login,error} → intersection=2, union=4
|
|
137
|
+
const sim = jaccardSimilarity('fix login crash', 'fix login error')
|
|
138
|
+
assert.equal(sim, 2 / 4)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
test('stop words are stripped from comparison', () => {
|
|
142
|
+
// "fix the login crash" → {fix,login,crash}; "fix a login error" → {fix,login,error}
|
|
143
|
+
const sim = jaccardSimilarity('fix the login crash', 'fix a login error')
|
|
144
|
+
assert.equal(sim, 2 / 4)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
test('high overlap for near-duplicate titles', () => {
|
|
148
|
+
// {fix,login,crash} vs {fix,login,crash,safari} → 3/4 = 0.75
|
|
149
|
+
const sim = jaccardSimilarity('fix login crash', 'fix login crash on safari')
|
|
150
|
+
assert.equal(sim, 3 / 4)
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
test('symmetric: sim(a,b) === sim(b,a)', () => {
|
|
154
|
+
const a = 'authentication error in production'
|
|
155
|
+
const b = 'authorization failure in staging'
|
|
156
|
+
assert.equal(jaccardSimilarity(a, b), jaccardSimilarity(b, a))
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
test('returns value in [0, 1] range', () => {
|
|
160
|
+
const pairs = [
|
|
161
|
+
['', 'abc'],
|
|
162
|
+
['abc', ''],
|
|
163
|
+
['abc', 'abc'],
|
|
164
|
+
['totally', 'different'],
|
|
165
|
+
['Fix null pointer', 'fix null-pointer exception in login'],
|
|
166
|
+
]
|
|
167
|
+
for (const [a, b] of pairs) {
|
|
168
|
+
const sim = jaccardSimilarity(a, b)
|
|
169
|
+
assert.ok(sim >= 0 && sim <= 1, `similarity out of range for "${a}", "${b}": ${sim}`)
|
|
170
|
+
}
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
test('short titles with different words have low similarity', () => {
|
|
174
|
+
// "fix bug" → {fix,bug}, "fix auth" → {fix,auth} → 1/3 = 0.33
|
|
175
|
+
const sim = jaccardSimilarity('fix bug', 'fix auth')
|
|
176
|
+
assert.ok(sim < 0.5, `expected < 0.5 for short distinct titles, got ${sim}`)
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
test('case insensitive', () => {
|
|
180
|
+
assert.equal(jaccardSimilarity('Fix Login Crash', 'fix login crash'), 1)
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
test('punctuation ignored', () => {
|
|
184
|
+
// Punctuation stripped: "fix login crash!" → {fix,login,crash}, same as "fix login crash"
|
|
185
|
+
assert.equal(jaccardSimilarity('fix login crash!', 'fix login crash'), 1)
|
|
186
|
+
})
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
describe('scoreDedupConfidence', () => {
|
|
190
|
+
test('high confidence at 70+', () => {
|
|
191
|
+
const result = scoreDedupConfidence({ titleOverlap: 50, fileMentions: 20, labelMatches: 5 })
|
|
192
|
+
assert.equal(result.level, 'high')
|
|
193
|
+
assert.equal(result.confidence, 75)
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
test('medium confidence at 40-69', () => {
|
|
197
|
+
const result = scoreDedupConfidence({ titleOverlap: 30, fileMentions: 15, labelMatches: 0 })
|
|
198
|
+
assert.equal(result.level, 'medium')
|
|
199
|
+
assert.equal(result.confidence, 45)
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
test('low confidence at 21-39', () => {
|
|
203
|
+
const result = scoreDedupConfidence({ titleOverlap: 20, fileMentions: 5, labelMatches: 0 })
|
|
204
|
+
assert.equal(result.level, 'low')
|
|
205
|
+
assert.equal(result.confidence, 25)
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
test('none confidence at 20 or below', () => {
|
|
209
|
+
const result = scoreDedupConfidence({ titleOverlap: 10, fileMentions: 5, labelMatches: 5 })
|
|
210
|
+
assert.equal(result.level, 'none')
|
|
211
|
+
assert.equal(result.confidence, 20)
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
test('clamps to 0-100 range', () => {
|
|
215
|
+
const high = scoreDedupConfidence({ titleOverlap: 60, fileMentions: 40, labelMatches: 20 })
|
|
216
|
+
assert.equal(high.confidence, 100)
|
|
217
|
+
|
|
218
|
+
const low = scoreDedupConfidence({ titleOverlap: -10, fileMentions: 0, labelMatches: 0 })
|
|
219
|
+
assert.equal(low.confidence, 0)
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
test('defaults to zero for missing fields', () => {
|
|
223
|
+
const result = scoreDedupConfidence({})
|
|
224
|
+
assert.equal(result.confidence, 0)
|
|
225
|
+
assert.equal(result.level, 'none')
|
|
226
|
+
})
|
|
227
|
+
})
|
package/src/lib/defaults.js
CHANGED
|
@@ -36,6 +36,11 @@ export const BUILT_IN_DEFAULTS = {
|
|
|
36
36
|
default: 'default', // default template name
|
|
37
37
|
},
|
|
38
38
|
|
|
39
|
+
// Enhancements
|
|
40
|
+
enhancements: {
|
|
41
|
+
dir: '.tissues/enhancements', // relative to repo root, or absolute
|
|
42
|
+
},
|
|
43
|
+
|
|
39
44
|
// AI
|
|
40
45
|
ai: {
|
|
41
46
|
enabled: true,
|
|
@@ -48,6 +53,10 @@ export const BUILT_IN_DEFAULTS = {
|
|
|
48
53
|
ollama: 'llama3.2',
|
|
49
54
|
'openai-compat': null, // user must set
|
|
50
55
|
command: null, // not applicable
|
|
56
|
+
'gemini-cli': null, // uses CLI default
|
|
57
|
+
'claude-cli': null, // uses CLI default
|
|
58
|
+
'codex-cli': null, // uses CLI default
|
|
59
|
+
openclaw: null, // set by gateway
|
|
51
60
|
},
|
|
52
61
|
keys: {
|
|
53
62
|
anthropic: null,
|
|
@@ -57,7 +66,13 @@ export const BUILT_IN_DEFAULTS = {
|
|
|
57
66
|
},
|
|
58
67
|
ollama: { url: 'http://localhost:11434' },
|
|
59
68
|
custom: { url: null }, // openai-compat base URL
|
|
69
|
+
openclaw: { // OpenClaw gateway config
|
|
70
|
+
gatewayUrl: null, // default: http://localhost:18790
|
|
71
|
+
token: null, // or OPENCLAW_GATEWAY_TOKEN env var
|
|
72
|
+
agentId: null, // optional agent ID
|
|
73
|
+
},
|
|
60
74
|
command: null, // e.g. 'co inference'
|
|
75
|
+
providers: {}, // named custom providers: { "my-gemini": { command: "co gemini", timeout: 120000 } }
|
|
61
76
|
routes: [], // routing rules
|
|
62
77
|
budgets: {
|
|
63
78
|
maxTokensPerRequest: 4096, // hard cap per single AI call
|
|
@@ -66,7 +81,7 @@ export const BUILT_IN_DEFAULTS = {
|
|
|
66
81
|
},
|
|
67
82
|
pipeline: {
|
|
68
83
|
enabled: true, // multi-step enhancement pipeline
|
|
69
|
-
steps: {
|
|
84
|
+
steps: { // legacy key (alias for enhancements)
|
|
70
85
|
triage: 'always', // 'always' | 'auto' | 'never'
|
|
71
86
|
dedup: 'auto', // 'always' | 'auto' | 'never'
|
|
72
87
|
context: 'always',
|
|
@@ -76,9 +91,30 @@ export const BUILT_IN_DEFAULTS = {
|
|
|
76
91
|
labels: 'auto',
|
|
77
92
|
format: 'always',
|
|
78
93
|
},
|
|
94
|
+
enhancements: { // preferred key (same shape as steps)
|
|
95
|
+
triage: 'always',
|
|
96
|
+
dedup: 'auto',
|
|
97
|
+
context: 'always',
|
|
98
|
+
scope: 'auto',
|
|
99
|
+
complexity: 'auto',
|
|
100
|
+
risk: 'auto',
|
|
101
|
+
labels: 'auto',
|
|
102
|
+
format: 'always',
|
|
103
|
+
},
|
|
79
104
|
},
|
|
80
105
|
},
|
|
81
106
|
|
|
107
|
+
// Background sync
|
|
108
|
+
sync: {
|
|
109
|
+
enabled: false, // opt-in
|
|
110
|
+
repos: [], // empty = activeRepo only
|
|
111
|
+
interval: 300, // seconds (5 min default)
|
|
112
|
+
adaptiveInterval: true, // adjust based on user activity
|
|
113
|
+
issueLimit: 500, // max issues per repo per sync
|
|
114
|
+
includeClosedDays: 30, // sync issues closed within N days
|
|
115
|
+
autoStart: false, // auto-fork daemon on first CLI use
|
|
116
|
+
},
|
|
117
|
+
|
|
82
118
|
// Hooks (shell commands to run)
|
|
83
119
|
hooks: {
|
|
84
120
|
postCreate: null, // e.g., 'slack-notify.sh'
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { test, describe } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import fs from 'node:fs'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import os from 'node:os'
|
|
6
|
+
import { BUILT_IN_DEFAULTS, findRepoRoot, loadConfig, getConfigValue, userConfigPath } from './defaults.js'
|
|
7
|
+
|
|
8
|
+
describe('BUILT_IN_DEFAULTS', () => {
|
|
9
|
+
test('has safety section with expected keys', () => {
|
|
10
|
+
assert.ok(BUILT_IN_DEFAULTS.safety)
|
|
11
|
+
assert.equal(typeof BUILT_IN_DEFAULTS.safety.maxPerHour, 'number')
|
|
12
|
+
assert.equal(typeof BUILT_IN_DEFAULTS.safety.burstLimit, 'number')
|
|
13
|
+
assert.equal(typeof BUILT_IN_DEFAULTS.safety.burstWindowMinutes, 'number')
|
|
14
|
+
assert.equal(typeof BUILT_IN_DEFAULTS.safety.tripThreshold, 'number')
|
|
15
|
+
assert.equal(typeof BUILT_IN_DEFAULTS.safety.cooldownMinutes, 'number')
|
|
16
|
+
assert.equal(typeof BUILT_IN_DEFAULTS.safety.globalMaxPerHour, 'number')
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
test('has dedup section with thresholds', () => {
|
|
20
|
+
assert.ok(BUILT_IN_DEFAULTS.dedup)
|
|
21
|
+
assert.equal(BUILT_IN_DEFAULTS.dedup.blockAbove, 0.95)
|
|
22
|
+
assert.equal(BUILT_IN_DEFAULTS.dedup.warnAbove, 0.80)
|
|
23
|
+
assert.ok(typeof BUILT_IN_DEFAULTS.dedup.fingerprintTTLDays === 'number')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('has attribution section', () => {
|
|
27
|
+
assert.ok(BUILT_IN_DEFAULTS.attribution)
|
|
28
|
+
assert.equal(BUILT_IN_DEFAULTS.attribution.required, false)
|
|
29
|
+
assert.equal(BUILT_IN_DEFAULTS.attribution.defaultAgent, 'human')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('has templates section', () => {
|
|
33
|
+
assert.ok(BUILT_IN_DEFAULTS.templates)
|
|
34
|
+
assert.ok(typeof BUILT_IN_DEFAULTS.templates.dir === 'string')
|
|
35
|
+
assert.equal(BUILT_IN_DEFAULTS.templates.default, 'default')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('has ai section', () => {
|
|
39
|
+
assert.ok(BUILT_IN_DEFAULTS.ai)
|
|
40
|
+
assert.equal(typeof BUILT_IN_DEFAULTS.ai.enabled, 'boolean')
|
|
41
|
+
assert.ok(typeof BUILT_IN_DEFAULTS.ai.provider === 'string')
|
|
42
|
+
assert.equal(BUILT_IN_DEFAULTS.ai.model, null)
|
|
43
|
+
assert.ok(BUILT_IN_DEFAULTS.ai.models)
|
|
44
|
+
assert.ok(typeof BUILT_IN_DEFAULTS.ai.models.anthropic === 'string')
|
|
45
|
+
assert.ok(BUILT_IN_DEFAULTS.ai.keys)
|
|
46
|
+
assert.ok(Array.isArray(BUILT_IN_DEFAULTS.ai.routes))
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('has hooks section with null defaults', () => {
|
|
50
|
+
assert.ok(BUILT_IN_DEFAULTS.hooks)
|
|
51
|
+
assert.equal(BUILT_IN_DEFAULTS.hooks.postCreate, null)
|
|
52
|
+
assert.equal(BUILT_IN_DEFAULTS.hooks.postClose, null)
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe('findRepoRoot', () => {
|
|
57
|
+
test('finds a git repo root from a subdirectory', () => {
|
|
58
|
+
// The project itself is a git repo
|
|
59
|
+
const root = findRepoRoot(path.join(process.cwd(), 'src', 'lib'))
|
|
60
|
+
assert.ok(root, 'should find a repo root')
|
|
61
|
+
assert.ok(fs.existsSync(path.join(root, '.git')))
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('returns null when no git repo found', () => {
|
|
65
|
+
// Use os.tmpdir() which (almost certainly) has no .git above it
|
|
66
|
+
const root = findRepoRoot(os.tmpdir())
|
|
67
|
+
// On some systems tmpdir might be inside a git repo; just check type
|
|
68
|
+
assert.ok(root === null || typeof root === 'string')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('returns the directory that contains .git, not a subdirectory', () => {
|
|
72
|
+
const root = findRepoRoot(process.cwd())
|
|
73
|
+
assert.ok(root)
|
|
74
|
+
assert.ok(fs.existsSync(path.join(root, '.git')))
|
|
75
|
+
// The .git directory should be a direct child, not deeper
|
|
76
|
+
const gitPath = path.join(root, '.git')
|
|
77
|
+
const stat = fs.statSync(gitPath)
|
|
78
|
+
assert.ok(stat.isDirectory() || stat.isFile()) // worktrees use a file
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
describe('userConfigPath', () => {
|
|
83
|
+
test('returns a string path ending in config.json', () => {
|
|
84
|
+
const p = userConfigPath()
|
|
85
|
+
assert.ok(typeof p === 'string')
|
|
86
|
+
assert.ok(p.endsWith('config.json'))
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test('includes tissues in the path', () => {
|
|
90
|
+
const p = userConfigPath()
|
|
91
|
+
assert.ok(p.includes('tissues'))
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('is under the home directory', () => {
|
|
95
|
+
const p = userConfigPath()
|
|
96
|
+
const home = os.homedir()
|
|
97
|
+
assert.ok(p.startsWith(home))
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe('loadConfig', () => {
|
|
102
|
+
test('returns an object with all built-in sections when no files exist', () => {
|
|
103
|
+
// Use a temp dir with .git so loadConfig does not pick up project-level config
|
|
104
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tissues-defaults-test-'))
|
|
105
|
+
fs.mkdirSync(path.join(tmpDir, '.git'))
|
|
106
|
+
try {
|
|
107
|
+
const cfg = loadConfig(tmpDir)
|
|
108
|
+
assert.ok(cfg.safety)
|
|
109
|
+
assert.ok(cfg.dedup)
|
|
110
|
+
assert.ok(cfg.attribution)
|
|
111
|
+
assert.ok(cfg.templates)
|
|
112
|
+
assert.ok(cfg.ai)
|
|
113
|
+
assert.ok(cfg.hooks)
|
|
114
|
+
} finally {
|
|
115
|
+
fs.rmSync(tmpDir, { recursive: true, force: true })
|
|
116
|
+
}
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
test('CLI overrides take highest priority', () => {
|
|
120
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tissues-cli-override-test-'))
|
|
121
|
+
fs.mkdirSync(path.join(tmpDir, '.git'))
|
|
122
|
+
try {
|
|
123
|
+
const cfg = loadConfig(tmpDir, { safety: { maxPerHour: 999 } })
|
|
124
|
+
assert.equal(cfg.safety.maxPerHour, 999)
|
|
125
|
+
} finally {
|
|
126
|
+
fs.rmSync(tmpDir, { recursive: true, force: true })
|
|
127
|
+
}
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test('repo-level config overrides built-in defaults', () => {
|
|
131
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tissues-repo-cfg-test-'))
|
|
132
|
+
fs.mkdirSync(path.join(tmpDir, '.git'))
|
|
133
|
+
fs.mkdirSync(path.join(tmpDir, '.tissues'))
|
|
134
|
+
fs.writeFileSync(
|
|
135
|
+
path.join(tmpDir, '.tissues', 'config.json'),
|
|
136
|
+
JSON.stringify({ safety: { maxPerHour: 50 } })
|
|
137
|
+
)
|
|
138
|
+
try {
|
|
139
|
+
const cfg = loadConfig(tmpDir)
|
|
140
|
+
assert.equal(cfg.safety.maxPerHour, 50)
|
|
141
|
+
// Other fields still come from defaults
|
|
142
|
+
assert.equal(cfg.safety.burstLimit, BUILT_IN_DEFAULTS.safety.burstLimit)
|
|
143
|
+
} finally {
|
|
144
|
+
fs.rmSync(tmpDir, { recursive: true, force: true })
|
|
145
|
+
}
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('CLI overrides take priority over repo config', () => {
|
|
149
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tissues-priority-test-'))
|
|
150
|
+
fs.mkdirSync(path.join(tmpDir, '.git'))
|
|
151
|
+
fs.mkdirSync(path.join(tmpDir, '.tissues'))
|
|
152
|
+
fs.writeFileSync(
|
|
153
|
+
path.join(tmpDir, '.tissues', 'config.json'),
|
|
154
|
+
JSON.stringify({ safety: { maxPerHour: 50 } })
|
|
155
|
+
)
|
|
156
|
+
try {
|
|
157
|
+
const cfg = loadConfig(tmpDir, { safety: { maxPerHour: 100 } })
|
|
158
|
+
assert.equal(cfg.safety.maxPerHour, 100)
|
|
159
|
+
} finally {
|
|
160
|
+
fs.rmSync(tmpDir, { recursive: true, force: true })
|
|
161
|
+
}
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
test('deep merge does not clobber sibling keys', () => {
|
|
165
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tissues-deep-merge-test-'))
|
|
166
|
+
fs.mkdirSync(path.join(tmpDir, '.git'))
|
|
167
|
+
try {
|
|
168
|
+
// Override only one key in safety; others should remain
|
|
169
|
+
const cfg = loadConfig(tmpDir, { safety: { maxPerHour: 20 } })
|
|
170
|
+
assert.equal(cfg.safety.maxPerHour, 20)
|
|
171
|
+
assert.equal(cfg.safety.burstLimit, BUILT_IN_DEFAULTS.safety.burstLimit)
|
|
172
|
+
assert.equal(cfg.safety.tripThreshold, BUILT_IN_DEFAULTS.safety.tripThreshold)
|
|
173
|
+
} finally {
|
|
174
|
+
fs.rmSync(tmpDir, { recursive: true, force: true })
|
|
175
|
+
}
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
test('invalid JSON in repo config is silently ignored', () => {
|
|
179
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tissues-bad-json-test-'))
|
|
180
|
+
fs.mkdirSync(path.join(tmpDir, '.git'))
|
|
181
|
+
fs.mkdirSync(path.join(tmpDir, '.tissues'))
|
|
182
|
+
fs.writeFileSync(path.join(tmpDir, '.tissues', 'config.json'), 'not json {{{')
|
|
183
|
+
try {
|
|
184
|
+
const cfg = loadConfig(tmpDir)
|
|
185
|
+
// Should fall back gracefully to defaults
|
|
186
|
+
assert.equal(cfg.safety.maxPerHour, BUILT_IN_DEFAULTS.safety.maxPerHour)
|
|
187
|
+
} finally {
|
|
188
|
+
fs.rmSync(tmpDir, { recursive: true, force: true })
|
|
189
|
+
}
|
|
190
|
+
})
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
describe('getConfigValue', () => {
|
|
194
|
+
test('retrieves a nested value using dot notation', () => {
|
|
195
|
+
// Use the actual tissues repo root — the project itself is a git repo
|
|
196
|
+
const val = getConfigValue('safety.maxPerHour')
|
|
197
|
+
assert.ok(typeof val === 'number')
|
|
198
|
+
assert.ok(val > 0)
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
test('returns undefined for unknown key path', () => {
|
|
202
|
+
const val = getConfigValue('nonexistent.key.path')
|
|
203
|
+
assert.equal(val, undefined)
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
test('retrieves top-level section', () => {
|
|
207
|
+
const safety = getConfigValue('safety')
|
|
208
|
+
assert.ok(safety && typeof safety === 'object')
|
|
209
|
+
assert.ok('maxPerHour' in safety)
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
test('retrieves deeply nested ai.models.anthropic', () => {
|
|
213
|
+
const model = getConfigValue('ai.models.anthropic')
|
|
214
|
+
assert.ok(typeof model === 'string')
|
|
215
|
+
assert.ok(model.length > 0)
|
|
216
|
+
})
|
|
217
|
+
})
|