tissues 0.6.1 → 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.
Files changed (42) hide show
  1. package/README.md +78 -1
  2. package/package.json +4 -2
  3. package/src/cli.js +10 -1
  4. package/src/commands/ai.js +147 -173
  5. package/src/commands/create.js +6 -6
  6. package/src/commands/create.test.js +381 -0
  7. package/src/commands/flush.test.js +299 -0
  8. package/src/commands/list.js +3 -2
  9. package/src/commands/providers.js +347 -0
  10. package/src/commands/providers.test.js +28 -0
  11. package/src/commands/storage.js +167 -0
  12. package/src/commands/sync.js +225 -0
  13. package/src/daemon/sync.js +189 -0
  14. package/src/lib/ai/adapters/claude-cli.js +55 -0
  15. package/src/lib/ai/adapters/cli-adapters.test.js +133 -0
  16. package/src/lib/ai/adapters/codex-cli.js +77 -0
  17. package/src/lib/ai/adapters/gemini-cli.js +55 -0
  18. package/src/lib/ai/adapters/openclaw.js +91 -0
  19. package/src/lib/ai/agent-actions.js +271 -0
  20. package/src/lib/ai/agent.js +323 -0
  21. package/src/lib/ai/discovery.js +89 -0
  22. package/src/lib/ai/discovery.test.js +74 -0
  23. package/src/lib/ai/enhancement-adapter.test.js +188 -0
  24. package/src/lib/ai/pipeline.test.js +257 -0
  25. package/src/lib/ai/prompt.test.js +30 -0
  26. package/src/lib/ai/router.js +23 -0
  27. package/src/lib/ai/router.test.js +481 -0
  28. package/src/lib/ai/steps.test.js +335 -0
  29. package/src/lib/attribution.test.js +64 -0
  30. package/src/lib/cache.js +408 -0
  31. package/src/lib/db.js +42 -0
  32. package/src/lib/dedup.js +8 -18
  33. package/src/lib/dedup.test.js +227 -0
  34. package/src/lib/defaults.js +20 -0
  35. package/src/lib/defaults.test.js +217 -0
  36. package/src/lib/drafts-perf.test.js +203 -0
  37. package/src/lib/drafts.test.js +300 -0
  38. package/src/lib/enhancements.test.js +294 -0
  39. package/src/lib/gh.js +60 -0
  40. package/src/lib/safety.test.js +217 -0
  41. package/src/lib/storage.js +298 -0
  42. 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
+ })
@@ -53,6 +53,10 @@ export const BUILT_IN_DEFAULTS = {
53
53
  ollama: 'llama3.2',
54
54
  'openai-compat': null, // user must set
55
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
56
60
  },
57
61
  keys: {
58
62
  anthropic: null,
@@ -62,6 +66,11 @@ export const BUILT_IN_DEFAULTS = {
62
66
  },
63
67
  ollama: { url: 'http://localhost:11434' },
64
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
+ },
65
74
  command: null, // e.g. 'co inference'
66
75
  providers: {}, // named custom providers: { "my-gemini": { command: "co gemini", timeout: 120000 } }
67
76
  routes: [], // routing rules
@@ -95,6 +104,17 @@ export const BUILT_IN_DEFAULTS = {
95
104
  },
96
105
  },
97
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
+
98
118
  // Hooks (shell commands to run)
