tspl-cli 0.0.0-stage → 0.1.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/README.md +127 -2
- package/bin/tspl.js +10 -0
- package/package.json +41 -4
- package/src/active-org.js +49 -0
- package/src/analysis/analyzer.js +61 -0
- package/src/analysis/extract.js +37 -0
- package/src/analysis/file-filter.js +69 -0
- package/src/analysis/git-reader.js +108 -0
- package/src/analysis/html-parser.js +46 -0
- package/src/analysis/js-parser.js +473 -0
- package/src/analysis/py-parser.js +66 -0
- package/src/analysis/repo-index.js +107 -0
- package/src/api-client.js +108 -0
- package/src/browser-tests.js +209 -0
- package/src/commands/analyze.command.js +81 -0
- package/src/commands/auth.command.js +250 -0
- package/src/commands/build.command.js +267 -0
- package/src/commands/connect.command.js +270 -0
- package/src/commands/engines.command.js +136 -0
- package/src/commands/find.command.js +107 -0
- package/src/commands/init.command.js +79 -0
- package/src/commands/link.command.js +65 -0
- package/src/commands/mcp.command.js +12 -0
- package/src/commands/member.command.js +82 -0
- package/src/commands/plan.command.js +161 -0
- package/src/commands/project.command.js +191 -0
- package/src/commands/setup.command.js +63 -0
- package/src/commands/sync.command.js +59 -0
- package/src/commands/task.command.js +475 -0
- package/src/commands/usage.command.js +105 -0
- package/src/config-store.js +255 -0
- package/src/connect/claude-skill.md +30 -0
- package/src/engines.js +218 -0
- package/src/find/graph.js +272 -0
- package/src/find/search.js +473 -0
- package/src/find/text.js +86 -0
- package/src/git-helper.js +102 -0
- package/src/index.js +91 -0
- package/src/mcp/server.js +708 -0
- package/src/project-config.js +197 -0
- package/src/project-init.js +447 -0
- package/src/providers/github.js +249 -0
- package/src/providers/index.js +83 -0
- package/src/runtime-check.js +18 -0
- package/src/setup.js +180 -0
- package/src/shared/approaches.js +44 -0
- package/src/shared/constants.js +89 -0
- package/src/shared/index.js +8 -0
- package/src/shared/workflow-progress.js +67 -0
- package/src/shared/workflow.js +381 -0
- package/src/stack-detector.js +47 -0
- package/src/task/approaches.js +53 -0
- package/src/task/branch-choice.js +69 -0
- package/src/task/brief.js +115 -0
- package/src/task/build.js +549 -0
- package/src/task/check.js +415 -0
- package/src/task/conventions.js +113 -0
- package/src/task/gate.js +88 -0
- package/src/task/hooks.js +82 -0
- package/src/task/plan-draft.js +43 -0
- package/src/task/plan-page.js +123 -0
- package/src/task/plan.js +293 -0
- package/src/task/planning-context.js +165 -0
- package/src/task/refine.js +73 -0
- package/src/workflow/agents.js +336 -0
- package/src/workflow/checkout-lock.js +92 -0
- package/src/workflow/client.js +82 -0
- package/src/workflow/controller.js +585 -0
- package/src/workflow/evidence.js +276 -0
- package/src/workflow/pr-body.js +52 -0
- package/src/workflow/process.js +82 -0
- package/src/workflow/review.js +128 -0
- package/src/workflow/runtime.js +148 -0
- package/src/workflow/wait.js +58 -0
- package/src/workspace.js +250 -0
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const Path = require('path').posix
|
|
4
|
+
const { splitTerms, words } = require('../find/text')
|
|
5
|
+
|
|
6
|
+
const MAX_QUESTIONS = 4
|
|
7
|
+
const BACKEND_LEAD = 3
|
|
8
|
+
const MAX_ASKS = 2
|
|
9
|
+
const BACKEND_ONLY = 'backend only'
|
|
10
|
+
const BUG_WORDS = /\b(bug|error|errors|fail|fails|failing|failed|broken|crash|crashes|wrong|incorrect|not working|doesn'?t work|does not|disappear|disappears|issue|stuck|missing|invalid)\b/i
|
|
11
|
+
const COMMON_VERBS = /^(load|loads|loading|show|shows|showing|open|opens|click|clicks|select|selects|display|displays|work|works|working|keep|keeps|allow|allows|need|needs|should|want|wants|when|where|after|before|still|also|only|every|each|some)$/i
|
|
12
|
+
const FEATURE_WORDS = /\b(add|adding|new|ability|allow|create|support|introduce|enable|implement)\b/i
|
|
13
|
+
const VAGUE = new Set(['add', 'new', 'featur', 'chang', 'updat', 'fix', 'need', 'want', 'make', 'creat', 'issu', 'bug', 'task', 'improv', 'enhanc', 'request', 'ability', 'abl'])
|
|
14
|
+
|
|
15
|
+
const KIND_LABELS = { app: 'app / screens', api: 'API', shared: 'shared code / jobs' }
|
|
16
|
+
|
|
17
|
+
const TEMPLATES = {
|
|
18
|
+
feature: ['what', 'who', 'where', 'how'],
|
|
19
|
+
change: ['what', 'where', 'now', 'how'],
|
|
20
|
+
bug: ['what', 'where', 'steps', 'now', 'expected'],
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const LABELS = {
|
|
24
|
+
feature: { what: 'Goal', how: 'How it works' },
|
|
25
|
+
change: { what: 'What should change', now: 'How it works today', how: 'How it should work' },
|
|
26
|
+
bug: { what: 'What is broken', now: 'What happens now', expected: 'What should happen' },
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const QUESTIONS = {
|
|
30
|
+
what: {
|
|
31
|
+
feature: 'What should users be able to do? One or two sentences.',
|
|
32
|
+
change: 'What should change? One or two sentences.',
|
|
33
|
+
bug: 'What is broken? One or two sentences.',
|
|
34
|
+
},
|
|
35
|
+
who: {
|
|
36
|
+
header: 'Who',
|
|
37
|
+
question: 'Who uses it?',
|
|
38
|
+
multiSelect: true,
|
|
39
|
+
suggestOptions: 'Suggest 2-4 user roles that fit the chosen app (for example resident, property manager, company admin, support).',
|
|
40
|
+
},
|
|
41
|
+
how: {
|
|
42
|
+
feature: {
|
|
43
|
+
header: 'How',
|
|
44
|
+
question: 'How should it work? The steps or rules, for example "a Sync button on the list pulls new work orders from Yardi for that property".',
|
|
45
|
+
suggestOptions: 'Suggest 2-3 short, different versions of how it could work, based on the task and the code; the user picks one or writes their own.',
|
|
46
|
+
},
|
|
47
|
+
change: {
|
|
48
|
+
header: 'Change',
|
|
49
|
+
question: 'How should it work after the change?',
|
|
50
|
+
suggestOptions: 'Suggest 2-3 short versions of the new behaviour; the user picks one or writes their own.',
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
now: {
|
|
54
|
+
change: { header: 'Today', question: 'How does it work today?' },
|
|
55
|
+
},
|
|
56
|
+
steps: { header: 'Steps', question: 'What are the steps to make it happen?' },
|
|
57
|
+
'now, expected': { header: 'Bug', question: 'What happens now, and what should happen instead?' },
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const MIN_TERMS = { what: 2, who: 1, how: 4, steps: 3, now: 2, expected: 2 }
|
|
61
|
+
const DESCRIBED = 12
|
|
62
|
+
|
|
63
|
+
const clean = value => (typeof value === 'string' ? value.trim() : '')
|
|
64
|
+
const list = value => (Array.isArray(value) ? value.map(clean).filter(Boolean) : clean(value) ? [clean(value)] : [])
|
|
65
|
+
|
|
66
|
+
const typeOf = value => {
|
|
67
|
+
const type = clean(value).toLowerCase()
|
|
68
|
+
if (/bug|broken|error|fix/.test(type)) return 'bug'
|
|
69
|
+
if (/feature|new/.test(type)) return 'feature'
|
|
70
|
+
if (/change|update|improve/.test(type)) return 'change'
|
|
71
|
+
return ''
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const guessType = text => {
|
|
75
|
+
if (BUG_WORDS.test(text)) return 'bug'
|
|
76
|
+
if (FEATURE_WORDS.test(text)) return 'feature'
|
|
77
|
+
return 'change'
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const normalize = card => ({
|
|
81
|
+
ticket: clean(card.ticket),
|
|
82
|
+
type: typeOf(card.type),
|
|
83
|
+
what: clean(card.what),
|
|
84
|
+
who: clean(card.who),
|
|
85
|
+
where: clean(card.where),
|
|
86
|
+
apps: list(card.apps),
|
|
87
|
+
screen: clean(card.screen),
|
|
88
|
+
how: clean(card.how),
|
|
89
|
+
steps: clean(card.steps),
|
|
90
|
+
now: clean(card.now),
|
|
91
|
+
expected: clean(card.expected),
|
|
92
|
+
doneWhen: list(card.doneWhen),
|
|
93
|
+
details: clean(card.details),
|
|
94
|
+
skip: Boolean(card.skip),
|
|
95
|
+
round: Number(card.round) || 0,
|
|
96
|
+
asked: card.asked && typeof card.asked === 'object' ? { ...card.asked } : {},
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
const taskText = card =>
|
|
100
|
+
[card.what, card.who, card.where, card.screen, card.how, card.steps, card.now, card.expected, card.details]
|
|
101
|
+
.filter(Boolean)
|
|
102
|
+
.join('. ')
|
|
103
|
+
|
|
104
|
+
const meaningful = value => splitTerms(value || '').single.filter(term => !VAGUE.has(term)).length
|
|
105
|
+
|
|
106
|
+
const section = (text, start, stop) => {
|
|
107
|
+
const match = text.match(new RegExp(`${start}\\s*:?\\s*\\n?([\\s\\S]*?)(?=\\n\\s*(?:${stop})\\b|$)`, 'i'))
|
|
108
|
+
return match ? match[1].replace(/\s+/g, ' ').trim() : ''
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const fromTicket = card => {
|
|
112
|
+
const text = card.details
|
|
113
|
+
const found = []
|
|
114
|
+
if (!text) return found
|
|
115
|
+
if (!card.who) {
|
|
116
|
+
const role = text.match(/\bAs an? ([a-z][\w\s/-]{2,40}?)(?:,|\n| I want| who)/i)
|
|
117
|
+
if (role) {
|
|
118
|
+
card.who = role[1].trim()
|
|
119
|
+
found.push('who')
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (!card.steps) {
|
|
123
|
+
const steps = section(text, 'steps to reproduce', 'expected|actual')
|
|
124
|
+
if (steps) {
|
|
125
|
+
card.steps = steps.slice(0, 400)
|
|
126
|
+
found.push('steps')
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (!card.expected) {
|
|
130
|
+
const expected = text.match(/\bexpected(?: result)?\s*:\s*(.+)/i)
|
|
131
|
+
if (expected) {
|
|
132
|
+
card.expected = expected[1].trim().slice(0, 300)
|
|
133
|
+
found.push('expected')
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (!card.now) {
|
|
137
|
+
const actual = text.match(/\bactual(?: result)?\s*:\s*(.+)/i)
|
|
138
|
+
if (actual) {
|
|
139
|
+
card.now = actual[1].trim().slice(0, 300)
|
|
140
|
+
found.push('now')
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (!card.doneWhen.length) {
|
|
144
|
+
const scenarios = text
|
|
145
|
+
.split(/scenario\s*:\s*/i)
|
|
146
|
+
.slice(1)
|
|
147
|
+
.map(block => {
|
|
148
|
+
const [title, ...rest] = block.split('\n')
|
|
149
|
+
const outcomes = rest.join('\n')
|
|
150
|
+
.split(/\n|\s+(?=(?:then|and)\s)/i)
|
|
151
|
+
.map(line => line.trim())
|
|
152
|
+
.filter(line => /^(then|and)\s/i.test(line))
|
|
153
|
+
.map(line => line.replace(/^(then|and)\s+/i, ''))
|
|
154
|
+
const check = outcomes.length ? `${title.trim()}: ${outcomes.join(', ')}` : title.trim()
|
|
155
|
+
return check.length > 180 ? `${check.slice(0, 180).trim()}…` : check
|
|
156
|
+
})
|
|
157
|
+
const thens = [...text.matchAll(/^\s*then\s+([^\n]+)/gim)].map(match => `Then ${match[1].trim()}`)
|
|
158
|
+
const checks = (scenarios.length ? scenarios : thens).slice(0, 6)
|
|
159
|
+
if (checks.length) {
|
|
160
|
+
card.doneWhen = checks
|
|
161
|
+
found.push('doneWhen')
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return found
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const filled = (card, field) => {
|
|
168
|
+
if (field === 'where') return card.apps.length > 0
|
|
169
|
+
if (['who', 'how', 'steps', 'now', 'expected'].includes(field) && meaningful(card.details) >= DESCRIBED) return true
|
|
170
|
+
return meaningful(card[field]) >= (MIN_TERMS[field] || 1)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const humanize = value => {
|
|
174
|
+
const text = words(value).join(' ')
|
|
175
|
+
return text.charAt(0).toUpperCase() + text.slice(1)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const isScreenFile = file => {
|
|
179
|
+
const facts = file.facts
|
|
180
|
+
return facts.language === 'html' || Boolean(facts.screens) || Boolean(facts.labels) ||
|
|
181
|
+
(facts.components || []).some(component => component.kind === 'component')
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const screenOf = path => {
|
|
185
|
+
const dir = Path.dirname(path)
|
|
186
|
+
const base = Path.basename(path).replace(/\.[^.]+$/, '')
|
|
187
|
+
const stem = base.split('.')[0]
|
|
188
|
+
return Path.basename(dir) === stem || /\.component$/.test(base) ? dir : Path.join(dir, stem)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const describePlace = (repo, dir) => {
|
|
192
|
+
const parts = dir.split('/').filter(part => !['src', 'app', 'pages', 'screens', 'components'].includes(part))
|
|
193
|
+
return [repo, ...parts.slice(0, -1).map(humanize)].join(' › ')
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const repoScores = (result, graph) => {
|
|
197
|
+
const scores = new Map()
|
|
198
|
+
result.files.forEach(file => scores.set(file.repo, (scores.get(file.repo) || 0) + file.score))
|
|
199
|
+
const kinds = new Map(graph.repos.map(repo => [repo.name, repo.kind]))
|
|
200
|
+
return [...scores.entries()]
|
|
201
|
+
.sort((a, b) => b[1] - a[1])
|
|
202
|
+
.map(([name, score]) => ({ name, score, kind: kinds.get(name) || 'shared' }))
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const APP_KINDS = [
|
|
206
|
+
['consol', 'portal', 'web', 'websit', 'site', 'dashboard', 'panel', 'frontend', 'ui', 'client'],
|
|
207
|
+
['nativ', 'mobil', 'app', 'ios', 'android', 'phon'],
|
|
208
|
+
['api', 'backend', 'server', 'servic'],
|
|
209
|
+
]
|
|
210
|
+
const kindOf = term => APP_KINDS.findIndex(group => group.includes(term))
|
|
211
|
+
|
|
212
|
+
const namedApps = (card, graph) => {
|
|
213
|
+
const said = new Set(splitTerms([card.what, card.where].join(' ')).single)
|
|
214
|
+
const saidKinds = new Set([...said].map(kindOf).filter(kind => kind >= 0))
|
|
215
|
+
return graph.repos
|
|
216
|
+
.filter(repo => {
|
|
217
|
+
const parts = splitTerms(repo.name).single
|
|
218
|
+
const specific = parts.filter(part => kindOf(part) < 0)
|
|
219
|
+
const kinds = new Set(parts.map(kindOf).filter(kind => kind >= 0))
|
|
220
|
+
if (!specific.length || !specific.every(part => said.has(part))) return false
|
|
221
|
+
return !kinds.size || [...kinds].some(kind => saidKinds.has(kind))
|
|
222
|
+
})
|
|
223
|
+
.map(repo => repo.name)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const screenCandidates = (result, graph, apps) => {
|
|
227
|
+
const appRepos = new Set(graph.repos.filter(repo => repo.kind === 'app' && apps.includes(repo.name)).map(repo => repo.name))
|
|
228
|
+
const groups = new Map()
|
|
229
|
+
result.files.forEach(item => {
|
|
230
|
+
const file = graph.files[item.id]
|
|
231
|
+
if (!appRepos.has(file.repo) || !isScreenFile(file)) return
|
|
232
|
+
const dir = screenOf(file.path)
|
|
233
|
+
const key = `${file.repo}\0${dir}`
|
|
234
|
+
const group = groups.get(key) || { repo: file.repo, dir, score: 0 }
|
|
235
|
+
group.score = Math.max(group.score, item.score)
|
|
236
|
+
groups.set(key, group)
|
|
237
|
+
})
|
|
238
|
+
return [...groups.values()]
|
|
239
|
+
.sort((a, b) => b.score - a.score)
|
|
240
|
+
.map(group => ({
|
|
241
|
+
label: humanize(Path.basename(group.dir)),
|
|
242
|
+
value: `${group.repo}/${group.dir}`,
|
|
243
|
+
description: describePlace(group.repo, group.dir),
|
|
244
|
+
score: group.score,
|
|
245
|
+
}))
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const shorten = (value, length) => (value.length > length ? `${value.slice(0, length).trim()}…` : value)
|
|
249
|
+
|
|
250
|
+
const summaryOf = (card, assumptions) => {
|
|
251
|
+
const labels = LABELS[card.type] || {}
|
|
252
|
+
const line = (field, fallback) => (card[field] ? `${labels[field] || fallback}: ${card[field]}` : null)
|
|
253
|
+
return [
|
|
254
|
+
card.ticket ? `Ticket: ${card.ticket}` : null,
|
|
255
|
+
`Type: ${card.type}`,
|
|
256
|
+
line('what', 'Task'),
|
|
257
|
+
line('who', 'Who'),
|
|
258
|
+
card.apps.length ? `Where: ${card.apps.join(', ')}` : null,
|
|
259
|
+
card.screen ? `Screen: ${card.screen}` : null,
|
|
260
|
+
line('how', 'How it works'),
|
|
261
|
+
line('steps', 'Steps'),
|
|
262
|
+
line('now', 'Now'),
|
|
263
|
+
line('expected', 'Expected'),
|
|
264
|
+
card.details ? `Details: ${shorten(card.details.replace(/\s+/g, ' '), 300)}` : null,
|
|
265
|
+
card.doneWhen.length ? `Done when:\n${card.doneWhen.map(check => ` - ${check}`).join('\n')}` : null,
|
|
266
|
+
assumptions.length ? `Assumed (say if wrong):\n${assumptions.map(item => ` - ${item}`).join('\n')}` : null,
|
|
267
|
+
]
|
|
268
|
+
.filter(Boolean)
|
|
269
|
+
.join('\n')
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const questionFor = (field, type) => {
|
|
273
|
+
if (field === 'what') return { header: 'Task', question: QUESTIONS.what[type] }
|
|
274
|
+
if (field === 'who') return QUESTIONS.who
|
|
275
|
+
if (field === 'how') return QUESTIONS.how[type]
|
|
276
|
+
if (field === 'now') return QUESTIONS.now[type]
|
|
277
|
+
if (field === 'steps') return QUESTIONS.steps
|
|
278
|
+
return null
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const checkTask = (input, { graph, search }) => {
|
|
282
|
+
const card = normalize(input || {})
|
|
283
|
+
const repoNames = new Set(graph.repos.map(repo => repo.name))
|
|
284
|
+
card.apps = card.apps.map(app => (/no screen change|backend/i.test(app) ? BACKEND_ONLY : app))
|
|
285
|
+
const otherPlaces = card.apps.filter(app => app !== BACKEND_ONLY && !repoNames.has(app))
|
|
286
|
+
if (otherPlaces.length) {
|
|
287
|
+
card.where = [card.where, ...otherPlaces].filter(Boolean).join('; ')
|
|
288
|
+
card.apps = card.apps.filter(app => !otherPlaces.includes(app))
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (!card.what) {
|
|
292
|
+
return {
|
|
293
|
+
status: 'needs_answers',
|
|
294
|
+
card: { ...card, round: card.round + 1 },
|
|
295
|
+
questions: [{ field: 'what', multiSelect: false, options: [], header: 'Task', question: 'What should change? One or two sentences, or paste the ticket.' }],
|
|
296
|
+
assumptions: [],
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const questions = []
|
|
301
|
+
const assumptions = []
|
|
302
|
+
const ask = (field, question) => {
|
|
303
|
+
if ((card.asked[field] || 0) >= MAX_ASKS) {
|
|
304
|
+
assumptions.push(`Not given: ${question.question}`)
|
|
305
|
+
return
|
|
306
|
+
}
|
|
307
|
+
questions.push({ field, multiSelect: false, options: [], ...question })
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const fromDetails = fromTicket(card)
|
|
311
|
+
if (fromDetails.length) assumptions.push(`Taken from the ticket text: ${fromDetails.join(', ')}`)
|
|
312
|
+
|
|
313
|
+
if (!card.type) {
|
|
314
|
+
card.type = guessType(taskText(card))
|
|
315
|
+
assumptions.push(`Type: ${card.type} (from the wording)`)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const result = search.find(taskText(card), { limit: 30, title: card.what })
|
|
319
|
+
|
|
320
|
+
let askedApps = false
|
|
321
|
+
if (!card.apps.length) {
|
|
322
|
+
const named = namedApps(card, graph)
|
|
323
|
+
const candidates = repoScores(result, graph)
|
|
324
|
+
const apps = candidates.filter(repo => repo.kind === 'app')
|
|
325
|
+
const backend = candidates.filter(repo => repo.kind !== 'app')
|
|
326
|
+
const backendFirst = backend.length && (!apps.length || backend[0].score >= apps[0].score * BACKEND_LEAD)
|
|
327
|
+
const pool = apps.length ? apps : candidates.filter(repo => repo.kind === 'api')
|
|
328
|
+
const close = pool.filter(repo => pool.length && repo.score >= pool[0].score * 0.35)
|
|
329
|
+
const others = graph.repos.filter(repo => repo.kind === 'app').map(repo => ({ name: repo.name, kind: repo.kind, score: 0 }))
|
|
330
|
+
const scored = [...new Map([...close, ...pool].map(repo => [repo.name, repo])).values()]
|
|
331
|
+
const offer = (scored.length >= 2 ? scored : [...new Map([...scored, ...others].map(repo => [repo.name, repo])).values()]).slice(0, 3)
|
|
332
|
+
if (named.length) {
|
|
333
|
+
card.apps = named
|
|
334
|
+
} else if (backendFirst && (card.asked.apps || 0) >= MAX_ASKS) {
|
|
335
|
+
card.apps = backend.filter(repo => repo.score >= backend[0].score * 0.35).map(repo => repo.name)
|
|
336
|
+
assumptions.push(`No screen change: ${card.apps.join(', ')} (closest match in the code)`)
|
|
337
|
+
} else if (offer.length >= 2 && (card.asked.apps || 0) < MAX_ASKS) {
|
|
338
|
+
askedApps = true
|
|
339
|
+
ask('apps', {
|
|
340
|
+
header: 'App',
|
|
341
|
+
question: 'Which app is this about?',
|
|
342
|
+
multiSelect: true,
|
|
343
|
+
options: [
|
|
344
|
+
...offer.slice(0, 3).map(repo => ({
|
|
345
|
+
label: repo.name,
|
|
346
|
+
value: repo.name,
|
|
347
|
+
description: result.files.some(file => file.repo === repo.name)
|
|
348
|
+
? `${KIND_LABELS[repo.kind]} · best match: ${humanize(Path.basename(screenOf(result.files.find(file => file.repo === repo.name).path)))}`
|
|
349
|
+
: KIND_LABELS[repo.kind],
|
|
350
|
+
})),
|
|
351
|
+
...(apps.length || backendFirst ? [{ label: 'No screen change', value: BACKEND_ONLY, description: 'Only the API / background jobs change' }] : []),
|
|
352
|
+
],
|
|
353
|
+
})
|
|
354
|
+
} else if (pool.length) {
|
|
355
|
+
card.apps = [pool[0].name]
|
|
356
|
+
assumptions.push(`App: ${pool[0].name} (closest match in the code)`)
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (card.apps.includes(BACKEND_ONLY)) {
|
|
361
|
+
card.apps = graph.repos.filter(repo => repo.kind !== 'app').map(repo => repo.name)
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (!filled(card, 'what')) ask('what', questionFor('what', card.type))
|
|
365
|
+
TEMPLATES[card.type]
|
|
366
|
+
.filter(field => !['what', 'where'].includes(field))
|
|
367
|
+
.filter(field => !(card.type === 'bug' && (field === 'now' || field === 'expected')))
|
|
368
|
+
.filter(field => !filled(card, field))
|
|
369
|
+
.forEach(field => ask(field, questionFor(field, card.type)))
|
|
370
|
+
if (card.type === 'bug' && (!filled(card, 'now') || !filled(card, 'expected'))) {
|
|
371
|
+
ask('now, expected', QUESTIONS['now, expected'])
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (!askedApps && card.apps.length && !card.screen) {
|
|
375
|
+
const screens = screenCandidates(search.find(taskText(card), { limit: 30, repos: card.apps, title: card.what }), graph, card.apps)
|
|
376
|
+
if (screens.length === 1 || (screens.length > 1 && screens[0].score >= screens[1].score * 1.6)) {
|
|
377
|
+
card.screen = `${screens[0].label} (${screens[0].description})`
|
|
378
|
+
assumptions.push(`Screen: ${card.screen}`)
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const titleWords = new Set(words(card.what))
|
|
383
|
+
const unknown = result.unknown.filter(word =>
|
|
384
|
+
word.length >= 4 && titleWords.has(word) && !BUG_WORDS.test(word) && !FEATURE_WORDS.test(word) && !COMMON_VERBS.test(word))
|
|
385
|
+
if (unknown.length && !card.asked.words) {
|
|
386
|
+
ask('words', {
|
|
387
|
+
target: 'details',
|
|
388
|
+
header: 'Words',
|
|
389
|
+
question: `The code never uses "${unknown.join('", "')}". What is it called on screen or in the code, if you know?`,
|
|
390
|
+
})
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const open = questions.slice(0, MAX_QUESTIONS)
|
|
394
|
+
if (!open.length || card.skip) {
|
|
395
|
+
questions.forEach(question => assumptions.push(`Not answered: ${question.question}`))
|
|
396
|
+
return { status: 'ready', card, assumptions, summary: summaryOf(card, assumptions), suggestChecks: !card.doneWhen.length }
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const asked = { ...card.asked }
|
|
400
|
+
open.forEach(question => {
|
|
401
|
+
asked[question.field] = (asked[question.field] || 0) + 1
|
|
402
|
+
})
|
|
403
|
+
return {
|
|
404
|
+
status: 'needs_answers',
|
|
405
|
+
card: { ...card, round: card.round + 1, asked },
|
|
406
|
+
questions: open,
|
|
407
|
+
assumptions,
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
module.exports = {
|
|
412
|
+
checkTask,
|
|
413
|
+
taskText,
|
|
414
|
+
BACKEND_ONLY,
|
|
415
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const Path = require('path').posix
|
|
4
|
+
const { TEST_FILE } = require('../shared')
|
|
5
|
+
|
|
6
|
+
const KIND_FILE = /^(.+?)\.([a-z][a-z0-9-]*)\.([a-z]+)$/
|
|
7
|
+
const MIN_KIND = 3
|
|
8
|
+
const MAX_KINDS = 7
|
|
9
|
+
const KIND_LABELS = { app: 'web or mobile app', api: 'API', shared: 'shared code' }
|
|
10
|
+
const TOOL_FILES = [
|
|
11
|
+
['ESLint', /^(eslint\.config\.[cm]?js|\.eslintrc(\.(js|cjs|json|ya?ml))?)$/],
|
|
12
|
+
['Prettier', /^(\.prettierrc(\.(js|cjs|json|ya?ml))?|prettier\.config\.[cm]?js)$/],
|
|
13
|
+
['EditorConfig', /^\.editorconfig$/],
|
|
14
|
+
['TypeScript', /^tsconfig\.json$/],
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
const styleOf = stem => {
|
|
18
|
+
if (/^[a-z0-9]+(-[a-z0-9]+)+$/.test(stem)) return 'kebab-case'
|
|
19
|
+
if (/^[a-z0-9]+(_[a-z0-9]+)+$/.test(stem)) return 'snake_case'
|
|
20
|
+
if (/^[a-z]+[a-z0-9]*[A-Z][A-Za-z0-9]*$/.test(stem)) return 'camelCase'
|
|
21
|
+
if (/^[A-Z][A-Za-z0-9]*$/.test(stem)) return 'PascalCase'
|
|
22
|
+
if (/^[a-z0-9]+$/.test(stem)) return 'lowercase'
|
|
23
|
+
return null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const majority = values => {
|
|
27
|
+
const counts = new Map()
|
|
28
|
+
values.filter(Boolean).forEach(value => counts.set(value, (counts.get(value) || 0) + 1))
|
|
29
|
+
return [...counts.entries()].sort((a, b) => b[1] - a[1])[0] || [null, 0]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const folderOf = (path, stem) => {
|
|
33
|
+
const dir = Path.dirname(path)
|
|
34
|
+
return [stem, stem.split('.')[0]].includes(Path.basename(dir)) ? `${Path.dirname(dir)}/<name>` : dir
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const kindsOf = files => {
|
|
38
|
+
const groups = new Map()
|
|
39
|
+
files.forEach(file => {
|
|
40
|
+
const match = Path.basename(file.path).match(KIND_FILE)
|
|
41
|
+
if (!match || TEST_FILE.test(file.path)) return
|
|
42
|
+
const key = `.${match[2]}.${match[3]}`
|
|
43
|
+
if (!groups.has(key)) groups.set(key, [])
|
|
44
|
+
groups.get(key).push({ path: file.path, stem: match[1] })
|
|
45
|
+
})
|
|
46
|
+
return [...groups.entries()]
|
|
47
|
+
.filter(([, items]) => items.length >= MIN_KIND)
|
|
48
|
+
.sort((a, b) => b[1].length - a[1].length)
|
|
49
|
+
.slice(0, MAX_KINDS)
|
|
50
|
+
.map(([suffix, items]) => {
|
|
51
|
+
const folders = new Map()
|
|
52
|
+
items.forEach(item => {
|
|
53
|
+
const folder = folderOf(item.path, item.stem)
|
|
54
|
+
folders.set(folder, (folders.get(folder) || 0) + 1)
|
|
55
|
+
})
|
|
56
|
+
const ranked = [...folders.entries()].sort((a, b) => b[1] - a[1])
|
|
57
|
+
const [folder, count] = ranked[0]
|
|
58
|
+
const places = count >= items.length / 2 || ranked.length < 2 ? folder : `${folder} (${count}), ${ranked[1][0]} (${ranked[1][1]}) and others`
|
|
59
|
+
const [style] = majority(items.map(item => styleOf(item.stem)).filter(value => value !== 'lowercase'))
|
|
60
|
+
const example = items.filter(item => folderOf(item.path, item.stem) === folder).sort((a, b) => a.path.length - b.path.length)[0] || items[0]
|
|
61
|
+
return { suffix, count: items.length, folder: places, style, example: example.path }
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const testsOf = files => {
|
|
66
|
+
const tests = files.filter(file => TEST_FILE.test(file.path))
|
|
67
|
+
if (!tests.length) return null
|
|
68
|
+
const [pattern] = majority(tests.map(file => {
|
|
69
|
+
const base = Path.basename(file.path)
|
|
70
|
+
const match = base.match(/\.(spec|test)\.[^.]+$/)
|
|
71
|
+
const top = file.path.split('/').find(part => /^(tests?|__tests__|spec)$/.test(part))
|
|
72
|
+
return top ? `${top}/ folder, named *${match ? match[0] : ''}` : `next to the file, named *${match ? match[0] : ''}`
|
|
73
|
+
}))
|
|
74
|
+
return { count: tests.length, pattern }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const toolsOf = repoIndex =>
|
|
78
|
+
TOOL_FILES.filter(([, pattern]) => (repoIndex.files || []).some(file => !file.path.includes('/') && pattern.test(file.path))).map(([name]) => name)
|
|
79
|
+
|
|
80
|
+
const repoConventions = (repo, repoIndex, files) => {
|
|
81
|
+
const kinds = kindsOf(files)
|
|
82
|
+
const tests = testsOf(files)
|
|
83
|
+
const aliases = (repoIndex.aliases || []).map(alias => alias.from).filter(from => from !== './').slice(0, 6)
|
|
84
|
+
const tools = toolsOf(repoIndex)
|
|
85
|
+
const lines = []
|
|
86
|
+
kinds.forEach(kind => {
|
|
87
|
+
lines.push(` ${kind.suffix.padEnd(16)} ${String(kind.count).padStart(4)} files in ${kind.folder}${kind.style ? `, ${kind.style}` : ''} · like ${kind.example}`)
|
|
88
|
+
})
|
|
89
|
+
if (tests) lines.push(` tests: ${tests.count}, ${tests.pattern}`)
|
|
90
|
+
if (aliases.length) lines.push(` imports use the aliases ${aliases.join(', ')}`)
|
|
91
|
+
if (tools.length) lines.push(` checked with ${tools.join(', ')} (changed files must pass)`)
|
|
92
|
+
return lines
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const conventionsFor = (graph, index, { focus = [] } = {}) => {
|
|
96
|
+
const byName = new Map(Object.values(index.repos || {}).map(repo => [repo.name, repo]))
|
|
97
|
+
const lines = ['HOW THIS PROJECT IS BUILT']
|
|
98
|
+
graph.repos.forEach(repo => {
|
|
99
|
+
const repoIndex = byName.get(repo.name) || { files: [], aliases: [] }
|
|
100
|
+
const files = graph.files.filter(file => file.repo === repo.name)
|
|
101
|
+
lines.push(`${repo.name} (${KIND_LABELS[repo.kind] || repo.kind}, ${files.length} code files${repoIndex.packageName ? `, package ${repoIndex.packageName}` : ''})`)
|
|
102
|
+
if (!focus.length || focus.includes(repo.name)) lines.push(...repoConventions(repo, repoIndex, files))
|
|
103
|
+
})
|
|
104
|
+
lines.push('New code copies the structure, naming and style of the files named above; new files go where files of the same kind live.')
|
|
105
|
+
return lines
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
module.exports = {
|
|
109
|
+
KIND_FILE,
|
|
110
|
+
styleOf,
|
|
111
|
+
majority,
|
|
112
|
+
conventionsFor,
|
|
113
|
+
}
|
package/src/task/gate.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const Path = require('path')
|
|
4
|
+
|
|
5
|
+
const WAITING = ['plan-review', 'pm-review', 'changes-requested']
|
|
6
|
+
const WAITING_HOURS = 12
|
|
7
|
+
|
|
8
|
+
const STATUS = {
|
|
9
|
+
'plan-review': 'waiting for the developer to approve the plan',
|
|
10
|
+
'pm-review': 'waiting for PM approval',
|
|
11
|
+
'changes-requested': 'sent back for changes',
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const { TEST_FILE } = require('../shared')
|
|
15
|
+
|
|
16
|
+
const inside = (file, dir) => file === dir || file.startsWith(`${dir}${Path.sep}`)
|
|
17
|
+
|
|
18
|
+
const repoOf = (file, repos) =>
|
|
19
|
+
repos
|
|
20
|
+
.filter(repo => inside(file, repo.path))
|
|
21
|
+
.sort((a, b) => b.path.length - a.path.length)[0] || null
|
|
22
|
+
|
|
23
|
+
const label = (taskKey, ticket) => (ticket ? `${ticket} (${taskKey})` : taskKey)
|
|
24
|
+
|
|
25
|
+
const checkEdit = ({ file, repos, active, tasks, currentBranch, now = Date.now() }) => {
|
|
26
|
+
const repo = repoOf(file, repos)
|
|
27
|
+
if (!repo) return { allow: true }
|
|
28
|
+
const relative = Path.relative(repo.path, file).split(Path.sep).join('/')
|
|
29
|
+
const key = `${repo.name}/${relative}`
|
|
30
|
+
|
|
31
|
+
const projectBuilds = Object.entries(active).filter(([, build]) => String(build.projectId) === String(repo.projectId))
|
|
32
|
+
const builds = projectBuilds.filter(([, build]) => build.repos.some(entry => entry.name === repo.name))
|
|
33
|
+
if (projectBuilds.length && !builds.length) {
|
|
34
|
+
const [taskKey, build] = projectBuilds[0]
|
|
35
|
+
return {
|
|
36
|
+
allow: false,
|
|
37
|
+
reason: `tspl: ${repo.name} is not part of the approved build for ${label(taskKey, build.ticket)} (${build.repos.map(entry => entry.name).join(', ')}). If it has to change, stop and tell the user, then submit a new plan version for approval.`,
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (builds.length) {
|
|
41
|
+
const [taskKey, build] = builds.find(([, entry]) => entry.files.includes(key)) || builds[0]
|
|
42
|
+
const branch = currentBranch(repo.path)
|
|
43
|
+
if (branch && branch !== build.branch) {
|
|
44
|
+
return {
|
|
45
|
+
allow: false,
|
|
46
|
+
reason: `tspl: ${repo.name} is on branch "${branch}", but the approved build for ${label(taskKey, build.ticket)} works on "${build.branch}". Switch first: git -C ${repo.path} switch ${build.branch}`,
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (!build.files.includes(key) && !TEST_FILE.test(relative)) {
|
|
50
|
+
return {
|
|
51
|
+
allow: false,
|
|
52
|
+
reason: `tspl: ${key} is not on the approved checklist for ${label(taskKey, build.ticket)}. If it has to change, stop and tell the user, then submit a new plan version for approval.`,
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return { allow: true }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const waiting = tasks
|
|
59
|
+
.filter(task => String(task.projectId) === String(repo.projectId) && WAITING.includes(task.status))
|
|
60
|
+
.filter(task => now - new Date(task.updatedAt || task.createdAt || 0).getTime() < WAITING_HOURS * 3600 * 1000)
|
|
61
|
+
.sort((a, b) => new Date(b.updatedAt || 0) - new Date(a.updatedAt || 0))[0]
|
|
62
|
+
if (waiting) {
|
|
63
|
+
return {
|
|
64
|
+
allow: false,
|
|
65
|
+
reason: `tspl: task ${label(waiting.id, waiting.card && waiting.card.ticket)} is ${STATUS[waiting.status]}; no code changes until its plan is approved (tspl plan show ${waiting.id}). To drop the task: tspl build stop ${waiting.id}`,
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return { allow: true }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const checkFiles = ({ taskKey, build, repoName, branch, files }) => {
|
|
72
|
+
const reasons = []
|
|
73
|
+
const name = label(taskKey, build.ticket)
|
|
74
|
+
if (branch && branch !== build.branch) {
|
|
75
|
+
reasons.push(`tspl: ${repoName} is on branch "${branch}", but the approved build for ${name} works on "${build.branch}".`)
|
|
76
|
+
}
|
|
77
|
+
const off = files.filter(file => !build.files.includes(`${repoName}/${file}`) && !TEST_FILE.test(file))
|
|
78
|
+
if (off.length) {
|
|
79
|
+
reasons.push(`tspl: not on the approved checklist for ${name}: ${off.join(', ')}. Submit a new plan version for approval, or stop the task: tspl build stop ${taskKey}`)
|
|
80
|
+
}
|
|
81
|
+
return { allow: !reasons.length, reasons }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
module.exports = {
|
|
85
|
+
TEST_FILE,
|
|
86
|
+
checkEdit,
|
|
87
|
+
checkFiles,
|
|
88
|
+
}
|