99
119
  hooks: {
100
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
+ })
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Performance and adversarial tests for drafts.js.
3
+ *
4
+ * These tests verify that drafts are correct under load:
5
+ * - 100 concurrent writes produce no collisions
6
+ * - Reading 1000 items completes in < 500ms
7
+ * - Corrupt files don't prevent other items from processing
8
+ * - writeToDrafts during an active readDrafts doesn't cause double-processing
9
+ *
10
+ * Run standalone: node --test src/lib/drafts-perf.test.js
11
+ */
12
+
13
+ import { test, describe, before, after } from 'node:test'
14
+ import assert from 'node:assert/strict'
15
+ import fs from 'node:fs'
16
+ import path from 'node:path'
17
+ import os from 'node:os'
18
+
19
+ import {
20
+ getDraftsDir,
21
+ writeToDrafts,
22
+ readDrafts,
23
+ markComplete,
24
+ hasPending,
25
+ countPending,
26
+ } from './drafts.js'
27
+
28
+ function makeTempRepo() {
29
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tissues-drafts-perf-'))
30
+ fs.mkdirSync(path.join(dir, '.git'))
31
+ return dir
32
+ }
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // 100 concurrent writes
36
+ // ---------------------------------------------------------------------------
37
+
38
+ describe('100 concurrent writes', () => {
39
+ let tmpDir
40
+
41
+ before(() => { tmpDir = makeTempRepo() })
42
+ after(() => { fs.rmSync(tmpDir, { recursive: true, force: true }) })
43
+
44
+ test('all 100 files exist with unique IDs', async () => {
45
+ const writes = Array.from({ length: 100 }, (_, i) =>
46
+ Promise.resolve(
47
+ writeToDrafts({ repo: 'perf/repo', title: `Issue ${i}`, body: `body ${i}` }, tmpDir),
48
+ ),
49
+ )
50
+
51
+ const items = await Promise.all(writes)
52
+ const ids = new Set(items.map((i) => i.id))
53
+
54
+ assert.equal(ids.size, 100, 'all 100 IDs should be unique')
55
+ assert.equal(countPending(tmpDir), 100, 'all 100 files should be on disk')
56
+ })
57
+ })
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // Reading 1000 items
61
+ // ---------------------------------------------------------------------------
62
+
63
+ describe('readDrafts() at scale', () => {
64
+ let tmpDir
65
+
66
+ before(() => {
67
+ tmpDir = makeTempRepo()
68
+ const dir = getDraftsDir(tmpDir)
69
+
70
+ // Pre-seed 1000 items directly (faster than writeToDrafts loop)
71
+ for (let i = 0; i < 1000; i++) {
72
+ const id = `perf${String(i).padStart(6, '0')}`
73
+ const record = {
74
+ id,
75
+ repo: 'perf/repo',
76
+ title: `Issue ${i}`,
77
+ body: `body ${i}`,
78
+ labels: [],
79
+ createdAt: new Date(Date.now() - (1000 - i) * 1000).toISOString(),
80
+ status: 'pending',
81
+ attemptCount: 0,
82
+ }
83
+ fs.writeFileSync(path.join(dir, `${id}.json`), JSON.stringify(record), 'utf8')
84
+ }
85
+ })
86
+
87
+ after(() => { fs.rmSync(tmpDir, { recursive: true, force: true }) })
88
+
89
+ test('reads 1000 items in < 500ms', () => {
90
+ const start = Date.now()
91
+ const items = readDrafts(tmpDir)
92
+ const elapsed = Date.now() - start
93
+
94
+ assert.equal(items.length, 1000)
95
+ assert.ok(elapsed < 500, `readDrafts took ${elapsed}ms — expected < 500ms`)
96
+ })
97
+
98
+ test('results are sorted by createdAt', () => {
99
+ const items = readDrafts(tmpDir)
100
+ for (let i = 1; i < items.length; i++) {
101
+ const prev = new Date(items[i - 1].createdAt)
102
+ const curr = new Date(items[i].createdAt)
103
+ assert.ok(prev <= curr, `item ${i - 1} should be before item ${i}`)
104
+ }
105
+ })
106
+ })
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // Corrupt files don't break the rest
110
+ // ---------------------------------------------------------------------------
111
+
112
+ describe('adversarial: corrupt files in drafts', () => {
113
+ let tmpDir
114
+
115
+ before(() => {
116
+ tmpDir = makeTempRepo()
117
+ const dir = getDraftsDir(tmpDir)
118
+
119
+ // Write 5 valid items
120
+ for (let i = 0; i < 5; i++) {
121
+ writeToDrafts({ repo: 'a/b', title: `Valid ${i}`, body: '' }, tmpDir)
122
+ }
123
+
124
+ // Inject 3 corrupt files
125
+ fs.writeFileSync(path.join(dir, 'corrupt1.json'), '{ this is not json', 'utf8')
126
+ fs.writeFileSync(path.join(dir, 'corrupt2.json'), '', 'utf8')
127
+ fs.writeFileSync(path.join(dir, 'corrupt3.json'), 'null', 'utf8')
128
+ })
129
+
130
+ after(() => { fs.rmSync(tmpDir, { recursive: true, force: true }) })
131
+
132
+ test('readDrafts() skips corrupt files and returns all valid items', () => {
133
+ const items = readDrafts(tmpDir)
134
+ // Should get 5 valid items (null is valid JSON but has no .id, gets returned anyway)
135
+ const validItems = items.filter((i) => i && i.id)
136
+ assert.equal(validItems.length, 5, 'should return all 5 valid items')
137
+ })
138
+
139
+ test('hasPending() does not throw on corrupt dir', () => {
140
+ assert.doesNotThrow(() => hasPending(tmpDir))
141
+ assert.equal(hasPending(tmpDir), true)
142
+ })
143
+ })
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // New write during read doesn't cause double-processing
147
+ // ---------------------------------------------------------------------------
148
+
149
+ describe('concurrent write during read', () => {
150
+ let tmpDir
151
+
152
+ before(() => { tmpDir = makeTempRepo() })
153
+ after(() => { fs.rmSync(tmpDir, { recursive: true, force: true }) })
154
+
155
+ test('item written after readDrafts snapshot is not included in that batch', () => {
156
+ // Write 3 items before read
157
+ writeToDrafts({ repo: 'c/d', title: 'Pre A', body: '' }, tmpDir)
158
+ writeToDrafts({ repo: 'c/d', title: 'Pre B', body: '' }, tmpDir)
159
+ writeToDrafts({ repo: 'c/d', title: 'Pre C', body: '' }, tmpDir)
160
+
161
+ // Snapshot the batch
162
+ const batch = readDrafts(tmpDir)
163
+ assert.equal(batch.length, 3)
164
+
165
+ // Write a new item AFTER reading — simulates concurrent create
166
+ writeToDrafts({ repo: 'c/d', title: 'Post D', body: '' }, tmpDir)
167
+
168
+ // The batch we already read should not include the late arrival
169
+ const lateArrival = batch.find((i) => i.title === 'Post D')
170
+ assert.equal(lateArrival, undefined, 'late-arriving item should not appear in existing batch')
171
+
172
+ // A fresh read should see all 4
173
+ const fresh = readDrafts(tmpDir)
174
+ assert.equal(fresh.length, 4)
175
+ })
176
+ })
177
+
178
+ // ---------------------------------------------------------------------------
179
+ // SIGTERM survival (file persists if process would be killed mid-flush)
180
+ // ---------------------------------------------------------------------------
181
+
182
+ describe('durability: file survives after write', () => {
183
+ let tmpDir
184
+
185
+ before(() => { tmpDir = makeTempRepo() })
186
+ after(() => { fs.rmSync(tmpDir, { recursive: true, force: true }) })
187
+
188
+ test('draft file exists immediately after writeToDrafts (before any network call)', () => {
189
+ // Simulate: we write draft, then "crash" (don't call createIssue)
190
+ const item = writeToDrafts({ repo: 'e/f', title: 'Crash survivor', body: 'important' }, tmpDir)
191
+
192
+ // The file should exist on disk right now
193
+ const dir = getDraftsDir(tmpDir)
194
+ const filePath = path.join(dir, `${item.id}.json`)
195
+ assert.ok(fs.existsSync(filePath), 'file must exist immediately after write')
196
+
197
+ // Simulate a re-run: readDrafts finds it
198
+ const recovered = readDrafts(tmpDir)
199
+ const mine = recovered.find((i) => i.id === item.id)
200
+ assert.ok(mine, 'file should be recoverable via readDrafts after simulated crash')
201
+ assert.equal(mine.status, 'pending')
202
+ })
203
+ })